diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs index 03f8830a8c8..0caf12be281 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs @@ -60,14 +60,13 @@ public override DateTimeOffset GetUtcNow() /// The date and time in the UTC timezone. public void SetUtcNow(DateTimeOffset value) { - List waiters; - lock (Waiters) + while (_now <= value && TryGetWaiterToWake(value) is FakeTimeProviderTimer.Waiter waiter) { - _now = value; - waiters = GetWaitersToWake(); + _now = waiter.WakeupTime; + waiter.TriggerAndSchedule(false); } - WakeWaiters(waiters); + _now = value; } /// @@ -75,16 +74,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. @@ -163,28 +153,43 @@ 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) + // Search through Waiters and find the waiter with the minimum + // WakeupTime which is less than or equal to targetNow. + 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..9ac58602567 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProviderTimer.cs +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProviderTimer.cs @@ -72,7 +72,15 @@ internal sealed class Waiter : IDisposable private long _periodMs; private long _dueTimeMs; - public DateTimeOffset WakeupTime { get; set; } + + /// + /// Gets the timestamp for when the s + /// was last set. This is used to ensure + /// 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,14 +99,13 @@ 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) { @@ -114,10 +121,9 @@ public void TriggerAndSchedule(bool restart) } else { - // Schedule next event on dueTime - - WakeupTime = _fakeTimeProvider.GetUtcNow() + TimeSpan.FromMilliseconds(_dueTimeMs); - + // Schedule next event on dueTime. + // Update the current time to ensure scheduling order is preserved. + ScheduleTimer(_dueTimeMs); return; } } @@ -130,11 +136,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 +148,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/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs index f085e2712ab..9cad76b844a 100644 --- a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs @@ -76,7 +76,7 @@ public void RichCtor() Assert.Equal(6, now.Second); Assert.Equal(16, now.Millisecond); Assert.Equal(frequency, frequency2); - Assert.True(pnow2 > pnow); + Assert.True(pnow2 > pnow); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTimerTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTimerTests.cs index c605235104d..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; @@ -131,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() { @@ -273,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); + } }