Description
Background and motivation
Background and motivation
I was looking for a way to test a framework with blocking methods to test asynchronous browser behavior. The solution direction that I am exploring is to use the FakeTimeProvider so I have much more control on the flow of time. But since the method is blocking, I need a way to progress time while the behavior is executing.
My mental model was related to automatic timers (e.g. to switch lights on/off). The way to test those is to turn the clock and see whether the expected behavior happens. So just advance the time till the next timer executes, than wait till the behavior is done (completed, or blocked), then advance the timer again, and so on until the original captured behavior is completed.
The goal is to not depend on real time progress, but make the execution fully deterministic based on the FakeTimeProvider advancements.
API Proposal
/// <summary>
/// Advances the clock per timer until the action is completed.
/// </summary>
public void StepwiseAdvanceTillDone(Action act)
{
if (Interlocked.CompareExchange(ref _wakeWaitersGate, 1, 0) == 1)
{
// some other thread is already in here, so let it take care of things
return;
}
var actWaiter = new Waiter(_ => act(), null, 0)
{
ScheduledOn = _now.Ticks,
WakeupTime = _now.Ticks
};
Exception? actException = null;
lock (Waiters)
{
_ = Waiters.Add(actWaiter);
}
var triggeredTasks = new List<Task>();
var waitersInProgress = new List<Waiter>();
Task? actTask = null;
while (Waiters.Count > 0 && !(actTask?.IsCompleted ?? false))
{
var clonedWaiters = DeepCloneWaiters();
_ = waitersInProgress.RemoveAll(x => !clonedWaiters.Contains(x, new WaiterEqualityComparer()));
Waiter? currentWaiter;
lock (Waiters)
{
currentWaiter = Waiters.Except(waitersInProgress, new WaiterEqualityComparer()).OrderBy(x => x.WakeupTime).ThenBy(x => x.ScheduledOn).FirstOrDefault();
}
Task? advancer = null;
if (currentWaiter != null)
{
var currentAdvanceTime = TimeSpan.FromTicks(currentWaiter.WakeupTime - _now.Ticks);
waitersInProgress.Add(new Waiter(currentWaiter._callback, currentWaiter._state, currentWaiter.Period)
{
ScheduledOn = currentWaiter.ScheduledOn,
WakeupTime = currentWaiter.WakeupTime
});
lock (Waiters)
{
_now += currentAdvanceTime;
}
advancer = Task.Run(() =>
{
#pragma warning disable CA1031 // Do not catch general exception types
try
{
currentWaiter.InvokeCallback();
}
catch (Exception ex)
{
if (currentWaiter == actWaiter)
{
actException = ex;
}
}
#pragma warning restore CA1031 // Do not catch general exception types
lock (Waiters)
{
// see if we need to reschedule the waiter
if (currentWaiter.Period > 0)
{
// move on to the next period
currentWaiter.WakeupTime += currentWaiter.Period;
}
else
{
// this waiter is never running again, so remove from the set.
RemoveWaiter(currentWaiter);
}
}
});
if (currentWaiter == actWaiter)
{
actTask = advancer;
}
}
HashSet<Waiter> clonedWaiters2;
do
{
_ = Thread.Yield();
clonedWaiters2 = DeepCloneWaiters();
}
while (
!clonedWaiters2.Except(clonedWaiters, new WaiterEqualityComparer()).Any() && // schedules not changed
!(actTask?.IsCompleted ?? false)); // action not completed
}
try
{
if (actException != null)
{
throw new AggregateException("Exception occurred while running the action.", actException);
}
}
finally
{
_wakeWaitersGate = 0;
}
}
private HashSet<Waiter> DeepCloneWaiters()
{
var clonedWaiters = new HashSet<Waiter>();
lock (Waiters)
{
foreach (var waiter in Waiters)
{
var clonedWaiter = new Waiter(waiter._callback, waiter._state, waiter.Period)
{
ScheduledOn = waiter.ScheduledOn,
WakeupTime = waiter.WakeupTime
};
_ = clonedWaiters.Add(clonedWaiter);
}
}
return clonedWaiters;
}
private class WaiterEqualityComparer : IEqualityComparer<Waiter>
{
public bool Equals(Waiter? b1, Waiter? b2)
{
if (ReferenceEquals(b1, b2))
{
return true;
}
if (b2 is null || b1 is null)
{
return false;
}
return b1.WakeupTime == b2.WakeupTime
&& b1.ScheduledOn == b2.ScheduledOn
&& b1.Period == b2.Period
&& b1._callback == b2._callback
&& b1._state == b2._state;
}
public int GetHashCode(Waiter box) => box.WakeupTime.GetHashCode() ^ box.ScheduledOn.GetHashCode() ^ box.Period.GetHashCode() ^ box._callback.GetHashCode() ^ box._state?.GetHashCode() ?? 1;
}
API Usage
[Fact]
public void StepwiseAdvanceTillDone()
{
FakeTimeProvider timeProvider = new();
var testCalendar = new AutomaticCalendar(timeProvider);
timeProvider.StepwiseAdvanceTillDone(() => WaitTillWithTimeout(
testCalendar, x => x.DayOfWeek == "Friday", TimeSpan.FromDays(10), TimeSpan.FromHours(1), timeProvider));
Assert.Equal(timeProvider.Start.AddDays(6), timeProvider.GetUtcNow());
Assert.Equal(DayOfWeek.Friday, timeProvider.GetUtcNow().DayOfWeek);
}
[Fact]
public void StepwiseAdvanceTillDoneWithException()
{
FakeTimeProvider timeProvider = new();
var testCalendar = new AutomaticCalendar(timeProvider);
var pollingPeriod = TimeSpan.FromDays(1);
var timeout = TimeSpan.FromDays(365);
Assert.Throws<AggregateException>(() => timeProvider.StepwiseAdvanceTillDone(
() => WaitTillWithTimeout(testCalendar, x => x.DayOfWeek == "LeapDay", timeout, pollingPeriod, timeProvider)));
Assert.Equal(timeProvider.Start.Add(timeout).Add(pollingPeriod), timeProvider.GetUtcNow());
}
Alternative Designs
I could think of adding an advance method that advances till next timer executes, but that does not solve my issue of advancing the time parallel to executing a blocking method:
public bool Advance()
{
Waiter? nextWaiter;
lock (Waiters)
{
nextWaiter = Waiters.OrderBy(x => x.WakeupTime).ThenBy(x => x.ScheduledOn).FirstOrDefault();
}
if (nextWaiter is null)
{
return false;
}
Advance(TimeSpan.FromTicks(nextWaiter.WakeupTime - _now.Ticks));
return true;
}
Risks
No response