diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7006f..a91ce75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,14 @@ All notable changes to TimeScheduler will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] -## [1.0.0-preview.2] +## [1.0.0-preview.3] -- Changed ManualTestProvider sets the local time zone to UTC by default, provides method for overriding during testing. - Changed `ManualTestProvider` sets the local time zone to UTC by default, provides method for overriding during testing. - Changed `ManualTestProvider.ToString()` method to return current date time. + +- Fixed `ITimer` returned by `ManualTestProvider` such that timers created with a due time equal to zero will fire the timer callback immediately. + ## [1.0.0-preview.1] This release adds a dependency on [Microsoft.Bcl.TimeProvider](https://www.nuget.org/packages/Microsoft.Bcl.TimeProvider) and utilizes the types built-in to that to do much of the work. diff --git a/src/TimeProviderExtensions/ManualTimeProvider.cs b/src/TimeProviderExtensions/ManualTimeProvider.cs index a5dce33..badf38c 100644 --- a/src/TimeProviderExtensions/ManualTimeProvider.cs +++ b/src/TimeProviderExtensions/ManualTimeProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; using System.Runtime.CompilerServices; using System.Text; @@ -21,6 +22,7 @@ public class ManualTimeProvider : TimeProvider { internal const uint MaxSupportedTimeout = 0xfffffffe; internal const uint UnsignedInfinite = unchecked((uint)-1); + internal static readonly DateTimeOffset Epoch = new(2000, 1, 1, 0, 0, 0, 0, TimeSpan.Zero); private readonly List futureCallbacks = new(); private DateTimeOffset utcNow; @@ -40,15 +42,12 @@ public class ManualTimeProvider : TimeProvider /// /// Creates an instance of the with - /// being the initial value returned by . + /// UtcNow set to 2000-01-01 00:00:00.000. /// - public ManualTimeProvider() - : this(System.GetUtcNow()) /// Optional local time zone to use during testing. Defaults to . public ManualTimeProvider(TimeZoneInfo? localTimeZone = null) : this(Epoch) { - this.localTimeZone = localTimeZone ?? TimeZoneInfo.Utc; } @@ -225,7 +224,9 @@ private void ScheduleCallback(ManualTimer timer, TimeSpan waitTime) var insertPosition = futureCallbacks.FindIndex(x => x.CallbackTime > mtsc.CallbackTime); if (insertPosition == -1) + { futureCallbacks.Add(mtsc); + } else { futureCallbacks.Insert(insertPosition, mtsc); @@ -341,7 +342,7 @@ internal void TimerElapsed() callback?.Invoke(state); - if (currentPeriod != Timeout.InfiniteTimeSpan) + if (currentPeriod != Timeout.InfiniteTimeSpan && currentPeriod != TimeSpan.Zero) ScheduleCallback(currentPeriod); } @@ -351,7 +352,15 @@ private void ScheduleCallback(TimeSpan waitTime) return; running = true; - owner.ScheduleCallback(this, waitTime); + + if (waitTime == TimeSpan.Zero) + { + TimerElapsed(); + } + else + { + owner.ScheduleCallback(this, waitTime); + } } private static void ValidateTimeSpanRange(TimeSpan time, [CallerArgumentExpression("time")] string? parameter = null) diff --git a/test/TimeProviderExtensions.Tests/Microsoft.Extensions.Time.Testing.Test/FakeTimeProviderTests.cs b/test/TimeProviderExtensions.Tests/Microsoft.Extensions.Time.Testing.Test/FakeTimeProviderTests.cs new file mode 100644 index 0000000..f19bc85 --- /dev/null +++ b/test/TimeProviderExtensions.Tests/Microsoft.Extensions.Time.Testing.Test/FakeTimeProviderTests.cs @@ -0,0 +1,294 @@ +#if TargetMicrosoftTestTimeProvider +using SutTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider; +#else +using SutTimeProvider = TimeProviderExtensions.ManualTimeProvider; +#endif + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Microsoft.Extensions.Time.Testing.Test; + +public class FakeTimeProviderTests +{ + [Fact] + public void DefaultCtor() + { + var timeProvider = new SutTimeProvider(); + + var now = timeProvider.GetUtcNow(); + var timestamp = timeProvider.GetTimestamp(); + var frequency = timeProvider.TimestampFrequency; + + 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(TimeSpan.Zero, now.Offset); + Assert.Equal(10_000_000, frequency); + + var timestamp2 = timeProvider.GetTimestamp(); + var frequency2 = timeProvider.TimestampFrequency; + now = 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); + } + + [Fact] + public void RichCtor() + { + var timeProvider = new SutTimeProvider(new DateTimeOffset(2001, 2, 3, 4, 5, 6, TimeSpan.Zero)); + + timeProvider.Advance(TimeSpan.FromMilliseconds(8)); + var pnow = timeProvider.GetTimestamp(); + var frequency = timeProvider.TimestampFrequency; + var now = timeProvider.GetUtcNow(); + + Assert.Equal(2001, now.Year); + Assert.Equal(2, now.Month); + Assert.Equal(3, now.Day); + Assert.Equal(4, now.Hour); + Assert.Equal(5, now.Minute); + Assert.Equal(6, now.Second); + Assert.Equal(TimeSpan.Zero, now.Offset); + Assert.Equal(8, now.Millisecond); + Assert.Equal(10_000_000, frequency); + + timeProvider.Advance(TimeSpan.FromMilliseconds(8)); + var pnow2 = timeProvider.GetTimestamp(); + var frequency2 = timeProvider.TimestampFrequency; + now = timeProvider.GetUtcNow(); + + Assert.Equal(2001, now.Year); + Assert.Equal(2, now.Month); + Assert.Equal(3, now.Day); + Assert.Equal(4, now.Hour); + Assert.Equal(5, now.Minute); + Assert.Equal(6, now.Second); + Assert.Equal(16, now.Millisecond); + Assert.Equal(10_000_000, frequency2); + Assert.True(pnow2 > pnow); + } + + [Fact] + public void LocalTimeZoneIsUtc() + { + var timeProvider = new SutTimeProvider(); + var localTimeZone = timeProvider.LocalTimeZone; + + Assert.Equal(TimeZoneInfo.Utc, localTimeZone); + } + + [Fact] + public void GetTimestampSyncWithUtcNow() + { + var timeProvider = new SutTimeProvider(new DateTimeOffset(2001, 2, 3, 4, 5, 6, TimeSpan.Zero)); + + var initialTimeUtcNow = timeProvider.GetUtcNow(); + var initialTimestamp = timeProvider.GetTimestamp(); + + timeProvider.SetUtcNow(timeProvider.GetUtcNow().AddMilliseconds(1234)); + + var finalTimeUtcNow = timeProvider.GetUtcNow(); + var finalTimeTimestamp = timeProvider.GetTimestamp(); + + var utcDelta = finalTimeUtcNow - initialTimeUtcNow; + var perfDelta = finalTimeTimestamp - initialTimestamp; + var elapsedTime = timeProvider.GetElapsedTime(initialTimestamp, finalTimeTimestamp); + + Assert.Equal(1, utcDelta.Seconds); + Assert.Equal(234, utcDelta.Milliseconds); + Assert.Equal(1234D, utcDelta.TotalMilliseconds); + Assert.Equal(1.234D, (double)perfDelta / timeProvider.TimestampFrequency, 3); + Assert.Equal(1234, elapsedTime.TotalMilliseconds); + } + + [Fact] + public void AdvanceGoesForward() + { + var timeProvider = new SutTimeProvider(new DateTimeOffset(2001, 2, 3, 4, 5, 6, TimeSpan.Zero)); + + var initialTimeUtcNow = timeProvider.GetUtcNow(); + var initialTimestamp = timeProvider.GetTimestamp(); + + timeProvider.Advance(TimeSpan.FromMilliseconds(1234)); + + var finalTimeUtcNow = timeProvider.GetUtcNow(); + var finalTimeTimestamp = timeProvider.GetTimestamp(); + + var utcDelta = finalTimeUtcNow - initialTimeUtcNow; + var perfDelta = finalTimeTimestamp - initialTimestamp; + var elapsedTime = timeProvider.GetElapsedTime(initialTimestamp, finalTimeTimestamp); + + Assert.Equal(1, utcDelta.Seconds); + Assert.Equal(234, utcDelta.Milliseconds); + Assert.Equal(1234D, utcDelta.TotalMilliseconds); + Assert.Equal(1.234D, (double)perfDelta / timeProvider.TimestampFrequency, 3); + Assert.Equal(1234, elapsedTime.TotalMilliseconds); + } + + [Fact] + public void ToStr() + { + var dto = new DateTimeOffset(new DateTime(2022, 1, 2, 3, 4, 5, 6), TimeSpan.Zero); + + var timeProvider = new SutTimeProvider(dto); + Assert.Equal("2022-01-02T03:04:05.006", timeProvider.ToString()); + } + + private readonly TimeSpan _infiniteTimeout = TimeSpan.FromMilliseconds(-1); + + [Fact] + public void Delay_InvalidArgs() + { + var timeProvider = new SutTimeProvider(); + _ = Assert.ThrowsAsync(() => timeProvider.Delay(TimeSpan.FromTicks(-1), CancellationToken.None)); + _ = Assert.ThrowsAsync(() => timeProvider.Delay(_infiniteTimeout, CancellationToken.None)); + } + + [Fact] + public async Task Delay_Zero() + { + var timeProvider = new SutTimeProvider(); + var t = timeProvider.Delay(TimeSpan.Zero, CancellationToken.None); + await t; + + Assert.True(t.IsCompleted && !t.IsFaulted); + } + + [Fact] + public async Task Delay_Timeout() + { + var timeProvider = new SutTimeProvider(); + + var delay = timeProvider.Delay(TimeSpan.FromMilliseconds(1), CancellationToken.None); + timeProvider.Advance(); + await delay; + + Assert.True(delay.IsCompleted); + Assert.False(delay.IsFaulted); + Assert.False(delay.IsCanceled); + } + + [Fact] + public async Task Delay_Cancelled() + { + var timeProvider = new SutTimeProvider(); + + using var cs = new CancellationTokenSource(); + var delay = timeProvider.Delay(_infiniteTimeout, cs.Token); + Assert.False(delay.IsCompleted); + + cs.Cancel(); + +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks + await Assert.ThrowsAsync(async () => await delay); +#pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks + } + + [Fact] + public async Task CreateSource() + { + var timeProvider = new SutTimeProvider(); + + using var cts = timeProvider.CreateCancellationTokenSource(TimeSpan.FromMilliseconds(1)); + timeProvider.Advance(); + + await Assert.ThrowsAsync(() => timeProvider.Delay(TimeSpan.FromTicks(1), cts.Token)); + } + + [Fact] + public async Task WaitAsync() + { + var timeProvider = new SutTimeProvider(); + var source = new TaskCompletionSource(); + +#if NET8_0_OR_GREATER + await Assert.ThrowsAsync(() => source.Task.WaitAsync(TimeSpan.FromTicks(-1), timeProvider, CancellationToken.None)); +#else + await Assert.ThrowsAsync(() => source.Task.WaitAsync(TimeSpan.FromTicks(-1), timeProvider, CancellationToken.None)); +#endif + await Assert.ThrowsAsync(() => source.Task.WaitAsync(TimeSpan.FromMilliseconds(-2), timeProvider, CancellationToken.None)); + + var t = source.Task.WaitAsync(TimeSpan.FromSeconds(100000), timeProvider, CancellationToken.None); + while (!t.IsCompleted) + { + timeProvider.Advance(); + await Task.Delay(1); + _ = source.TrySetResult(true); + } + + Assert.True(t.IsCompleted); + Assert.False(t.IsFaulted); + Assert.False(t.IsCanceled); + } + + [Fact] + public async Task WaitAsync_InfiniteTimeout() + { + var timeProvider = new SutTimeProvider(); + var source = new TaskCompletionSource(); + + var t = source.Task.WaitAsync(_infiniteTimeout, timeProvider, CancellationToken.None); + while (!t.IsCompleted) + { + timeProvider.Advance(); + await Task.Delay(1); + _ = source.TrySetResult(true); + } + + Assert.True(t.IsCompleted); + Assert.False(t.IsFaulted); + Assert.False(t.IsCanceled); + } + + [Fact] + public async Task WaitAsync_Timeout() + { + var timeProvider = new SutTimeProvider(); + var source = new TaskCompletionSource(); + + var t = source.Task.WaitAsync(TimeSpan.FromMilliseconds(1), timeProvider, CancellationToken.None); + while (!t.IsCompleted) + { + timeProvider.Advance(); + await Task.Delay(1); + } + + Assert.True(t.IsCompleted); + Assert.True(t.IsFaulted); + Assert.False(t.IsCanceled); + } + + [Fact] + public async Task WaitAsync_Cancel() + { + var timeProvider = new SutTimeProvider(); + var source = new TaskCompletionSource(); + using var cts = new CancellationTokenSource(); + + var t = source.Task.WaitAsync(_infiniteTimeout, timeProvider, cts.Token); + cts.Cancel(); + +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks + await Assert.ThrowsAsync(() => t).ConfigureAwait(false); +#pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks + } +} diff --git a/test/TimeProviderExtensions.Tests/Microsoft.Extensions.Time.Testing.Test/FakeTimeProviderTimerTests.cs b/test/TimeProviderExtensions.Tests/Microsoft.Extensions.Time.Testing.Test/FakeTimeProviderTimerTests.cs new file mode 100644 index 0000000..1d2ae23 --- /dev/null +++ b/test/TimeProviderExtensions.Tests/Microsoft.Extensions.Time.Testing.Test/FakeTimeProviderTimerTests.cs @@ -0,0 +1,283 @@ +#if TargetMicrosoftTestTimeProvider +using SutTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider; +#else +using SutTimeProvider = TimeProviderExtensions.ManualTimeProvider; +#endif + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Microsoft.Extensions.Time.Testing.Test; + +public class FakeTimeProviderTimerTests +{ + private void EmptyTimerTarget(object? o) + { + // no-op for timer callbacks + } + + [Fact] + public void TimerNonPeriodicPeriodZero() + { + var counter = 0; + var timeProvider = new SutTimeProvider(); + using var timer = timeProvider.CreateTimer(_ => { counter++; }, null, TimeSpan.FromMilliseconds(10), TimeSpan.Zero); + + var value1 = counter; + timeProvider.Advance(TimeSpan.FromMilliseconds(20)); + + var value2 = counter; + + timeProvider.Advance(TimeSpan.FromMilliseconds(1000)); + + var value3 = counter; + + Assert.Equal(0, value1); + Assert.Equal(1, value2); + Assert.Equal(1, value3); + } + + [Fact] + public void TimerNonPeriodicPeriodInfinite() + { + var counter = 0; + var timeProvider = new SutTimeProvider(); + using var timer = timeProvider.CreateTimer(_ => { counter++; }, null, TimeSpan.FromMilliseconds(10), Timeout.InfiniteTimeSpan); + + var value1 = counter; + timeProvider.Advance(TimeSpan.FromMilliseconds(20)); + + var value2 = counter; + + timeProvider.Advance(TimeSpan.FromMilliseconds(1000)); + + var value3 = counter; + + Assert.Equal(0, value1); + Assert.Equal(1, value2); + Assert.Equal(1, value3); + } + + [Fact] + public void TimerStartsImmediately() + { + var counter = 0; + var timeProvider = new SutTimeProvider(); + using var timer = timeProvider.CreateTimer(_ => { counter++; }, null, TimeSpan.Zero, Timeout.InfiniteTimeSpan); + + var value1 = counter; + + timeProvider.Advance(TimeSpan.FromMilliseconds(100)); + + var value2 = counter; + + timeProvider.Advance(TimeSpan.FromMilliseconds(100)); + + var value3 = counter; + + Assert.Equal(1, value1); + Assert.Equal(1, value2); + Assert.Equal(1, value3); + } + + [Fact] + public void NoDueTime_TimerDoesntStart() + { + var counter = 0; + var timeProvider = new SutTimeProvider(); + var timer = timeProvider.CreateTimer(_ => { counter++; }, null, Timeout.InfiniteTimeSpan, TimeSpan.FromMilliseconds(10)); + + var value1 = counter; + + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + + var value2 = counter; + + timeProvider.Advance(TimeSpan.FromMilliseconds(50)); + + var value3 = counter; + + Assert.Equal(0, value1); + Assert.Equal(0, value2); + Assert.Equal(0, value3); + } + + [Fact] + public void TimerTriggersPeriodically() + { + var counter = 0; + var timeProvider = new SutTimeProvider(); + var timer = timeProvider.CreateTimer(_ => { counter++; }, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(10)); + + var value1 = counter; + + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + + var value2 = counter; + + timeProvider.Advance(TimeSpan.FromMilliseconds(10)); + + var value3 = counter; + + timeProvider.Advance(TimeSpan.FromMilliseconds(10)); + + var value4 = counter; + + Assert.Equal(1, value1); + Assert.Equal(1, value2); + Assert.Equal(2, value3); + Assert.Equal(3, value4); + } + + [Fact(Skip = "If interval is 10ms and time is advanced with 100, there should be 10 callbacks. That is how the production timer works.")] + public void LongPausesTriggerSingleCallback() + { + var counter = 0; + var timeProvider = new SutTimeProvider(); + 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() + { + var fakeTimeProvider = new SutTimeProvider(); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(1000)); + + var task = fakeTimeProvider.Delay(TimeSpan.FromMilliseconds(10000), cancellationTokenSource.Token).ConfigureAwait(false); + + fakeTimeProvider.Advance(TimeSpan.FromMilliseconds(10000)); + + await task; + + Assert.False(cancellationTokenSource.Token.IsCancellationRequested); + } + + [Fact] + public async Task TaskDelayWithFakeTimeProviderStopped() + { + var fakeTimeProvider = new SutTimeProvider(); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + await Assert.ThrowsAsync(async () => + { + await fakeTimeProvider.Delay( + TimeSpan.FromMilliseconds(10000), + cancellationTokenSource.Token) + .ConfigureAwait(false); + }); + } + +#if TargetMicrosoftTestTimeProvider + + [Fact] + public void TimerChangeDueTimeOutOfRangeThrows() + { + using var t = new FakeTimeProviderTimer(new SutTimeProvider(), TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1), new TimerCallback(EmptyTimerTarget), null); + + Assert.Throws("dueTime", () => t.Change(TimeSpan.FromMilliseconds(-2), TimeSpan.FromMilliseconds(1))); + Assert.Throws("dueTime", () => t.Change(TimeSpan.FromMilliseconds(-2), TimeSpan.FromSeconds(1))); + Assert.Throws("dueTime", () => t.Change(TimeSpan.FromMilliseconds(0xFFFFFFFFL), TimeSpan.FromMilliseconds(1))); + Assert.Throws("dueTime", () => t.Change(TimeSpan.FromMilliseconds(0xFFFFFFFFL), TimeSpan.FromSeconds(1))); + } + + [Fact] + public void TimerChangePeriodOutOfRangeThrows() + { + using var t = new FakeTimeProviderTimer(new SutTimeProvider(), TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1), new TimerCallback(EmptyTimerTarget), null); + + Assert.Throws("period", () => t.Change(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(-2))); + Assert.Throws("period", () => t.Change(TimeSpan.FromSeconds(1), TimeSpan.FromMilliseconds(-2))); + Assert.Throws("period", () => t.Change(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(0xFFFFFFFFL))); + Assert.Throws("period", () => t.Change(TimeSpan.FromSeconds(1), TimeSpan.FromMilliseconds(0xFFFFFFFFL))); + } + + [Fact] + public void Timer_Change_AfterDispose_Test() + { + var t = new FakeTimeProviderTimer(new SutTimeProvider(), TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1), new TimerCallback(EmptyTimerTarget), null); + + Assert.True(t.Change(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1))); + t.Dispose(); + Assert.False(t.Change(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1))); + } + + [Fact] + public void WaiterRemovedAfterDispose() + { + var timer1Counter = 0; + var timer2Counter = 0; + + var timeProvider = new SutTimeProvider(); + var waitersCountStart = timeProvider.Waiters.Count; + + var timer1 = timeProvider.CreateTimer(_ => timer1Counter++, null, TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1)); + var timer2 = timeProvider.CreateTimer(_ => timer2Counter++, null, TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1)); + + var waitersCountDuring = timeProvider.Waiters.Count; + + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + + timer1.Dispose(); + + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + + var waitersCountAfter = timeProvider.Waiters.Count; + + Assert.Equal(0, waitersCountStart); + Assert.Equal(2, waitersCountDuring); + Assert.Equal(1, timer1Counter); + Assert.Equal(2, timer2Counter); + Assert.Equal(1, waitersCountAfter); + } + +#if RELEASE // In Release only since this might not work if the timer reference being tracked by the debugger + [Fact] + public void WaiterRemovedWhenCollectedWithoutDispose() + { + var timer1Counter = 0; + var timer2Counter = 0; + + var timeProvider = new SutTimeProvider(); + var waitersCountStart = timeProvider.Waiters.Count; + + var timer1 = timeProvider.CreateTimer(_ => timer1Counter++, null, TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1)); + var timer2 = timeProvider.CreateTimer(_ => timer2Counter++, null, TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1)); + + var waitersCountDuring = timeProvider.Waiters.Count; + + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + + // Force the finalizer on timer1 to ensure Dispose is releasing the waiter object + // even when a Timer is not disposed + timer1 = null; + GC.Collect(); + GC.WaitForPendingFinalizers(); + + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + + var waitersCountAfter = timeProvider.Waiters.Count; + + Assert.Equal(0, waitersCountStart); + Assert.Equal(2, waitersCountDuring); + Assert.Equal(1, timer1Counter); + Assert.Equal(2, timer2Counter); + Assert.Equal(1, waitersCountAfter); + } +#endif +#endif +} \ No newline at end of file