dotnet / extensions

This repository contains a suite of libraries that provide facilities commonly needed when creating production-ready applications.
MIT License
2.65k stars 753 forks source link

[API Proposal]: FakeTimeProvider.StepwiseAdvanceTillDone #5441

Open wjgerritsen-0001 opened 1 month ago

wjgerritsen-0001 commented 1 month ago

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.

automatic timer switch

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

RussKie commented 1 week ago

@dotnet/dotnet-extensions-fundamentals please triage