Table of Contents

Time and timers

Wall-clock time is the classic case where Behavior<T> earns its keep over Cell<T>. Time is not a sequence of discrete steps — it is defined at every instant — so a behavior is the honest model for it.

A timer system provides that behavior, plus a way to get a stream that fires at a chosen moment.

Choosing a timer system

Two implementations ship in SodaFlow:

Type Time is Use when
SecondsTimerSystem double, seconds since the system was created Simulations, animation, anything relative.
SystemClockTimerSystem DateTime, from DateTime.Now Scheduling against real calendar times.

Both take an exception handler, because timer callbacks run outside your call stack and an exception there has nowhere else to go:

SecondsTimerSystem timers = new SecondsTimerSystem(ex => Log.Error(ex));

Time

Time is a Behavior<T> giving the current time:

Behavior<double> now = timers.Time;

Being a behavior, it has no Listen and no Updates — you cannot subscribe to "every moment", which is exactly right, since there is no such discrete sequence. You use it by sampling it when something else happens, and Snapshot has behavior overloads for precisely this:

// Timestamp each click.
Stream<double> clickTimes = clicks.Snapshot(now, (_, t) => t);

This is the normal way to work with time in FRP: time does not push events at you, it is a value you read at the moments that matter.

At

At turns a cell of target times into a stream that fires when each target is reached:

Stream<T> At(Cell<Maybe<T>> t)

The Maybe is what makes it useful. Maybe.Some(t) arms the alarm for time t; Maybe.None disarms it. Because the argument is a cell, the target can change over time, and rescheduling is just sending a new value.

SecondsTimerSystem timers = new SecondsTimerSystem(ex => Log.Error(ex));

// Fire five seconds from now.
double target = timers.Time.Sample() + 5.0;
CellSink<Maybe<double>> alarm = Cell.CreateSink(Maybe.Some(target));

IListener l = timers.At(alarm).Listen(t => Console.WriteLine($"fired at {t}"));

// Cancel it before it fires.
alarm.Send(Maybe.None);

A repeating timer

At plus a cell loop, and nothing else — no sink, and no Transaction.Post. The alarm computes the next target, and the cell holding that target is what the alarm reads:

Stream<double> ticks =
    Transaction.Run(() =>
        Cell.Loop<Maybe<double>>()
            .WithCaptures(deadline =>
            {
                Stream<double> alarm = timers.At(deadline.AsCell());

                return (
                    Cell: alarm.Map(static t => Maybe.Some(t + 5.0)).Hold(Maybe.Some(5.0)),
                    Captures: alarm);
            })
            .Captures);

The loop is the whole trick, and it is why this is ordinary FRP rather than a callback that sends into a sink: the deadline cell is defined in terms of the alarm that reads it. See Loops.

Missed ticks are made up, one per transaction. The next target is the alarm's own time plus the period, so the period stays exact; if the process is suspended across a minute of them, the stream fires once per missed interval, each in its own transaction, as fast as transactions open.

Reading the clock rather than the alarm's value changes nothing. Inside an alarm's transaction timers.Time is that alarm's time — that is what a behavior means, the value at that instant — so snapshotting it gives the same sequence. A repeating timer built on At is fixed-rate with catch-up, and dropping stale ticks is a decision outside the graph: compare a firing's time against a clock read in a later transaction.

There is no Periodic in the library, and the loop above is why: it is short, it is ordinary FRP, and written out like this the catch-up behavior is visible instead of hidden behind an interval argument.

Consider whether you need a tick stream at all. Bounce animates without one: a ball's position is a Behavior — a function of time — and the view samples it when it paints, at whatever frequency it likes. A stream of ticks invites code that adds a delta per tick, which is the state the graph is there to remove.

Debounce

Debounce fires the last value of a stream once a quiet time has passed with no other value. Each firing pushes the alarm out, so a burst of firings produces one value:

// One search per 300ms of quiet, rather than one per keystroke.
Stream<string> searches = query.Updates().Debounce(timers, static now => now.AddMilliseconds(300));

The second argument is the deadline: given the time of a firing, return the time to fire at. It is a function rather than a duration because the time type is yours — DateTime for SystemClockTimerSystem, double seconds for SecondsTimerSystem, whatever your own implementation uses. It must return a time after the one it is given; an earlier time is a time the clock has already passed, and the alarm fires at the next transaction.

Three things are worth knowing:

  • The result is in the alarm's transaction, never in the transaction of a firing. A graph that reads both sees two instants, which is correct — they happen at two times.
  • It does not cancel work already started. Debouncing and SwitchLatest solve different halves of the same problem: debouncing stops requests that should never have been made, and a cancelling MapAsync strategy stops the ones already in flight whose results nobody wants. A search box usually wants both. See Asynchronous work.
  • A firing that lands in the alarm's own transaction re-arms it rather than being swallowed, which is what makes a debounce safe to put in a loop.

Writing this by hand takes a cell loop, a deadline cell, a Hold for the last value, and care about which side of an OrElse wins when a firing and the alarm share a transaction. That is the reason it is in the library.

Testing

TimerSystem<T> is built on ITimerSystemImplementation<T>, whose entire contract is Now, Start, SetTimer, and RunTimersTo. That is the reason to prefer a timer system over DateTime.Now scattered through your logic: supply your own implementation and time becomes a value the test controls.

Implement the interface directly for a test. TimerSystemImplementationBase<T> is the base for a real clock — its Start spins a background thread that waits out the interval to the next alarm — which is the one thing a deterministic test must not do. Four members is the whole job:

internal sealed class ManualClock : ITimerSystemImplementation<double>
{
    private readonly List<ManualTimer> timers = [];

    public double Now { get; private set; }

    // Nothing to start: this clock only moves when the test moves it.
    public void Start(Action<Exception> handleException)
    {
    }

    public ITimer SetTimer(double time, Action callback)
    {
        ManualTimer timer = new ManualTimer(time, callback);
        this.timers.Add(timer);
        return timer;
    }

    public void RunTimersTo(double now)
    {
        // A copy, because a callback that runs here can set a timer of its own.
        ManualTimer[] due = [.. this.timers.Where(t => !t.Canceled && t.Time <= now)];

        foreach (ManualTimer timer in due)
        {
            this.timers.Remove(timer);
            timer.Fire();
        }
    }

    public void AdvanceTo(double now)
    {
        this.Now = now;
        this.RunTimersTo(now);
    }

    // ITimer is an IDisposable, and disposing one cancels it.
    private sealed class ManualTimer(double time, Action callback) : ITimer
    {
        public double Time { get; } = time;

        public bool Canceled { get; private set; }

        public void Cancel() => this.Canceled = true;

        public void Dispose() => this.Cancel();

        public void Fire() => callback();
    }
}

The test then reads as a sequence of instants rather than a sequence of sleeps:

ManualClock clock = new ManualClock();
ITimerSystem<double> timers = new TimerSystem<double>(clock, static ex => throw ex);

CellSink<Maybe<double>> alarm = Cell.CreateSink(Maybe.Some(5.0));
List<double> fired = [];

using (timers.At(alarm).ListenStrong(fired.Add))
{
    clock.AdvanceTo(4.9);
    // fired is still empty.

    clock.AdvanceTo(5.0);
    // fired now holds one value, and nothing slept to get it there.
}

Nothing in that test depends on wall-clock timing, so it cannot be flaky and it runs as fast as the processor can walk the graph. TimerGarbageCollectionTests in SodaFlow.Tests.Memory uses exactly this shape.

The F# implementation mirrors all of this in SodaFlow.Time — TimerSystem<'T>, SecondsTimerSystem, SystemClockTimerSystem, and the same interfaces.