Skip to content

Commit 168ead5

Browse files
committed
refactor: expose the wrapped PeriodicTimer as PeriodicTimerWrapper to avoid ambiguity when importing
1 parent 6803a0b commit 168ead5

File tree

7 files changed

+176
-125
lines changed

7 files changed

+176
-125
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.0.0-preview.1]
11+
12+
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.
13+
14+
When using the `ManualTimeProvider` during testing, be aware of these outstanding issues: https://github.com/dotnet/runtime/issues/85326
15+
16+
- Removed `CancelAfter` extension methods. Instead create a CancellationTokenSource via the method `TimeProvider.CreateCancellationTokenSource(TimeSpan delay)` or in .NET 8, using `new CancellationTokenSource(TimeSpan delay, TimeProvider timeProvider).
17+
18+
**NOTE:** If running on .NET versions earlier than .NET 8.0, there is a constraint when invoking `CancellationTokenSource.CancelAfter(TimeSpan)` on the resultant object. This action will not terminate the initial timer indicated by `delay`. However, this restriction does not apply on .NET 8.0 and later versions.
19+
1020
## [0.8.0]
1121

1222
- Added `TimeProvider.GetElapsedTime(long startingTimestamp)`

README.md

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,40 @@ Currently, the following .NET time-based APIs are supported:
88
|----------------------|----------------------|
99
| `GetUtcNow()` method | `DateTimeOffset.UtcNow` property |
1010
| `CreateTimer()` method | `System.Threading.Timer` type |
11-
| `TimeProvider.CreatePeriodicTimer(TimeSpan)` method (only .NET 6) | `System.Threading.PeriodicTimer` type |
1211
| `Delay(TimeSpan, CancellationToken)` method | `Task.Delay(TimeSpan, CancellationToken)` method |
13-
| `CancellationTokenSource.CancelAfter(TimeSpan, TimeProvider)` and `TimeProvider.CreateCancellationTokenSource(TimeSpan delay)` methods | `CancellationTokenSource.CancelAfter(TimeSpan)` method |
14-
| `Task.WaitAsync(TimeSpan, TimeProvider)` method (only .NET 6)| `Task.WaitAsync(TimeSpan)` method |
15-
| `Task.WaitAsync(TimeSpan, TimeProvider, CancellationToken)` method (only .NET 6)| `Task.WaitAsync(TimeSpan, CancellationToken)` method |
12+
| `Task.WaitAsync(TimeSpan, TimeProvider)` method | `Task.WaitAsync(TimeSpan)` method |
13+
| `Task.WaitAsync(TimeSpan, TimeProvider, CancellationToken)` method | `Task.WaitAsync(TimeSpan, CancellationToken)` method |
14+
| `TimeProvider.CreatePeriodicTimer(TimeSpan)` method | `System.Threading.PeriodicTimer` type |
15+
| `TimeProvider.CreateCancellationTokenSource(TimeSpan)` method | `new CancellationTokenSource(TimeSpan)` method |
1616

17-
The implementations of `TimeProvider` is abstract. An instance of `TimeProvider` for production use is availalbe on the `TimeProvider.System` property,
17+
The implementation of `TimeProvider` is abstract. An instance of `TimeProvider` for production use is available on the `TimeProvider.System` property,
1818
and `ManualTimeProvider` can be used during testing.
1919

2020
During testing, you can move time forward by calling `ForwardTime(TimeSpan)` or `SetUtcNow(DateTimeOffset)` on `ManualTimeProvider`. This allows
2121
you to write tests that run fast and predictable, even if the system under test pauses execution for
2222
multiple minutes using e.g. `TimeProvider.Delay(TimeSpan)`, the replacement for `Task.Delay(TimeSpan)`.
2323

24+
## Known issues and limitations:
25+
26+
- When using the `ManualTimeProvider` during testing to forward time, be aware of this issue: https://github.com/dotnet/runtime/issues/85326.
27+
- If running on .NET versions earlier than .NET 8.0, there is a constraint when invoking `CancellationTokenSource.CancelAfter(TimeSpan)` on the `CancellationTokenSource` object returned by `CreateCancellationTokenSource(TimeSpan delay)`. This action will not terminate the initial timer indicated by the `delay` argument initially passed the `CreateCancellationTokenSource` method. However, this restriction does not apply on .NET 8.0 and later versions.
28+
- To enable controlling `PeriodicTimer` via `TimeProvider` in versions of .NET earlier than .NET 8.0, the `TimeProvider.CreatePeriodicTimer` returns a `PeriodicTimerWrapper` object instead of a `PeriodicTimer` object. The `PeriodicTimerWrapper` type is just a lightweight wrapper around the original `System.Threading.PeriodicTimer` and will behave identically to it.
29+
2430
## Installation
2531

2632
Get the latest release from https://www.nuget.org/packages/TimeProviderExtensions
2733

2834
## Set up in production
2935

3036
To use in production, pass in `TimeProvider.System` to the types that depend on `TimeProvider`.
31-
This can be done directly, or via an IoC Container, e.g. .NETs built-in `IServiceCollection` like so:
37+
This can be done directly or via an IoC Container, e.g. .NETs built-in `IServiceCollection` like so:
3238

3339
```c#
3440
services.AddSingleton(TimeProvider.System);
3541
```
3642

3743
If you do not want to register the `TimeProvider` with your IoC container, you can instead create
38-
an additional constructor in the types that use it, which allow you to pass in a `TimeProvider`,
44+
an additional constructor in the types that use it, which allows you to pass in a `TimeProvider`,
3945
and in the existing constructor(s) you have, just new up `TimeProvider.System` directly. For example:
4046

4147
```c#
@@ -54,20 +60,20 @@ public class MyService
5460
}
5561
```
5662

57-
This allows you to explicitly pass in an `ManualTimeProvider` during testing.
63+
This allows you to explicitly pass in a `ManualTimeProvider` during testing.
5864

5965
## Example - control time during tests
6066

6167
If a system under test (SUT) uses things like `Task.Delay`, `DateTimeOffset.UtcNow`, `Task.WaitAsync`, or `PeriodicTimer`,
62-
it becomes hard to create tests that runs fast and predictably.
68+
it becomes hard to create tests that run fast and predictably.
6369

6470
The idea is to replace the use of e.g. `Task.Delay` with an abstraction, the `TimeProvider`, that in production
65-
is represented by the `TimeProvider.System`, that just uses the real `Task.Delay`. During testing it is now possible to
66-
pass in `ManualTimeProvider`, that allows the test to control the progress of time, making it possible to skip ahead,
71+
is represented by the `TimeProvider.System`, which just uses the real `Task.Delay`. During testing it is now possible to
72+
pass in `ManualTimeProvider`, which allows the test to control the progress of time, making it possible to skip ahead,
6773
e.g. 10 minutes, and also pause time, leading to fast and predictable tests.
6874

69-
As an example, lets test the "Stuff Service" below that performs a specific tasks every 10 second with an additional
70-
1 second delay. We have two versions, one that uses the standard types in .NET, and one that uses the `TimeProvider`.
75+
As an example, let us test the "Stuff Service" below that performs specific tasks every 10 seconds with an additional
76+
1-second delay. We have two versions, one that uses the standard types in .NET, and one that uses the `TimeProvider`.
7177

7278
```c#
7379
// Version of stuff service that uses the built in DateTimeOffset, PeriodicTimer, and Task.Delay
@@ -93,7 +99,7 @@ public class StuffService
9399
}
94100
}
95101

96-
// Version of stuff service that uses the built in TimeProvider
102+
// Version of stuff service that uses the built-in TimeProvider
97103
public class StuffServiceUsingTimeProvider
98104
{
99105
private static readonly TimeSpan doStuffDelay = TimeSpan.FromSeconds(10);
@@ -144,7 +150,7 @@ public void DoStuff_does_stuff_every_11_seconds()
144150
}
145151
```
146152

147-
This test will run in nanoseconds, and is deterministic.
153+
This test will run in nanoseconds and is deterministic.
148154

149155
Compare that to the similar test below for `StuffService` that needs to wait for 11 seconds before it can safely assert that the expectation has been met.
150156

src/TimeProviderExtensions/PeriodicTimer.cs

Lines changed: 0 additions & 66 deletions
This file was deleted.

src/TimeProviderExtensions/System.Threading/ManualPeriodicTimer.cs renamed to src/TimeProviderExtensions/System.Threading/PeriodicTimerPort.cs

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,38 @@
33
// Original code: https://github.com/dotnet/runtime/blob/0096ba52e8c86e4d712013f6330a9b8a6496a1e0/src/libraries/System.Private.CoreLib/src/System/Threading/PeriodicTimer.cs
44

55
#if NET6_0_OR_GREATER && !NET8_0_OR_GREATER
6+
using System;
67
using System.Diagnostics;
78
using System.Diagnostics.CodeAnalysis;
89
using System.Runtime.ExceptionServices;
10+
using System.Threading.Tasks;
911
using System.Threading.Tasks.Sources;
10-
using System.Timers;
11-
using TimeProviderExtensions;
1212

13-
namespace System.Threading;
13+
namespace TimeProviderExtensions;
1414

15-
internal sealed class ManualPeriodicTimer : TimeProviderExtensions.PeriodicTimer
15+
/// <summary>Provides a periodic timer that enables waiting asynchronously for timer ticks.</summary>
16+
/// <remarks>
17+
/// This timer is intended to be used only by a single consumer at a time: only one call to <see cref="WaitForNextTickAsync" />
18+
/// may be in flight at any given moment. <see cref="Dispose"/> may be used concurrently with an active <see cref="WaitForNextTickAsync"/>
19+
/// to interrupt it and cause it to return false. Similarly, <see cref="Period"/> may be used concurrently with a consumer accessing
20+
/// <see cref="WaitForNextTickAsync"/> in order to change the timer's period.
21+
/// </remarks>
22+
internal sealed class PeriodicTimerPort : IDisposable
1623
{
17-
private readonly ITimer timer;
18-
private readonly State state;
19-
private bool disposed;
24+
internal const uint MaxSupportedTimeout = 0xfffffffe;
25+
internal const uint UnsignedInfinite = unchecked((uint)-1);
2026

21-
public ManualPeriodicTimer(TimeSpan period, TimeProvider timeProvider)
27+
/// <summary>The underlying timer.</summary>
28+
private readonly ITimer _timer;
29+
/// <summary>All state other than the _timer, so that the rooted timer's callback doesn't indirectly root itself by referring to _timer.</summary>
30+
private readonly State _state;
31+
32+
/// <summary>Initializes the timer.</summary>
33+
/// <param name="period">The period between ticks</param>
34+
/// <param name="timeProvider">The <see cref="TimeProvider"/> used to interpret <paramref name="period"/>.</param>
35+
/// <exception cref="ArgumentOutOfRangeException"><paramref name="period"/> must be <see cref="Timeout.InfiniteTimeSpan"/> or represent a number of milliseconds equal to or larger than 1 and smaller than <see cref="uint.MaxValue"/>.</exception>
36+
/// <exception cref="ArgumentNullException"><paramref name="timeProvider"/> is null</exception>
37+
internal PeriodicTimerPort(TimeSpan period, TimeProvider timeProvider)
2238
{
2339
if (!TryGetMilliseconds(period, out uint ms))
2440
{
@@ -29,36 +45,64 @@ public ManualPeriodicTimer(TimeSpan period, TimeProvider timeProvider)
2945
if (timeProvider is null)
3046
{
3147
GC.SuppressFinalize(this);
32-
ArgumentNullException.ThrowIfNull(timeProvider);
48+
throw new ArgumentNullException(nameof(timeProvider));
3349
}
3450

35-
state = new State();
51+
_state = new State();
3652
TimerCallback callback = s => ((State)s!).Signal();
3753

3854
using (ExecutionContext.SuppressFlow())
3955
{
40-
timer = timeProvider.CreateTimer(callback, state, period, period);
56+
_timer = timeProvider.CreateTimer(callback, _state, period, period);
4157
}
4258
}
4359

44-
public override ValueTask<bool> WaitForNextTickAsync(CancellationToken cancellationToken = default)
45-
=> state.WaitForNextTickAsync(this, cancellationToken);
46-
47-
protected override void Dispose(bool disposing)
60+
/// <summary>Tries to extract the number of milliseconds from <paramref name="value"/>.</summary>
61+
/// <returns>
62+
/// true if the number of milliseconds is extracted and stored into <paramref name="milliseconds"/>;
63+
/// false if the number of milliseconds would be out of range of a timer.
64+
/// </returns>
65+
private static bool TryGetMilliseconds(TimeSpan value, out uint milliseconds)
4866
{
49-
if (disposed)
67+
long ms = (long)value.TotalMilliseconds;
68+
if ((ms >= 1 && ms <= MaxSupportedTimeout) || value == Timeout.InfiniteTimeSpan)
5069
{
51-
return;
70+
milliseconds = (uint)ms;
71+
return true;
5272
}
5373

54-
disposed = true;
55-
timer.Dispose();
56-
state.Signal(stopping: true);
57-
base.Dispose(disposing);
74+
milliseconds = 0;
75+
return false;
76+
}
77+
78+
/// <summary>Wait for the next tick of the timer, or for the timer to be stopped.</summary>
79+
/// <param name="cancellationToken">
80+
/// A <see cref="CancellationToken"/> to use to cancel the asynchronous wait. If cancellation is requested, it affects only the single wait operation;
81+
/// the underlying timer continues firing.
82+
/// </param>
83+
/// <returns>A task that will be completed due to the timer firing, <see cref="Dispose"/> being called to stop the timer, or cancellation being requested.</returns>
84+
/// <remarks>
85+
/// The <see cref="PeriodicTimer"/> behaves like an auto-reset event, in that multiple ticks are coalesced into a single tick if they occur between
86+
/// calls to <see cref="WaitForNextTickAsync"/>. Similarly, a call to <see cref="Dispose"/> will void any tick not yet consumed. <see cref="WaitForNextTickAsync"/>
87+
/// may only be used by one consumer at a time, and may be used concurrently with a single call to <see cref="Dispose"/>.
88+
/// </remarks>
89+
public ValueTask<bool> WaitForNextTickAsync(CancellationToken cancellationToken = default) =>
90+
_state.WaitForNextTickAsync(this, cancellationToken);
91+
92+
/// <summary>Stops the timer and releases associated managed resources.</summary>
93+
/// <remarks>
94+
/// <see cref="Dispose"/> will cause an active wait with <see cref="WaitForNextTickAsync"/> to complete with a value of false.
95+
/// All subsequent <see cref="WaitForNextTickAsync"/> invocations will produce a value of false.
96+
/// </remarks>
97+
public void Dispose()
98+
{
99+
GC.SuppressFinalize(this);
100+
_timer.Dispose();
101+
_state.Signal(stopping: true);
58102
}
59103

60104
/// <summary>Ensures that resources are freed and other cleanup operations are performed when the garbage collector reclaims the <see cref="PeriodicTimer" /> object.</summary>
61-
~ManualPeriodicTimer() => Dispose();
105+
~PeriodicTimerPort() => Dispose();
62106

63107
/// <summary>Core implementation for the periodic timer.</summary>
64108
[SuppressMessage("Reliability", "CA2002:Do not lock on objects with weak identity", Justification = "Code copied from Microsoft.")]
@@ -84,7 +128,7 @@ private sealed class State : IValueTaskSource<bool>
84128
/// will never tick in such a case, and for the timer's period to be changed, the user's code would need
85129
/// some other reference to PeriodicTimer keeping it alive, anyway.
86130
/// </remarks>
87-
private ManualPeriodicTimer? _owner;
131+
private PeriodicTimerPort? _owner;
88132
/// <summary>Core of the <see cref="IValueTaskSource{TResult}"/> implementation.</summary>
89133
private ManualResetValueTaskSourceCore<bool> _mrvtsc;
90134
/// <summary>Cancellation registration for any active <see cref="WaitForNextTickAsync"/> call.</summary>
@@ -97,7 +141,7 @@ private sealed class State : IValueTaskSource<bool>
97141
private bool _activeWait;
98142

99143
/// <summary>Wait for the next tick of the timer, or for the timer to be stopped.</summary>
100-
public ValueTask<bool> WaitForNextTickAsync(ManualPeriodicTimer owner, CancellationToken cancellationToken)
144+
public ValueTask<bool> WaitForNextTickAsync(PeriodicTimerPort owner, CancellationToken cancellationToken)
101145
{
102146
lock (this)
103147
{

0 commit comments

Comments
 (0)