Skip to content

Commit 661de5a

Browse files
committed
fix: allow ManualTimer finalizer to run
1 parent 760431d commit 661de5a

File tree

4 files changed

+178
-158
lines changed

4 files changed

+178
-158
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using System.Runtime.CompilerServices;
2+
3+
namespace TimeProviderExtensions;
4+
5+
/// <summary>
6+
/// Represents a synthetic time provider that can be used to enable deterministic behavior in tests.
7+
/// </summary>
8+
/// <remarks>
9+
/// Learn more at <see href="https://github.com/egil/TimeProviderExtensions"/>.
10+
/// </remarks>
11+
public partial class ManualTimeProvider : TimeProvider
12+
{
13+
private sealed class ManualTimer : ITimer
14+
{
15+
private ManualTimerScheduler? scheduledCallback;
16+
17+
public ManualTimer(TimerCallback callback, object? state, ManualTimeProvider timeProvider)
18+
{
19+
scheduledCallback = new ManualTimerScheduler(timeProvider, callback, state);
20+
}
21+
22+
/// <inheritdoc/>
23+
public bool Change(TimeSpan dueTime, TimeSpan period)
24+
{
25+
ValidateTimeSpanRange(dueTime);
26+
ValidateTimeSpanRange(period);
27+
28+
if (scheduledCallback is null)
29+
{
30+
return false;
31+
}
32+
33+
scheduledCallback.Change(dueTime, period);
34+
35+
return true;
36+
}
37+
38+
// In case the timer is not disposed explicitly by the user.
39+
~ManualTimer() => Dispose(false);
40+
41+
public void Dispose()
42+
{
43+
Dispose(true);
44+
GC.SuppressFinalize(this);
45+
}
46+
47+
public ValueTask DisposeAsync()
48+
{
49+
Dispose(true);
50+
GC.SuppressFinalize(this);
51+
return ValueTask.CompletedTask;
52+
}
53+
54+
private void Dispose(bool _)
55+
{
56+
if (scheduledCallback is null)
57+
{
58+
return;
59+
}
60+
61+
scheduledCallback.Cancel();
62+
scheduledCallback = null;
63+
}
64+
65+
private static void ValidateTimeSpanRange(TimeSpan time, [CallerArgumentExpression("time")] string? parameter = null)
66+
{
67+
long tm = (long)time.TotalMilliseconds;
68+
if (tm < -1)
69+
{
70+
throw new ArgumentOutOfRangeException(parameter, $"{parameter}.TotalMilliseconds must be greater than -1.");
71+
}
72+
73+
if (tm > MaxSupportedTimeout)
74+
{
75+
throw new ArgumentOutOfRangeException(parameter, $"{parameter}.TotalMilliseconds must be less than than {MaxSupportedTimeout}.");
76+
}
77+
}
78+
}
79+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
namespace TimeProviderExtensions;
2+
3+
/// <summary>
4+
/// Represents a synthetic time provider that can be used to enable deterministic behavior in tests.
5+
/// </summary>
6+
/// <remarks>
7+
/// Learn more at <see href="https://github.com/egil/TimeProviderExtensions"/>.
8+
/// </remarks>
9+
public partial class ManualTimeProvider : TimeProvider
10+
{
11+
// The reason this class exists and it is separate from ManualTimer is that
12+
// the GC should be collect the ManualTimer in case users forget to dispose it.
13+
// If all the references captured by this type was part of the ManualTimer
14+
// type, the finalizer would not be invoked on ManualTimer if a callback was scheduled.
15+
private sealed class ManualTimerScheduler : IComparable<ManualTimerScheduler>
16+
{
17+
private readonly TimerCallback callback;
18+
private readonly object? state;
19+
private readonly ManualTimeProvider timeProvider;
20+
private TimeSpan period;
21+
private bool running;
22+
23+
public DateTimeOffset CallbackTime { get; set; }
24+
25+
public ManualTimerScheduler(ManualTimeProvider timeProvider, TimerCallback callback, object? state)
26+
{
27+
this.timeProvider = timeProvider;
28+
this.callback = callback;
29+
this.state = state;
30+
}
31+
32+
public int CompareTo(ManualTimerScheduler? other)
33+
=> other is not null
34+
? Comparer<DateTimeOffset>.Default.Compare(CallbackTime, other.CallbackTime)
35+
: -1;
36+
37+
internal void Cancel()
38+
{
39+
if (running)
40+
{
41+
timeProvider.RemoveCallback(this);
42+
}
43+
}
44+
45+
internal void Change(TimeSpan dueTime, TimeSpan period)
46+
{
47+
Cancel();
48+
49+
this.period = period;
50+
51+
if (dueTime != Timeout.InfiniteTimeSpan)
52+
{
53+
ScheduleCallback(dueTime);
54+
}
55+
}
56+
57+
internal void TimerElapsed()
58+
{
59+
running = false;
60+
61+
callback.Invoke(state);
62+
63+
if (period != Timeout.InfiniteTimeSpan && period != TimeSpan.Zero)
64+
{
65+
ScheduleCallback(period);
66+
}
67+
}
68+
69+
private void ScheduleCallback(TimeSpan waitTime)
70+
{
71+
running = true;
72+
73+
if (waitTime == TimeSpan.Zero)
74+
{
75+
TimerElapsed();
76+
}
77+
else
78+
{
79+
timeProvider.ScheduleCallback(this, waitTime);
80+
}
81+
}
82+
}
83+
}

src/TimeProviderExtensions/ManualTimeProvider.cs

Lines changed: 15 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System.Diagnostics;
22
using System.Globalization;
3-
using System.Runtime.CompilerServices;
43

54
namespace TimeProviderExtensions;
65

@@ -11,13 +10,13 @@ namespace TimeProviderExtensions;
1110
/// Learn more at <see href="https://github.com/egil/TimeProviderExtensions"/>.
1211
/// </remarks>
1312
[DebuggerDisplay("{ToString()}. Scheduled callback count: {ScheduledCallbackCount}")]
14-
public class ManualTimeProvider : TimeProvider
13+
public partial class ManualTimeProvider : TimeProvider
1514
{
1615
internal const uint MaxSupportedTimeout = 0xfffffffe;
1716
internal const uint UnsignedInfinite = unchecked((uint)-1);
1817
internal static readonly DateTimeOffset DefaultStartDateTime = new(2000, 1, 1, 0, 0, 0, 0, TimeSpan.Zero);
1918

20-
private readonly List<ManualTimerScheduledCallback> callbacks = new();
19+
private readonly List<ManualTimerScheduler> callbacks = new();
2120
private DateTimeOffset utcNow;
2221
private TimeZoneInfo localTimeZone;
2322
private TimeSpan autoAdvanceAmount = TimeSpan.Zero;
@@ -227,7 +226,7 @@ public void SetUtcNow(DateTimeOffset value)
227226
{
228227
if (value < utcNow)
229228
{
230-
throw new ArgumentOutOfRangeException(nameof(value), $"The new UtcNow must be greater than or equal to the curren time {utcNow}. Going back in time is not supported.");
229+
throw new ArgumentOutOfRangeException(nameof(value), $"The new UtcNow must be greater than or equal to the curren time {ToString()}. Going back in time is not supported.");
231230
}
232231

233232
lock (callbacks)
@@ -238,16 +237,16 @@ public void SetUtcNow(DateTimeOffset value)
238237
return;
239238
}
240239

241-
while (utcNow <= value && TryGetNext(value) is ManualTimerScheduledCallback mtsc)
240+
while (utcNow <= value && TryGetNext(value) is ManualTimerScheduler mtsc)
242241
{
243242
utcNow = mtsc.CallbackTime;
244-
mtsc.Timer.TimerElapsed();
243+
mtsc.TimerElapsed();
245244
}
246245

247246
utcNow = value;
248247
}
249248

250-
ManualTimerScheduledCallback? TryGetNext(DateTimeOffset targetUtcNow)
249+
ManualTimerScheduler? TryGetNext(DateTimeOffset targetUtcNow)
251250
{
252251
if (callbacks.Count > 0 && callbacks[0].CallbackTime <= targetUtcNow)
253252
{
@@ -266,174 +265,33 @@ public void SetUtcNow(DateTimeOffset value)
266265
/// <returns>A string representing the clock's current time.</returns>
267266
public override string ToString() => utcNow.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture);
268267

269-
private void ScheduleCallback(ManualTimer timer, TimeSpan waitTime)
268+
private void ScheduleCallback(ManualTimerScheduler scheduler, TimeSpan waitTime)
270269
{
271270
lock (callbacks)
272271
{
273-
var timerCallback = new ManualTimerScheduledCallback(timer, utcNow + waitTime);
274-
var insertPosition = callbacks.FindIndex(x => x.CallbackTime > timerCallback.CallbackTime);
272+
scheduler.CallbackTime = utcNow + waitTime;
273+
274+
var insertPosition = callbacks.FindIndex(x => x.CallbackTime > scheduler.CallbackTime);
275275

276276
if (insertPosition == -1)
277277
{
278-
callbacks.Add(timerCallback);
278+
callbacks.Add(scheduler);
279279
}
280280
else
281281
{
282-
callbacks.Insert(insertPosition, timerCallback);
282+
callbacks.Insert(insertPosition, scheduler);
283283
}
284284
}
285285
}
286286

287-
private void RemoveCallback(ManualTimer timer)
287+
private void RemoveCallback(ManualTimerScheduler timerCallback)
288288
{
289289
lock (callbacks)
290290
{
291-
var existingIndexOf = callbacks.FindIndex(0, x => ReferenceEquals(x.Timer, timer));
291+
var existingIndexOf = callbacks.FindIndex(0, x => ReferenceEquals(x, timerCallback));
292292
if (existingIndexOf >= 0)
293-
callbacks.RemoveAt(existingIndexOf);
294-
}
295-
}
296-
297-
private readonly struct ManualTimerScheduledCallback :
298-
IEqualityComparer<ManualTimerScheduledCallback>,
299-
IComparable<ManualTimerScheduledCallback>
300-
{
301-
public readonly ManualTimer Timer { get; }
302-
303-
public readonly DateTimeOffset CallbackTime { get; }
304-
305-
public ManualTimerScheduledCallback(ManualTimer timer, DateTimeOffset callbackTime)
306-
{
307-
Timer = timer;
308-
CallbackTime = callbackTime;
309-
}
310-
311-
public readonly bool Equals(ManualTimerScheduledCallback x, ManualTimerScheduledCallback y)
312-
=> ReferenceEquals(x.Timer, y.Timer);
313-
314-
public readonly int GetHashCode(ManualTimerScheduledCallback obj)
315-
=> Timer.GetHashCode();
316-
317-
public readonly int CompareTo(ManualTimerScheduledCallback other)
318-
=> Comparer<DateTimeOffset>.Default.Compare(CallbackTime, other.CallbackTime);
319-
}
320-
321-
private sealed class ManualTimer : ITimer
322-
{
323-
private ManualTimeProvider? timeProvider;
324-
private bool isDisposed;
325-
private bool running;
326-
327-
private TimeSpan currentDueTime;
328-
private TimeSpan currentPeriod;
329-
private object? state;
330-
private TimerCallback? callback;
331-
332-
public ManualTimer(TimerCallback callback, object? state, ManualTimeProvider timeProvider)
333-
{
334-
this.timeProvider = timeProvider;
335-
this.callback = callback;
336-
this.state = state;
337-
}
338-
339-
public bool Change(TimeSpan dueTime, TimeSpan period)
340-
{
341-
ValidateTimeSpanRange(dueTime);
342-
ValidateTimeSpanRange(period);
343-
344-
if (isDisposed || timeProvider is null)
345-
{
346-
return false;
347-
}
348-
349-
if (running)
350-
{
351-
timeProvider.RemoveCallback(this);
352-
}
353-
354-
currentDueTime = dueTime;
355-
currentPeriod = period;
356-
357-
if (currentDueTime != Timeout.InfiniteTimeSpan)
358-
{
359-
ScheduleCallback(dueTime);
360-
}
361-
362-
return true;
363-
}
364-
365-
public void Dispose()
366-
{
367-
if (isDisposed || timeProvider is null)
368-
{
369-
return;
370-
}
371-
372-
isDisposed = true;
373-
374-
if (running)
375-
{
376-
timeProvider.RemoveCallback(this);
377-
}
378-
379-
callback = null;
380-
state = null;
381-
timeProvider = null;
382-
}
383-
384-
public ValueTask DisposeAsync()
385-
{
386-
Dispose();
387-
return ValueTask.CompletedTask;
388-
}
389-
390-
internal void TimerElapsed()
391-
{
392-
if (isDisposed || timeProvider is null)
393-
{
394-
return;
395-
}
396-
397-
running = false;
398-
399-
callback?.Invoke(state);
400-
401-
if (currentPeriod != Timeout.InfiniteTimeSpan && currentPeriod != TimeSpan.Zero)
402293
{
403-
ScheduleCallback(currentPeriod);
404-
}
405-
}
406-
407-
private void ScheduleCallback(TimeSpan waitTime)
408-
{
409-
if (isDisposed || timeProvider is null)
410-
{
411-
return;
412-
}
413-
414-
running = true;
415-
416-
if (waitTime == TimeSpan.Zero)
417-
{
418-
TimerElapsed();
419-
}
420-
else
421-
{
422-
timeProvider.ScheduleCallback(this, waitTime);
423-
}
424-
}
425-
426-
private static void ValidateTimeSpanRange(TimeSpan time, [CallerArgumentExpression("time")] string? parameter = null)
427-
{
428-
long tm = (long)time.TotalMilliseconds;
429-
if (tm < -1)
430-
{
431-
throw new ArgumentOutOfRangeException(parameter, $"{parameter}.TotalMilliseconds must be greater than -1.");
432-
}
433-
434-
if (tm > MaxSupportedTimeout)
435-
{
436-
throw new ArgumentOutOfRangeException(parameter, $"{parameter}.TotalMilliseconds must be less than than {MaxSupportedTimeout}.");
294+
callbacks.RemoveAt(existingIndexOf);
437295
}
438296
}
439297
}

0 commit comments

Comments
 (0)