Using FakeTimeProvider in PeriodicTimer #125077
-
|
I have the following abstraction of a internal class PeriodicTimerAdapter : IPeriodicTimer
{
/// <inheritdoc />
public async Task RunPeriodicallyAsync(TimeSpan period, Func<CancellationToken, Task> action, CancellationToken cancellationToken, TimeProvider? timeProvider = null)
{
timeProvider ??= TimeProvider.System;
using var timer = new PeriodicTimer(period, timeProvider);
// try/catch here
while (!cancellationToken.IsCancellationRequested && await timer.WaitForNextTickAsync(cancellationToken))
{
await action(cancellationToken);
}
}
}Since it accepts [Fact]
public async Task CallsActionUntilCancelled()
{
var period = TimeSpan.FromSeconds(2);
using var cts = new CancellationTokenSource();
var periodicTimer = new PeriodicTimerAdapter();
var timeProvider = new FakeTimeProvider() { AutoAdvanceAmount = period };
var actionCalledCount = 0;
var actionToCall = async (CancellationToken _) =>
{
actionCalledCount++; // Called the first time, never get the second call
if (actionCalledCount == 3)
{
await cts.CancelAsync();
}
timeProvider.Advance(period * 2); // The "right" time? Second tick never happens
};
var runTask = periodicTimer.RunPeriodicallyAsync(period, actionToCall, cts.Token, timeProvider);
timeProvider.Advance(period); // Advancing time to trigger the first tick
await runTask;
Assert.Equal(3, actionCalledCount);
}What i'm observing is that advancing fake time provider in between the ticks - doesn't seem to achieve anything (i.e. the second tick never happens). Am i using the time provider wrong? There seem to be no other hook points (apart from in between the ticks) to advance the time. And if this is by design - what's the purpose of passing time provider to periodic timer in the first place? I'm curious where i am mistaken here |
Beta Was this translation helpful? Give feedback.
Replies: 5 comments 4 replies
-
|
I suspect the problem is here: if (oldTicks != newTicks)
{
// time changed while in the callback, readjust the wake time accordingly
candidate.WakeupTime = newTicks + candidate.Period;
}You are advancing time while the callback is executing, which results in the callback getting rescheduled to a later point, which then never happens. |
Beta Was this translation helpful? Give feedback.
-
|
@svick perhaps, but in that case - what is the proper way to use |
Beta Was this translation helpful? Give feedback.
-
|
In the remarks section in documentation of TimeProvider.CreateTimer it says:
IMO either FakeTimeProvider should be explicit in documentation or perhaps it should be fixed so that it is triggered when advancing time. |
Beta Was this translation helpful? Give feedback.
-
|
The issue here isn't necessarily a bug in FakeTimeProvider, but a synchronization conflict between the AutoAdvanceAmount and the manual Advance() call inside an async callback. When you use AutoAdvanceAmount, the clock moves immediately when the timer checks the time. If you also call timeProvider.Advance(period * 2) inside the action, you are moving the target "wakeup time" further away while the PeriodicTimer is still trying to process the current state. The Clean Senior Approach: To test PeriodicTimer reliably with FakeTimeProvider, you should avoid AutoAdvanceAmount if you intend to control the ticks manually for assertions. Here is how to refactor your test to make it deterministic: Why this works: Manual Control: By removing AutoAdvanceAmount, you ensure the clock only moves when you decide. Task.Yield(): In PeriodicTimer, WaitForNextTickAsync is a state machine. Advance() triggers the timer, but the code inside your while loop needs a micro-moment to execute. await Task.Yield() ensures the loop actually runs the action before you advance the clock again. The purpose of passing TimeProvider to PeriodicTimer is exactly this: to allow the clock to be a "controlled variable" in your test suite without relying on the OS clock. |
Beta Was this translation helpful? Give feedback.
-
|
After hours of hair pulling, I came to the conclusion that there is something wrong with the implementation of I had an implementation that looked something like this public class MonitorLoop(TimeProvider timeProvider)
{
public async Task RunAsync(CancellationToken token)
{
var timer = new PeriodicTimer(600, timeProvider);
do
{
TimeSpan delay;
try
{
timer.Period = 600;
await StartMonitoringAsync(token);
}
catch (Exception monitoringException)
{
timer.Period = 60;
}
} while (await timer.WaitForNextTickAsync(token));
}
}I then had a unit test that kept failing intermittently. When I was debugging it, it kept jumping through my unit test thread and the one for [Fact]
public async Task RunAsync_WhenFailingAndThenRecovering_GoesBackToRegularInterval()
{
// Arrange
_monitoringService
.SetupSequence(x => x.StartMonitoring())
.Throws<Exception>()
.Returns(Task.CompletedTask);
// Act
_monitorLoop.RunAsync(CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromSeconds(60));
_timeProvider.Advance(TimeSpan.FromSeconds(600));
// Assert
_monitoringService
.Verify(x => x.StartMonitoring(),
Times.Exactly(3));
}You would think this is fair enough since, as stated, there is still a race condition there. However, I have seen other implementations where this hasn't been a problem, the debugger was deterministically breaking between the 2 'threads' only when time was advanced etc. I tried using I finally refactored the public class MonitorLoop(TimeProvider timeProvider)
{
public async Task RunAsync(CancellationToken token)
{
do
{
TimeSpan delay;
try
{
delay = TimeSpan.FromSeconds(600);
await StartMonitoringAsync(token);
}
catch (Exception monitoringException)
{
delay = TimeSpan.FromSeconds(60);
}
await Task.Delay(delay, timeProvider, token);
} while (!token.IsCancellationRequested);
}
}I don't know exactly why this is happening but my suspicion is that there is something in the runtime/compiler that is optimised enough when run |
Beta Was this translation helpful? Give feedback.
The issue here isn't necessarily a bug in FakeTimeProvider, but a synchronization conflict between the AutoAdvanceAmount and the manual Advance() call inside an async callback.
When you use AutoAdvanceAmount, the clock moves immediately when the timer checks the time. If you also call timeProvider.Advance(period * 2) inside the action, you are moving the target "wakeup time" further away while the PeriodicTimer is still trying to process the current state.
The Clean Senior Approach:
To test PeriodicTimer reliably with FakeTimeProvider, you should avoid AutoAdvanceAmount if you intend to control the ticks manually for assertions. Here is how to refactor your test to make it deterministic: