diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs index c2590f65164..936ef4d6abd 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs @@ -4,8 +4,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Threading; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Time.Testing; @@ -14,13 +16,22 @@ namespace Microsoft.Extensions.Time.Testing; /// public class FakeTimeProvider : TimeProvider { - internal static readonly DateTimeOffset Epoch = new(2000, 1, 1, 0, 0, 0, 0, TimeSpan.Zero); - internal readonly List Waiters = new(); - private DateTimeOffset _now = Epoch; + private DateTimeOffset _now; private TimeZoneInfo _localTimeZone; + /// + /// Gets the time which was used as the starting point for the clock in this . + /// + /// + /// This can be set by passing in a to the constructor + /// which takes the epoch argument. If the default constructor is used, + /// the clocks start time defaults to midnight January 1st 2000. + /// + [Experimental] + public DateTimeOffset Epoch { get; } = new DateTimeOffset(2000, 1, 1, 0, 0, 0, 0, TimeSpan.Zero); + /// /// Initializes a new instance of the class. /// @@ -31,16 +42,19 @@ public class FakeTimeProvider : TimeProvider public FakeTimeProvider() { _localTimeZone = TimeZoneInfo.Utc; + _now = Epoch; } /// /// Initializes a new instance of the class. /// - /// The initial time reported by the clock. - public FakeTimeProvider(DateTimeOffset startTime) + /// The starting point for the clock used by this . + [Experimental] + public FakeTimeProvider(DateTimeOffset epoch) : this() { - _now = startTime; + Epoch = epoch; + _now = epoch; } /// @@ -55,14 +69,18 @@ public override DateTimeOffset GetUtcNow() /// The date and time in the UTC timezone. public void SetUtcNow(DateTimeOffset value) { - List waiters; - lock (Waiters) + if (value < _now) + { + Throw.ArgumentOutOfRangeException(nameof(value), $"Cannot go back in time. Current time is {GetUtcNow()}."); + } + + while (value >= _now && TryGetWaiterToWake(value) is FakeTimeProviderTimer.Waiter waiter) { - _now = value; - waiters = GetWaitersToWake(); + _now = waiter.WakeupTime; + waiter.TriggerAndSchedule(false); } - WakeWaiters(waiters); + _now = value; } /// @@ -70,16 +88,7 @@ public void SetUtcNow(DateTimeOffset value) /// /// The amount of time to advance the clock by. public void Advance(TimeSpan delta) - { - List waiters; - lock (Waiters) - { - _now += delta; - waiters = GetWaitersToWake(); - } - - WakeWaiters(waiters); - } + => SetUtcNow(_now + delta); /// /// Advances the clock's time by one millisecond. @@ -158,28 +167,39 @@ internal void RemoveWaiter(FakeTimeProviderTimer.Waiter waiter) } } - private List GetWaitersToWake() + private FakeTimeProviderTimer.Waiter? TryGetWaiterToWake(DateTimeOffset targetNow) { - var l = new List(Waiters.Count); - foreach (var w in Waiters) + var candidate = default(FakeTimeProviderTimer.Waiter); + + lock (Waiters) { - if (_now >= w.WakeupTime) + if (Waiters.Count == 0) { - l.Add(w); + return null; } - } - - return l; - } - private void WakeWaiters(List waiters) - { - foreach (var w in waiters) - { - if (_now >= w.WakeupTime) + foreach (var waiter in Waiters) { - w.TriggerAndSchedule(false); + if (waiter.WakeupTime > targetNow) + { + continue; + } + + if (candidate is null) + { + candidate = waiter; + continue; + } + + // This finds the waiter with the minimum WakeupTime and also ensures that if multiple waiters have the same + // the one that is picked is also the one that was scheduled first. + candidate = candidate.WakeupTime > waiter.WakeupTime + || (candidate.WakeupTime == waiter.WakeupTime && candidate.ScheduledOn > waiter.ScheduledOn) + ? waiter + : candidate; } } + + return candidate; } } diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProviderTimer.cs b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProviderTimer.cs index 45989de13dc..5994b460269 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProviderTimer.cs +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProviderTimer.cs @@ -72,7 +72,16 @@ internal sealed class Waiter : IDisposable private long _periodMs; private long _dueTimeMs; - public DateTimeOffset WakeupTime { get; set; } + + /// + /// Gets the timestamp for when the was last set. + /// + /// + /// This property ensures timer callbacks are invoked in the order they were scheduled. + /// + public long ScheduledOn { get; private set; } + + public DateTimeOffset WakeupTime { get; private set; } public Waiter(FakeTimeProvider fakeTimeProvider, TimeSpan dueTime, TimeSpan period, TimerCallback callback, object? state) { @@ -91,33 +100,29 @@ public void ChangeAndValidateDurations(TimeSpan dueTime, TimeSpan period) _ = Throw.IfOutOfRange(_dueTimeMs, -1, MaxSupportedTimeout, nameof(dueTime)); _ = Throw.IfOutOfRange(_periodMs, -1, MaxSupportedTimeout, nameof(period)); #pragma warning restore S3236 // Caller information arguments should not be provided explicitly - } public void TriggerAndSchedule(bool restart) { if (restart) { - WakeupTime = DateTimeOffset.MaxValue; + DisableTimer(); if (_dueTimeMs == 0) { - // If dueTime is zero, callback is invoked immediately + // If dueTime is zero, callback is invoked immediately. _callback(_state); } else if (_dueTimeMs == Timeout.Infinite) { - // If dueTime is Timeout.Infinite, callback is not invoked; the timer is disabled + // If dueTime is Timeout.Infinite, callback is not invoked; the timer is disabled. return; } else { - // Schedule next event on dueTime - - WakeupTime = _fakeTimeProvider.GetUtcNow() + TimeSpan.FromMilliseconds(_dueTimeMs); - + ScheduleTimer(_dueTimeMs); return; } } @@ -130,11 +135,11 @@ public void TriggerAndSchedule(bool restart) if (_periodMs == 0 || _periodMs == Timeout.Infinite) { - WakeupTime = DateTimeOffset.MaxValue; + DisableTimer(); } else { - WakeupTime = _fakeTimeProvider.GetUtcNow() + TimeSpan.FromMilliseconds(_periodMs); + ScheduleTimer(_periodMs); } } @@ -142,5 +147,17 @@ public void Dispose() { _fakeTimeProvider.RemoveWaiter(this); } + + private void DisableTimer() + { + ScheduledOn = long.MaxValue; + WakeupTime = DateTimeOffset.MaxValue; + } + + private void ScheduleTimer(long delayMilliseconds) + { + ScheduledOn = _fakeTimeProvider.GetTimestamp(); + WakeupTime = _fakeTimeProvider.GetUtcNow() + TimeSpan.FromMilliseconds(delayMilliseconds); + } } } diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj index 4f69cf72e92..1f89c0e21c9 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj @@ -5,6 +5,7 @@ Fundamentals Testing $(PackageTags);Testing + true diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Telemetry.Internal/SelfDiagnosticsConfigRefresherTest.cs b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Telemetry.Internal/SelfDiagnosticsConfigRefresherTest.cs index 5bb71191ede..b3bfeede4c3 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Telemetry.Internal/SelfDiagnosticsConfigRefresherTest.cs +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Telemetry.Internal/SelfDiagnosticsConfigRefresherTest.cs @@ -137,7 +137,7 @@ public void TryGetLogStream_WhenViewStreamDisposed_ReturnsFalse() public async Task SelfDiagnosticsConfigRefresher_WhenConfigDisappearsAndAppearsBack_CaptureAsConfigured() { const string LogFileName = "withUnreliableConfig.log"; - var timeProvider = new FakeTimeProvider(startTime: DateTime.UtcNow); + var timeProvider = new FakeTimeProvider(epoch: DateTime.UtcNow); var parserMock = new Mock(); var configFileContentInitial = @"{""LogDirectory"": ""."", ""FileSize"": 1024, ""LogLevel"": ""Verbose""}"; var configFileContentNew = @"{""LogDirectory"": ""."", ""FileSize"": 1025, ""LogLevel"": ""Verbose""}"; @@ -174,7 +174,7 @@ public async Task SelfDiagnosticsConfigRefresher_WhenConfigDisappearsAndAppearsB public async Task SelfDiagnosticsConfigRefresher_WhenLogLevelUpdated_CaptureAsConfigured() { const string LogFileName = "withNewLogLevel.log"; - var timeProvider = new FakeTimeProvider(startTime: DateTime.UtcNow); + var timeProvider = new FakeTimeProvider(epoch: DateTime.UtcNow); var parserMock = new Mock(); var configFileContentInitial = @"{""LogDirectory"": ""."", ""FileSize"": 1024, ""LogLevel"": ""Error""}"; var configFileContentNew = @"{""LogDirectory"": ""."", ""FileSize"": 1024, ""LogLevel"": ""Verbose""}"; diff --git a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs index cca94a3e5b1..50fbdb17d5f 100644 --- a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs @@ -20,6 +20,7 @@ public void DefaultCtor() var timestamp = timeProvider.GetTimestamp(); var frequency = timeProvider.TimestampFrequency; + Assert.Equal(timeProvider.Epoch, now); Assert.Equal(2000, now.Year); Assert.Equal(1, now.Month); Assert.Equal(1, now.Day); @@ -32,17 +33,12 @@ public void DefaultCtor() var timestamp2 = timeProvider.GetTimestamp(); var frequency2 = timeProvider.TimestampFrequency; - now = timeProvider.GetUtcNow(); + var now2 = timeProvider.GetUtcNow(); - Assert.Equal(2000, now.Year); - Assert.Equal(1, now.Month); - Assert.Equal(1, now.Day); - Assert.Equal(0, now.Hour); - Assert.Equal(0, now.Minute); - Assert.Equal(0, now.Second); - Assert.Equal(0, now.Millisecond); - Assert.Equal(10_000_000, frequency2); - Assert.Equal(timestamp2, timestamp); + Assert.Equal(timeProvider.Epoch, now2); + Assert.Equal(now, now2); + Assert.Equal(frequency, frequency2); + Assert.Equal(timestamp, timestamp2); } [Fact] @@ -55,6 +51,7 @@ public void RichCtor() var frequency = timeProvider.TimestampFrequency; var now = timeProvider.GetUtcNow(); + Assert.Equal(timeProvider.Epoch + TimeSpan.FromMilliseconds(8), now); Assert.Equal(2001, now.Year); Assert.Equal(2, now.Month); Assert.Equal(3, now.Day); @@ -70,6 +67,7 @@ public void RichCtor() var frequency2 = timeProvider.TimestampFrequency; now = timeProvider.GetUtcNow(); + Assert.Equal(timeProvider.Epoch + TimeSpan.FromMilliseconds(16), now); Assert.Equal(2001, now.Year); Assert.Equal(2, now.Month); Assert.Equal(3, now.Day); @@ -77,7 +75,7 @@ public void RichCtor() Assert.Equal(5, now.Minute); Assert.Equal(6, now.Second); Assert.Equal(16, now.Millisecond); - Assert.Equal(10_000_000, frequency2); + Assert.Equal(frequency, frequency2); Assert.True(pnow2 > pnow); } @@ -138,6 +136,15 @@ public void AdvanceGoesForward() Assert.Equal(1234, elapsedTime.TotalMilliseconds); } + [Fact] + public void TimeCannotGoBackwards() + { + var timeProvider = new FakeTimeProvider(); + + Assert.Throws(() => timeProvider.Advance(TimeSpan.FromTicks(-1))); + Assert.Throws(() => timeProvider.SetUtcNow(timeProvider.Epoch - TimeSpan.FromTicks(1))); + } + [Fact] public void ToStr() { @@ -173,7 +180,7 @@ public async Task Delay_Timeout() var timeProvider = new FakeTimeProvider(); var delay = timeProvider.Delay(TimeSpan.FromMilliseconds(1), CancellationToken.None); - timeProvider.Advance(); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); await delay; Assert.True(delay.IsCompleted); @@ -203,7 +210,7 @@ public async Task CreateSource() var timeProvider = new FakeTimeProvider(); using var cts = timeProvider.CreateCancellationTokenSource(TimeSpan.FromMilliseconds(1)); - timeProvider.Advance(); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); await Assert.ThrowsAsync(() => timeProvider.Delay(TimeSpan.FromTicks(1), cts.Token)); } @@ -224,7 +231,7 @@ public async Task WaitAsync() var t = source.Task.WaitAsync(TimeSpan.FromSeconds(100000), timeProvider, CancellationToken.None); while (!t.IsCompleted) { - timeProvider.Advance(); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); await Task.Delay(1); _ = source.TrySetResult(true); } diff --git a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTimerTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTimerTests.cs index 64cb4aececc..f894c8d3b8a 100644 --- a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTimerTests.cs +++ b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTimerTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Time.Testing; @@ -24,6 +25,7 @@ public void TimerNonPeriodicPeriodZero() using var timer = timeProvider.CreateTimer(_ => { counter++; }, null, TimeSpan.FromMilliseconds(10), TimeSpan.Zero); var value1 = counter; + timeProvider.Advance(TimeSpan.FromMilliseconds(20)); var value2 = counter; @@ -45,6 +47,7 @@ public void TimerNonPeriodicPeriodInfinite() using var timer = timeProvider.CreateTimer(_ => { counter++; }, null, TimeSpan.FromMilliseconds(10), Timeout.InfiniteTimeSpan); var value1 = counter; + timeProvider.Advance(TimeSpan.FromMilliseconds(20)); var value2 = counter; @@ -129,23 +132,6 @@ public void TimerTriggersPeriodically() Assert.Equal(3, value4); } - [Fact] - public void LongPausesTriggerSingleCallback() - { - var counter = 0; - var timeProvider = new FakeTimeProvider(); - var timer = timeProvider.CreateTimer(_ => { counter++; }, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(10)); - - var value1 = counter; - - timeProvider.Advance(TimeSpan.FromMilliseconds(100)); - - var value2 = counter; - - Assert.Equal(1, value1); - Assert.Equal(2, value2); - } - [Fact] public async Task TaskDelayWithFakeTimeProviderAdvanced() { @@ -271,4 +257,77 @@ public void WaiterRemovedWhenCollectedWithoutDispose() Assert.Equal(1, waitersCountAfter); } #endif + + [Fact] + public void UtcNowUpdatedBeforeTimerCallback() + { + var timeProvider = new FakeTimeProvider(); + var callbackTime = DateTimeOffset.MinValue; + using var timer = timeProvider.CreateTimer(_ => { callbackTime = timeProvider.GetUtcNow(); }, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(10)); + + var value1 = callbackTime; + + timeProvider.SetUtcNow(timeProvider.Epoch + TimeSpan.FromMilliseconds(20)); + + var value2 = callbackTime; + + timeProvider.SetUtcNow(timeProvider.Epoch + TimeSpan.FromMilliseconds(1000)); + + var value3 = callbackTime; + + Assert.Equal(timeProvider.Epoch, value1); + Assert.Equal(timeProvider.Epoch + TimeSpan.FromMilliseconds(20), value2); + Assert.Equal(timeProvider.Epoch + TimeSpan.FromMilliseconds(1000), value3); + } + + [Fact] + public void LongPausesTriggerMultipleCallbacks() + { + var callbackTimes = new List(); + var timeProvider = new FakeTimeProvider(); + var period = TimeSpan.FromMilliseconds(10); + var timer = timeProvider.CreateTimer(_ => { callbackTimes.Add(timeProvider.GetUtcNow()); }, null, TimeSpan.Zero, period); + + var value1 = callbackTimes.ToArray(); + + timeProvider.Advance(period + period + period); + + var value2 = callbackTimes.ToArray(); + + Assert.Equal(new[] { timeProvider.Epoch }, value1); + Assert.Equal(new[] + { + timeProvider.Epoch, + timeProvider.Epoch + period, + timeProvider.Epoch + period + period, + timeProvider.Epoch + period + period + period, + }, + value2); + } + + [Fact] + public void MultipleTimersCallbackInvokedInScheduledOrder() + { + var callbacks = new List<(int timerId, TimeSpan callbackTime)>(); + var timeProvider = new FakeTimeProvider(); + var startTime = timeProvider.GetTimestamp(); + using var timer1 = timeProvider.CreateTimer(_ => callbacks.Add((1, timeProvider.GetElapsedTime(startTime))), null, TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(3)); + using var timer2 = timeProvider.CreateTimer(_ => callbacks.Add((2, timeProvider.GetElapsedTime(startTime))), null, TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(3)); + using var timer3 = timeProvider.CreateTimer(_ => callbacks.Add((3, timeProvider.GetElapsedTime(startTime))), null, TimeSpan.FromMilliseconds(6), TimeSpan.FromMilliseconds(5)); + + timeProvider.Advance(TimeSpan.FromMilliseconds(11)); + + Assert.Equal(new[] + { + (1, TimeSpan.FromMilliseconds(3)), + (2, TimeSpan.FromMilliseconds(3)), + (3, TimeSpan.FromMilliseconds(6)), + (1, TimeSpan.FromMilliseconds(6)), + (2, TimeSpan.FromMilliseconds(6)), + (1, TimeSpan.FromMilliseconds(9)), + (2, TimeSpan.FromMilliseconds(9)), + (3, TimeSpan.FromMilliseconds(11)), + }, + callbacks); + } }