Skip to content

Commit 1bc6cc3

Browse files
committed
fix: sync implmentations of PeriodicTimer with microsofts
1 parent 55af215 commit 1bc6cc3

File tree

5 files changed

+211
-44
lines changed

5 files changed

+211
-44
lines changed

src/TimeProviderExtensions/ManualTimeProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,6 @@ public ManualTimer(TimerCallback callback, object? state, TimeSpan dueTime, Time
225225
{
226226
ValidateTimeSpanRange(dueTime);
227227
ValidateTimeSpanRange(period);
228-
229228
this.callback = callback;
230229
this.state = state;
231230
currentDueTime = dueTime;
@@ -283,6 +282,7 @@ internal void TimerElapsed()
283282
return;
284283

285284
running = false;
285+
286286
callback?.Invoke(state);
287287

288288
if (currentPeriod != Timeout.InfiniteTimeSpan)

src/TimeProviderExtensions/PeriodicTimer.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,23 @@ public void Dispose()
4444
protected virtual void Dispose(bool disposing)
4545
{
4646
}
47+
48+
/// <summary>Tries to extract the number of milliseconds from <paramref name="value"/>.</summary>
49+
/// <returns>
50+
/// true if the number of milliseconds is extracted and stored into <paramref name="milliseconds"/>;
51+
/// false if the number of milliseconds would be out of range of a timer.
52+
/// </returns>
53+
protected static bool TryGetMilliseconds(TimeSpan value, out uint milliseconds)
54+
{
55+
long ms = (long)value.TotalMilliseconds;
56+
if ((ms >= 1 && ms <= ManualTimeProvider.MaxSupportedTimeout) || value == Timeout.InfiniteTimeSpan)
57+
{
58+
milliseconds = (uint)ms;
59+
return true;
60+
}
61+
62+
milliseconds = 0;
63+
return false;
64+
}
4765
}
4866
#endif
Lines changed: 184 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,220 @@
1+
// The code in this file is based on the following source,
2+
// which is licensed to the .NET Foundation under one or more agreements.
3+
// Original code: https://github.com/dotnet/runtime/blob/0096ba52e8c86e4d712013f6330a9b8a6496a1e0/src/libraries/System.Private.CoreLib/src/System/Threading/PeriodicTimer.cs
4+
15
#if NET6_0_OR_GREATER && !NET8_0_OR_GREATER
6+
using System.Diagnostics;
7+
using System.Diagnostics.CodeAnalysis;
8+
using System.Runtime.ExceptionServices;
9+
using System.Threading.Tasks.Sources;
10+
using System.Timers;
211
using TimeProviderExtensions;
312

413
namespace System.Threading;
514

615
internal sealed class ManualPeriodicTimer : TimeProviderExtensions.PeriodicTimer
716
{
817
private readonly ITimer timer;
9-
private bool stopped;
10-
private TaskCompletionSource<bool>? completionSource;
11-
private CancellationTokenRegistration? cancellationRegistration;
18+
private readonly State state;
19+
private bool disposed;
1220

1321
public ManualPeriodicTimer(TimeSpan period, TimeProvider timeProvider)
1422
{
15-
long ms = (long)period.TotalMilliseconds;
16-
if (ms < 1 || ms > ManualTimeProvider.MaxSupportedTimeout)
23+
if (!TryGetMilliseconds(period, out uint ms))
1724
{
1825
GC.SuppressFinalize(this);
1926
throw new ArgumentOutOfRangeException(nameof(period));
2027
}
2128

22-
timer = timeProvider.CreateTimer(Signal, null, period, period);
29+
if (timeProvider is null)
30+
{
31+
GC.SuppressFinalize(this);
32+
ArgumentNullException.ThrowIfNull(timeProvider);
33+
}
34+
35+
state = new State();
36+
TimerCallback callback = s => ((State)s!).Signal();
37+
38+
using (ExecutionContext.SuppressFlow())
39+
{
40+
timer = timeProvider.CreateTimer(callback, state, period, period);
41+
}
2342
}
2443

2544
public override ValueTask<bool> WaitForNextTickAsync(CancellationToken cancellationToken = default)
45+
=> state.WaitForNextTickAsync(this, cancellationToken);
46+
47+
protected override void Dispose(bool disposing)
2648
{
27-
if (completionSource is not null && !completionSource.Task.IsCompleted)
49+
if (disposed)
2850
{
29-
throw new InvalidOperationException("WaitForNextTickAsync should only be used by one consumer at a time. Failing to do so is an error.");
51+
return;
3052
}
3153

32-
if (cancellationToken.IsCancellationRequested)
33-
{
34-
return ValueTask.FromCanceled<bool>(cancellationToken);
35-
}
54+
disposed = true;
55+
timer.Dispose();
56+
state.Signal(stopping: true);
57+
base.Dispose(disposing);
58+
}
59+
60+
/// <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();
62+
63+
/// <summary>Core implementation for the periodic timer.</summary>
64+
[SuppressMessage("Reliability", "CA2002:Do not lock on objects with weak identity", Justification = "Code copied from Microsoft.")]
65+
private sealed class State : IValueTaskSource<bool>
66+
{
67+
/// <summary>The associated <see cref="PeriodicTimer"/>.</summary>
68+
/// <remarks>
69+
/// This should refer to the parent instance only when there's an active waiter, and be null when there
70+
/// isn't. The TimerQueueTimer in the PeriodicTimer strongly roots itself, and it references this State
71+
/// object:
72+
/// PeriodicTimer (finalizable) --ref--> TimerQueueTimer (rooted) --ref--> State --ref--> null
73+
/// If this State object then references the PeriodicTimer, it creates a strongly-rooted cycle that prevents anything from
74+
/// being GC'd:
75+
/// PeriodicTimer (finalizable) --ref--> TimerQueueTimer (rooted) --ref--> State --v
76+
/// ^--ref-------------------------------------------------------------------|
77+
/// When this field is null, the cycle is broken, and dropping all references to the PeriodicTimer allows the
78+
/// PeriodicTimer to be finalized and unroot the TimerQueueTimer. Thus, we keep this field set during<see cref="WaitForNextTickAsync"/>
79+
/// so that the timer roots any async continuation chain awaiting it, and then keep it unset otherwise so that everything
80+
/// can be GC'd appropriately.
81+
///
82+
/// Note that if the period is set to infinite, even when there's an active waiter the PeriodicTimer won't
83+
/// be rooted because TimerQueueTimer won't be rooted via the static linked list. That's fine, as the timer
84+
/// will never tick in such a case, and for the timer's period to be changed, the user's code would need
85+
/// some other reference to PeriodicTimer keeping it alive, anyway.
86+
/// </remarks>
87+
private ManualPeriodicTimer? _owner;
88+
/// <summary>Core of the <see cref="IValueTaskSource{TResult}"/> implementation.</summary>
89+
private ManualResetValueTaskSourceCore<bool> _mrvtsc;
90+
/// <summary>Cancellation registration for any active <see cref="WaitForNextTickAsync"/> call.</summary>
91+
private CancellationTokenRegistration _ctr;
92+
/// <summary>Whether the timer has been stopped.</summary>
93+
private bool _stopped;
94+
/// <summary>Whether there's a pending notification to be received. This could be due to the timer firing, the timer being stopped, or cancellation being requested.</summary>
95+
private bool _signaled;
96+
/// <summary>Whether there's a <see cref="WaitForNextTickAsync"/> call in flight.</summary>
97+
private bool _activeWait;
3698

37-
if (!stopped && completionSource is not null)
99+
/// <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)
38101
{
39-
return new ValueTask<bool>(completionSource.Task);
102+
lock (this)
103+
{
104+
if (_activeWait)
105+
{
106+
throw new InvalidOperationException("WaitForNextTickAsync should only be used by one consumer at a time. Failing to do so is an error.");
107+
}
108+
109+
// If cancellation has already been requested, short-circuit.
110+
if (cancellationToken.IsCancellationRequested)
111+
{
112+
return ValueTask.FromCanceled<bool>(cancellationToken);
113+
}
114+
115+
// If the timer has a pending tick or has been stopped, we can complete synchronously.
116+
if (_signaled)
117+
{
118+
// Reset the signal for subsequent consumers, but only if we're not stopped. Since.
119+
// stopping the timer is one way, any subsequent calls should also complete synchronously
120+
// with false, and thus we leave _signaled pinned at true.
121+
if (!_stopped)
122+
{
123+
_signaled = false;
124+
}
125+
126+
return new ValueTask<bool>(!_stopped);
127+
}
128+
129+
Debug.Assert(!_stopped, "Unexpectedly stopped without _signaled being true.");
130+
131+
// Set up for the wait and return a task that will be signaled when the
132+
// timer fires, stop is called, or cancellation is requested.
133+
_owner = owner;
134+
_activeWait = true;
135+
_ctr = cancellationToken.UnsafeRegister(static (state, cancellationToken) => ((State)state!).Signal(cancellationToken: cancellationToken), this);
136+
137+
return new ValueTask<bool>(this, _mrvtsc.Version);
138+
}
40139
}
41140

42-
completionSource = new();
43-
cancellationRegistration?.Unregister();
44-
cancellationRegistration = cancellationToken.Register(() =>
141+
/// <summary>Signal that the timer has either fired or been stopped.</summary>
142+
public void Signal(bool stopping = false, CancellationToken cancellationToken = default)
45143
{
46-
completionSource?.TrySetCanceled(cancellationToken);
47-
});
144+
bool completeTask = false;
48145

49-
return new ValueTask<bool>(completionSource.Task);
50-
}
146+
lock (this)
147+
{
148+
_stopped |= stopping;
149+
if (!_signaled)
150+
{
151+
_signaled = true;
152+
completeTask = _activeWait;
153+
}
154+
}
51155

52-
private void Signal(object? state)
53-
{
54-
if (completionSource is TaskCompletionSource<bool> tcs)
55-
{
56-
completionSource = null;
57-
tcs.TrySetResult(!stopped);
156+
if (completeTask)
157+
{
158+
if (cancellationToken.IsCancellationRequested)
159+
{
160+
// If cancellation is requested just before the UnsafeRegister call, it's possible this will end up being invoked
161+
// as part of the WaitForNextTickAsync call and thus as part of holding the lock. The goal of completeTask
162+
// was to escape that lock, so that we don't invoke any synchronous continuations from the ValueTask as part
163+
// of completing _mrvtsc. However, in that case, we also haven't returned the ValueTask to the caller, so there
164+
// won't be any continuations yet, which makes this safe.
165+
_mrvtsc.SetException(ExceptionDispatchInfo.SetCurrentStackTrace(new OperationCanceledException(cancellationToken)));
166+
}
167+
else
168+
{
169+
Debug.Assert(!Monitor.IsEntered(this));
170+
_mrvtsc.SetResult(true);
171+
}
172+
}
58173
}
59-
else
174+
175+
/// <inheritdoc/>
176+
bool IValueTaskSource<bool>.GetResult(short token)
60177
{
61-
completionSource = new();
62-
completionSource.SetResult(!stopped);
178+
// Dispose of the cancellation registration. This is done outside of the below lock in order
179+
// to avoid a potential deadlock due to waiting for a concurrent cancellation callback that might
180+
// in turn try to take the lock. For valid usage, GetResult is only called once _ctr has been
181+
// successfully initialized before WaitForNextTickAsync returns to its synchronous caller, and
182+
// there should be no race conditions accessing it, as concurrent consumption is invalid. If there
183+
// is invalid usage, with GetResult used erroneously/concurrently, the worst that happens is cancellation
184+
// may not take effect for the in-flight operation, with its registration erroneously disposed.
185+
// Note we use Dispose rather than Unregister (which wouldn't risk deadlock) so that we know that thecancellation callback associated with this operation
186+
// won't potentially still fire after we've completed this GetResult and a new operation
187+
// has potentially started.
188+
_ctr.Dispose();
189+
190+
lock (this)
191+
{
192+
try
193+
{
194+
_mrvtsc.GetResult(token);
195+
}
196+
finally
197+
{
198+
_mrvtsc.Reset();
199+
_ctr = default;
200+
_activeWait = false;
201+
_owner = null;
202+
if (!_stopped)
203+
{
204+
_signaled = false;
205+
}
206+
}
207+
208+
return !_stopped;
209+
}
63210
}
64-
}
65211

66-
protected override void Dispose(bool disposing)
67-
{
68-
stopped = true;
69-
Signal(null);
70-
cancellationRegistration?.Unregister();
71-
timer.Dispose();
72-
base.Dispose(disposing);
212+
/// <inheritdoc/>
213+
ValueTaskSourceStatus IValueTaskSource<bool>.GetStatus(short token) => _mrvtsc.GetStatus(token);
214+
215+
/// <inheritdoc/>
216+
void IValueTaskSource<bool>.OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) =>
217+
_mrvtsc.OnCompleted(continuation, state, token, flags);
73218
}
74219
}
75-
#endif
220+
#endif

src/TimeProviderExtensions/System.Threading/TimeProviderPeriodicTimerExtensions.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ public static class TimeProviderPeriodicTimerExtensions
1515
/// may be in flight at any given moment. <see cref="System.Threading.PeriodicTimer.Dispose()"/> may be used concurrently with an active <see cref="System.Threading.PeriodicTimer.WaitForNextTickAsync"/>
1616
/// to interrupt it and cause it to return false.
1717
/// </remarks>
18-
/// <returns>A new <see cref="TimeProviderExtensions.PeriodicTimer"/>. Note, this is a wrapper around a <see cref="System.Threading.PeriodicTimer"/>.</returns>
18+
/// <returns>
19+
/// A new <see cref="TimeProviderExtensions.PeriodicTimer"/>.
20+
/// Note, this is a wrapper around a <see cref="System.Threading.PeriodicTimer"/>,
21+
/// and will behave exactly the same as the original.
22+
/// </returns>
1923
public static TimeProviderExtensions.PeriodicTimer CreatePeriodicTimer(this TimeProvider timeProvider, TimeSpan period)
2024
{
2125
if (timeProvider == TimeProvider.System)

test/TimeProviderExtensions.Tests/ManualTimeProviderPeriodicTimerTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,13 @@ public async Task PeriodicTimer_WaitForNextTickAsync_cancelled_with_exception()
6262
var sut = new ManualTimeProvider();
6363
using var periodicTimer = sut.CreatePeriodicTimer(TimeSpan.FromMilliseconds(1));
6464
var task = periodicTimer.WaitForNextTickAsync(cts.Token);
65-
cts.CancelAfter(TimeSpan.Zero);
65+
cts.Cancel();
6666

6767
var throws = async () => await task;
6868

6969
await throws
7070
.Should()
71-
.ThrowExactlyAsync<TaskCanceledException>();
71+
.ThrowAsync<OperationCanceledException>();
7272
}
7373

7474
[Fact]
@@ -182,7 +182,7 @@ static async Task CancelAfterWaitForNextTick(System.Threading.PeriodicTimer peri
182182
static async Task WaitForNextTickInLoop(TimeProvider scheduler, Action callback, TimeSpan interval)
183183
{
184184
using var periodicTimer = scheduler.CreatePeriodicTimer(interval);
185-
while (await periodicTimer.WaitForNextTickAsync(CancellationToken.None))
185+
while (await periodicTimer.WaitForNextTickAsync(CancellationToken.None).ConfigureAwait(false))
186186
{
187187
callback();
188188
}

0 commit comments

Comments
 (0)