Skip to content

Commit 3b2020d

Browse files
committed
feat: change name to TimeProviderExtensions, utilize Microsoft.Bcl.TimeProvider, create simplified ManualTimeProvider
1 parent ed5ea56 commit 3b2020d

27 files changed

+743
-1250
lines changed

README.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
# Time Scheduler - a TimeProvider shim
1+
# TimeProvider Extensions
22

3-
This is a shim for the upcoming `System.TimeProvider` API coming in .NET 8. It includes a test version of the `TimeProvider` type, named `ManualTimeProvider`, that allows you to control the progress of time during testing deterministically.
4-
5-
*NOTE: Originally, this library provided its own abstraction, `ITimeScheduler` and related types, `DefaultScheduler` and `TestScheduler`. These are now considered obsolete.*
3+
Extensions for `System.TimeProvider` API. It includes a test version of the `TimeProvider` type, named `ManualTimeProvider`, that allows you to control the progress of time during testing deterministically.
64

75
Currently, the following .NET time-based APIs are supported:
86

@@ -25,7 +23,7 @@ multiple minutes using e.g. `TimeProvider.Delay(TimeSpan)`, the replacement for
2523

2624
## Installation
2725

28-
Get the latest release from https://www.nuget.org/packages/TimeScheduler
26+
Get the latest release from https://www.nuget.org/packages/TimeProviderExtensions
2927

3028
## Set up in production
3129

TimeScheduler.sln renamed to TimeProviderExtensions.sln

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,31 +14,31 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
1414
README.md = README.md
1515
EndProjectSection
1616
EndProject
17-
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TimeScheduler", "src\TimeScheduler\TimeScheduler.csproj", "{2E7DDB3B-1C84-4714-A907-2FABE6A426B2}"
17+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TimeProviderExtensions.Tests", "test\TimeScheduler.Tests\TimeProviderExtensions.Tests.csproj", "{F475F44C-35A3-408D-824A-177EA43A8E2A}"
1818
EndProject
19-
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TimeScheduler.Tests", "test\TimeScheduler.Tests\TimeScheduler.Tests.csproj", "{F475F44C-35A3-408D-824A-177EA43A8E2A}"
19+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TimeProviderExtensions", "src\TimeProviderExtensions\TimeProviderExtensions.csproj", "{E36A99D1-69FC-4418-A1EF-C9C38F8832F4}"
2020
EndProject
2121
Global
2222
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2323
Debug|Any CPU = Debug|Any CPU
2424
Release|Any CPU = Release|Any CPU
2525
EndGlobalSection
2626
GlobalSection(ProjectConfigurationPlatforms) = postSolution
27-
{2E7DDB3B-1C84-4714-A907-2FABE6A426B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
28-
{2E7DDB3B-1C84-4714-A907-2FABE6A426B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
29-
{2E7DDB3B-1C84-4714-A907-2FABE6A426B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
30-
{2E7DDB3B-1C84-4714-A907-2FABE6A426B2}.Release|Any CPU.Build.0 = Release|Any CPU
3127
{F475F44C-35A3-408D-824A-177EA43A8E2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
3228
{F475F44C-35A3-408D-824A-177EA43A8E2A}.Debug|Any CPU.Build.0 = Debug|Any CPU
3329
{F475F44C-35A3-408D-824A-177EA43A8E2A}.Release|Any CPU.ActiveCfg = Release|Any CPU
3430
{F475F44C-35A3-408D-824A-177EA43A8E2A}.Release|Any CPU.Build.0 = Release|Any CPU
31+
{E36A99D1-69FC-4418-A1EF-C9C38F8832F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
32+
{E36A99D1-69FC-4418-A1EF-C9C38F8832F4}.Debug|Any CPU.Build.0 = Debug|Any CPU
33+
{E36A99D1-69FC-4418-A1EF-C9C38F8832F4}.Release|Any CPU.ActiveCfg = Release|Any CPU
34+
{E36A99D1-69FC-4418-A1EF-C9C38F8832F4}.Release|Any CPU.Build.0 = Release|Any CPU
3535
EndGlobalSection
3636
GlobalSection(SolutionProperties) = preSolution
3737
HideSolutionNode = FALSE
3838
EndGlobalSection
3939
GlobalSection(NestedProjects) = preSolution
40-
{2E7DDB3B-1C84-4714-A907-2FABE6A426B2} = {695B57CD-9E0E-4648-8A48-9E8A358B014B}
4140
{F475F44C-35A3-408D-824A-177EA43A8E2A} = {7C987338-A88E-4AB5-98CE-39A93F4CDC94}
41+
{E36A99D1-69FC-4418-A1EF-C9C38F8832F4} = {695B57CD-9E0E-4648-8A48-9E8A358B014B}
4242
EndGlobalSection
4343
GlobalSection(ExtensibilityGlobals) = postSolution
4444
SolutionGuid = {A7780397-0E8F-4C60-8D68-569022905162}

global.json

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics.CodeAnalysis;
4+
using System.Linq;
5+
using System.Runtime.CompilerServices;
6+
using System.Text;
7+
8+
namespace TimeProviderExtensions;
9+
10+
/// <summary>
11+
/// Represents a test implementation of a <see cref="TimeProvider"/>,
12+
/// where time stands still until you call <see cref="ForwardTime(TimeSpan)"/>
13+
/// or <see cref="SetUtcNow(DateTimeOffset)"/>.
14+
/// </summary>
15+
/// <remarks>
16+
/// Learn more at <see href="https://github.com/egil/TimeProviderExtensions"/>.
17+
/// </remarks>
18+
public class ManualTimeProvider : TimeProvider
19+
{
20+
internal const uint MaxSupportedTimeout = 0xfffffffe;
21+
internal const uint UnsignedInfinite = unchecked((uint)-1);
22+
23+
private readonly List<ManualTimerScheduledCallback> futureCallbacks = new();
24+
private DateTimeOffset utcNow;
25+
26+
/// <summary>
27+
/// Gets the frequency of <see cref="GetTimestamp"/> of high-frequency value per second.
28+
/// </summary>
29+
/// <remarks>
30+
/// This implementation bases timestamp on <see cref="DateTimeOffset.UtcTicks"/>, which is 10,000 ticks per millisecond,
31+
/// since the progression of time is represented by the date and time returned from <see cref="GetUtcNow()" />.
32+
/// </remarks>
33+
public override long TimestampFrequency { get; } = 10_000_000;
34+
35+
/// <summary>
36+
/// Creates an instance of the <see cref="ManualTimeProvider"/> with
37+
/// <see cref="DateTimeOffset.UtcNow"/> being the initial value returned by <see cref="GetUtcNow()"/>.
38+
/// </summary>
39+
public ManualTimeProvider()
40+
: this(System.GetUtcNow())
41+
{
42+
43+
}
44+
45+
/// <summary>
46+
/// Creates an instance of the <see cref="ManualTimeProvider"/> with
47+
/// <paramref name="startDateTime"/> being the initial value returned by <see cref="GetUtcNow()"/>.
48+
/// </summary>
49+
/// <param name="startDateTime">The initial date and time <see cref="GetUtcNow()"/> will return.</param>
50+
public ManualTimeProvider(DateTimeOffset startDateTime)
51+
{
52+
utcNow = startDateTime;
53+
}
54+
55+
/// <summary>
56+
/// Gets the current high-frequency value designed to measure small time intervals with high accuracy in the timer mechanism.
57+
/// </summary>
58+
/// <returns>A long integer representing the high-frequency counter value of the underlying timer mechanism. </returns>
59+
/// <remarks>
60+
/// This implementation bases timestamp on <see cref="DateTimeOffset.UtcTicks"/>,
61+
/// since the progression of time is represented by the date and time returned from <see cref="GetUtcNow()" />.
62+
/// </remarks>
63+
public override long GetTimestamp() => GetUtcNow().UtcTicks;
64+
65+
/// <summary>
66+
/// Gets a <see cref="DateTimeOffset"/> value whose date and time are set to the current
67+
/// Coordinated Universal Time (UTC) date and time and whose offset is Zero,
68+
/// all according to this <see cref="ManualTimeProvider"/>'s notion of time.
69+
/// </summary>
70+
/// <remarks>
71+
/// To advance time, call <see cref="ForwardTime(TimeSpan)"/> or <see cref="SetUtcNow(DateTimeOffset)"/>.
72+
/// </remarks>
73+
public override DateTimeOffset GetUtcNow() => utcNow;
74+
75+
/// <summary>Creates a new <see cref="ITimer"/> instance, using <see cref="TimeSpan"/> values to measure time intervals.</summary>
76+
/// <param name="callback">
77+
/// A delegate representing a method to be executed when the timer fires. The method specified for callback should be reentrant,
78+
/// as it may be invoked simultaneously on two threads if the timer fires again before or while a previous callback is still being handled.
79+
/// </param>
80+
/// <param name="state">An object to be passed to the <paramref name="callback"/>. This may be null.</param>
81+
/// <param name="dueTime">The amount of time to delay before <paramref name="callback"/> is invoked. Specify <see cref="Timeout.InfiniteTimeSpan"/> to prevent the timer from starting. Specify <see cref="TimeSpan.Zero"/> to start the timer immediately.</param>
82+
/// <param name="period">The time interval between invocations of <paramref name="callback"/>. Specify <see cref="Timeout.InfiniteTimeSpan"/> to disable periodic signaling.</param>
83+
/// <returns>
84+
/// The newly created <see cref="ITimer"/> instance.
85+
/// </returns>
86+
/// <exception cref="ArgumentNullException"><paramref name="callback"/> is null.</exception>
87+
/// <exception cref="ArgumentOutOfRangeException">The number of milliseconds in the value of <paramref name="dueTime"/> or <paramref name="period"/> is negative and not equal to <see cref="Timeout.Infinite"/>, or is greater than <see cref="int.MaxValue"/>.</exception>
88+
/// <remarks>
89+
/// <para>
90+
/// The delegate specified by the callback parameter is invoked once after <paramref name="dueTime"/> elapses, and thereafter each time the <paramref name="period"/> time interval elapses.
91+
/// </para>
92+
/// <para>
93+
/// If <paramref name="dueTime"/> is zero, the callback is invoked immediately. If <paramref name="dueTime"/> is -1 milliseconds, <paramref name="callback"/> is not invoked; the timer is disabled,
94+
/// but can be re-enabled by calling the <see cref="ITimer.Change"/> method.
95+
/// </para>
96+
/// <para>
97+
/// If <paramref name="period"/> is 0 or -1 milliseconds and <paramref name="dueTime"/> is positive, <paramref name="callback"/> is invoked once; the periodic behavior of the timer is disabled,
98+
/// but can be re-enabled using the <see cref="ITimer.Change"/> method.
99+
/// </para>
100+
/// <para>
101+
/// The return <see cref="ITimer"/> instance will be implicitly rooted while the timer is still scheduled.
102+
/// </para>
103+
/// <para>
104+
/// <see cref="CreateTimer"/> captures the <see cref="ExecutionContext"/> and stores that with the <see cref="ITimer"/> for use in invoking <paramref name="callback"/>
105+
/// each time it's called. That capture can be suppressed with <see cref="ExecutionContext.SuppressFlow"/>.
106+
/// </para>
107+
/// <para>
108+
/// To advance time, call <see cref="ForwardTime(TimeSpan)"/> or <see cref="SetUtcNow(DateTimeOffset)"/>.
109+
/// </para>
110+
/// </remarks>
111+
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
112+
=> new ManualTimer(callback, state, dueTime, period, this);
113+
114+
/// <summary>
115+
/// Forward the date and time represented by <see cref="GetUtcNow()"/>
116+
/// by the specified <paramref name="time"/>, and triggers any
117+
/// scheduled items that are waiting for time to be forwarded.
118+
/// </summary>
119+
/// <param name="time">The span of time to forward <see cref="GetUtcNow()"/> with.</param>
120+
/// <exception cref="ArgumentException">If <paramref name="time"/> is negative or zero.</exception>
121+
public void ForwardTime(TimeSpan time)
122+
{
123+
if (time <= TimeSpan.Zero)
124+
throw new ArgumentException("The timespan to forward time by must be positive.", nameof(time));
125+
126+
SetUtcNow(utcNow + time);
127+
}
128+
129+
/// <summary>
130+
/// Sets the date and time returned by <see cref="GetUtcNow()"/> to <paramref name="newUtcNew"/> and triggers any
131+
/// scheduled items that are waiting for time to be forwarded.
132+
/// </summary>
133+
/// <param name="newUtcNew">The new UtcNow time.</param>
134+
/// <exception cref="ArgumentException">If <paramref name="newUtcNew"/> is less than the value returned by <see cref="GetUtcNow()"/>.</exception>
135+
public void SetUtcNow(DateTimeOffset newUtcNew)
136+
{
137+
if (newUtcNew < utcNow)
138+
throw new ArgumentException("The new UtcNow must be greater than or equal to the current UtcNow.", nameof(newUtcNew));
139+
140+
while (utcNow <= newUtcNew && TryGetNext(newUtcNew) is ManualTimerScheduledCallback mtsc)
141+
{
142+
utcNow = mtsc.CallbackTime;
143+
mtsc.Timer.TimerElapsed();
144+
}
145+
146+
utcNow = newUtcNew;
147+
148+
ManualTimerScheduledCallback? TryGetNext(DateTimeOffset targetUtcNow)
149+
{
150+
lock (futureCallbacks)
151+
{
152+
if (futureCallbacks.Count > 0 && futureCallbacks[0].CallbackTime <= targetUtcNow)
153+
{
154+
var callback = futureCallbacks[0];
155+
futureCallbacks.RemoveAt(0);
156+
return callback;
157+
}
158+
}
159+
160+
return null;
161+
}
162+
}
163+
164+
private void ScheduleCallback(ManualTimer timer, TimeSpan waitTime)
165+
{
166+
lock (futureCallbacks)
167+
{
168+
var mtsc = new ManualTimerScheduledCallback(timer, utcNow + waitTime);
169+
var insertPosition = futureCallbacks.FindIndex(x => x.CallbackTime > mtsc.CallbackTime);
170+
171+
if (insertPosition == -1)
172+
futureCallbacks.Add(mtsc);
173+
else
174+
{
175+
futureCallbacks.Insert(insertPosition, mtsc);
176+
}
177+
}
178+
}
179+
180+
private void RemoveCallback(ManualTimer timer)
181+
{
182+
lock (futureCallbacks)
183+
{
184+
var existingIndexOf = futureCallbacks.FindIndex(0, x => ReferenceEquals(x.Timer, timer));
185+
if (existingIndexOf >= 0)
186+
futureCallbacks.RemoveAt(existingIndexOf);
187+
}
188+
}
189+
190+
private readonly struct ManualTimerScheduledCallback :
191+
IEqualityComparer<ManualTimerScheduledCallback>,
192+
IComparable<ManualTimerScheduledCallback>
193+
{
194+
public readonly ManualTimer Timer { get; }
195+
public readonly DateTimeOffset CallbackTime { get; }
196+
197+
public ManualTimerScheduledCallback(ManualTimer timer, DateTimeOffset callbackTime)
198+
{
199+
Timer = timer;
200+
CallbackTime = callbackTime;
201+
}
202+
203+
public readonly bool Equals(ManualTimerScheduledCallback x, ManualTimerScheduledCallback y)
204+
=> ReferenceEquals(x.Timer, y.Timer);
205+
206+
public readonly int GetHashCode(ManualTimerScheduledCallback obj)
207+
=> Timer.GetHashCode();
208+
209+
public readonly int CompareTo(ManualTimerScheduledCallback other)
210+
=> Comparer<DateTimeOffset>.Default.Compare(CallbackTime, other.CallbackTime);
211+
}
212+
213+
private sealed class ManualTimer : ITimer
214+
{
215+
private readonly ManualTimeProvider owner;
216+
private bool isDisposed;
217+
private bool running;
218+
219+
private TimeSpan currentDueTime;
220+
private TimeSpan currentPeriod;
221+
private object? state;
222+
private TimerCallback? callback;
223+
224+
public ManualTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period, ManualTimeProvider owner)
225+
{
226+
ValidateTimeSpanRange(dueTime);
227+
ValidateTimeSpanRange(period);
228+
229+
this.callback = callback;
230+
this.state = state;
231+
currentDueTime = dueTime;
232+
currentPeriod = period;
233+
this.owner = owner;
234+
235+
if (currentDueTime != Timeout.InfiniteTimeSpan)
236+
ScheduleCallback(dueTime);
237+
}
238+
239+
public bool Change(TimeSpan dueTime, TimeSpan period)
240+
{
241+
ValidateTimeSpanRange(dueTime);
242+
ValidateTimeSpanRange(period);
243+
244+
if (running)
245+
owner.RemoveCallback(this);
246+
247+
currentDueTime = dueTime;
248+
currentPeriod = period;
249+
250+
if (currentDueTime != Timeout.InfiniteTimeSpan)
251+
ScheduleCallback(dueTime);
252+
253+
return true;
254+
}
255+
256+
public void Dispose()
257+
{
258+
if (isDisposed)
259+
return;
260+
261+
isDisposed = true;
262+
263+
if (running)
264+
owner.RemoveCallback(this);
265+
266+
callback = null;
267+
state = null;
268+
}
269+
270+
public ValueTask DisposeAsync()
271+
{
272+
Dispose();
273+
#if NETSTANDARD2_0
274+
return new ValueTask(Task.CompletedTask);
275+
#else
276+
return ValueTask.CompletedTask;
277+
#endif
278+
}
279+
280+
internal void TimerElapsed()
281+
{
282+
if (isDisposed)
283+
return;
284+
285+
running = false;
286+
callback?.Invoke(state);
287+
288+
if (currentPeriod != Timeout.InfiniteTimeSpan)
289+
ScheduleCallback(currentPeriod);
290+
}
291+
292+
private void ScheduleCallback(TimeSpan waitTime)
293+
{
294+
if (isDisposed)
295+
return;
296+
297+
running = true;
298+
owner.ScheduleCallback(this, waitTime);
299+
}
300+
301+
private static void ValidateTimeSpanRange(TimeSpan time, [CallerArgumentExpression("time")] string? parameter = null)
302+
{
303+
long tm = (long)time.TotalMilliseconds;
304+
if (tm < -1)
305+
throw new ArgumentOutOfRangeException(parameter, $"{parameter}.TotalMilliseconds must be greater than -1.");
306+
307+
if (tm > MaxSupportedTimeout)
308+
throw new ArgumentOutOfRangeException(parameter, $"{parameter}.TotalMilliseconds must be less than than {MaxSupportedTimeout}.");
309+
}
310+
}
311+
}

0 commit comments

Comments
 (0)