eta_time (eta v0.1.0)

Copy Markdown

A virtual clock and timer wheel — Phase 1 of the DST framework (design: docs/design.md).

Drop-in replacements for the erlang timer and clock BIFs, backed by a clock that only moves when the driver moves it. eta_transform rewrites a module's calls to point here; nothing else about the module changes.

Two things follow, and the second is the one that pays for the phase:

  • Determinism. A timeout fires at a point the schedule chooses, not whenever the machine happens to get there, so a run replays identically.
  • Speed. Waiting out a one-second timeout costs nothing: with no process runnable, the driver jumps the clock straight to the next deadline. A run that spends most of its wall clock waiting collapses to the work it actually does.

No process of its own

State lives in ETS, and every operation is a direct table access from whichever process is calling. That is deliberate. A gen_server here would make every send_after/3 a blocking call, and a blocking call is quiescence as far as eta_sched is concerned — so each timer set would end a step, cost a scheduling choice, and pollute the recorded schedule with the framework's own traffic.

Concurrent access is safe because eta_sched guarantees only one process under test runs at a time, with the driver the only other participant.

Inert unless started

Every function falls back to its real erlang counterpart when no clock is running. A module compiled with the transform therefore behaves exactly as it did before outside a simulation, which is what makes the transform safe to leave enabled in a build that also runs ordinary tests.

The event loop

The driver alternates between stepping processes and advancing time:

eta_sched:run(Sched, MaxSteps, fun eta_time:advance_to_next/0)

advance_to_next/0 is called only when nothing is runnable, and moves the clock to the earliest pending deadline — so time advances in jumps between events, never in ticks, and never while work remains to be done at the current instant.

Single instance

The tables are named, so one clock exists per VM. Simulations must not run concurrently in the same node.

This documentation is LLM-generated. See the AI disclosure in README.md.

Summary

Functions

Advances the clock by Ms, firing every timer that becomes due. Returns how many fired.

Advances the clock to the earliest pending deadline and fires every timer due at it, in creation order.

Advances to the earliest deadline whose destination Schedulable accepts.

Arms the virtual timeout behind a rewritten receive ... after.

Arms a deadline a process sets for itself, delivered as Msg.

Cancels a timer, returning the milliseconds that were left, or false if it had already fired or never existed — the same contract as erlang:cancel_timer/1.

Cancels an armed timeout and removes its message if it already landed.

Cancels a deadline armed by arm_self/2, removing its message if it already landed.

The earliest pending deadline, or infinity if no timers are set.

The earliest pending deadline whose destination Schedulable accepts.

The virtual clock in milliseconds. Raises if no clock is running.

How many timers are currently pending.

Whether a virtual clock is running. When false, every call here delegates to erlang.

Sets the timer fault policy.

What eta_transform rewrites timer:sleep/1 to.

Starts a clock with default options. See start/1.

Starts the virtual clock.

Clock value, pending timer count, and how many timers have fired or been dropped.

How many distinct timers the clock has refused to advance to — see advance_to_next/1.

Types

fault_opts()

-type fault_opts() :: #{drop_p => float(), skew_ms => non_neg_integer()}.

Functions

advance(Ms)

-spec advance(non_neg_integer()) -> non_neg_integer().

Advances the clock by Ms, firing every timer that becomes due. Returns how many fired.

advance_to_next/0 is usually what a run wants; this is for a test that needs to say "and then a second passed" regardless of what is pending.

advance_to_next()

-spec advance_to_next() -> boolean().

Advances the clock to the earliest pending deadline and fires every timer due at it, in creation order.

Returns true if the clock moved, false if nothing was pending — which is the signal the event loop uses to decide a run is over. Suitable as eta_sched:run/3's idle callback.

advance_to_next(Schedulable)

-spec advance_to_next(fun((pid() | atom()) -> boolean())) -> boolean().

Advances to the earliest deadline whose destination Schedulable accepts.

A timer is owned by a process, and the scheduler is not obliged to own that process. A deadline belonging to one it does not own cannot make anything runnable, so advancing to it moves the clock and records an entry on behalf of something that can never take a step. Two runs of one seed then differ by an inserted {clock, Ms} with no step behind it, which is a schedule decided by something other than the seed.

There are 2 ordinary ways to get such a timer. A process killed while blocked in a rewritten receive ... after never reaches the clause head that disarms it, so its deadline outlives it. And a process spawned before the scheduler existed — anything a system starts during init/2 — is outside the schedule by construction.

Rejected timers are skipped, not cancelled. Cancelling would silently disarm a live process's timer, and the goal is only that a stray stops deciding when the clock moves. If time passes far enough for another reason, fire_due/1 delivers it as before.

advance_to_next/0 accepts everything, which is what you want when driving the clock by hand with no scheduler to ask.

arm_after/2

-spec arm_after(timeout(), reference()) -> term().

Arms the virtual timeout behind a rewritten receive ... after.

eta_transform turns the after block into an ordinary receive clause waiting on {'$eta_after', Ref}, and this is what makes that message arrive. The handle it returns is opaque and belongs to disarm_after/2.

infinity arms nothing — the generated clause then simply cannot match, which is exactly receive ... end.

A zero timeout is delivered immediately rather than through a timer. after 0 is a mailbox poll, not a wait: routing it through the timer wheel would block until something advanced the clock, turning a non-blocking poll into a wait. Appending to our own mailbox also keeps the ordering right, since a selective receive scans in arrival order and therefore still prefers anything already queued.

Exempt from drop_p

Skewed like anything else, never dropped. drop_p models a timer lost with the process or connection it belonged to (see set_faults/1), and this timer has neither: a process arms it for itself, and the BEAM does not lose a receive timeout. eta_net:call/3 arms its call deadline through here for the same reason.

Dropping one is not a fault, it is a hang. The waiting process has nothing left that can wake it, so the run stalls or burns its step budget, and the trace says a timeout never fired rather than that a message was lost — a finding about the framework wearing the shape of a finding about the system. send_after/3 and start_timer/3 stay faultable, because a timer a system sets to fire at another process is exactly the thing that can go missing with it.

arm_self(Time, Msg)

-spec arm_self(non_neg_integer(), term()) -> reference().

Arms a deadline a process sets for itself, delivered as Msg.

arm_after/2 with the message chosen by the caller instead of fixed, and the same reasoning applies to all of it: exempt from drop_p because a process cannot lose a timer it armed for itself, subject to skew_ms because when it lands relative to other work is a real question.

eta_statem is the caller — a gen_statem state time-out is exactly this shape, a deadline the machine sets for itself. Pair with disarm_self/2.

cancel_timer(Ref)

-spec cancel_timer(reference()) -> non_neg_integer() | false.

Cancels a timer, returning the milliseconds that were left, or false if it had already fired or never existed — the same contract as erlang:cancel_timer/1.

cancel_timer(Ref, Opts)

-spec cancel_timer(reference(), list()) -> non_neg_integer() | false | ok.

disarm_after/2

-spec disarm_after(term(), reference()) -> ok.

Cancels an armed timeout and removes its message if it already landed.

The transform calls this at the head of every ordinary receive clause rather than after the receive, which is what keeps the clause bodies in tail position — see eta_transform. Flushing is not optional: a timer that fired before its receive matched something else leaves a {'$eta_after', Ref} nothing will ever match again.

disarm_self/2

-spec disarm_self(reference() | undefined, term()) -> ok.

Cancels a deadline armed by arm_self/2, removing its message if it already landed.

The flush is what makes a cancel a cancel rather than a race. A deadline that fired just before this ran has already put Msg in the mailbox, and the caller has by definition stopped expecting it — for a gen_statem that message would otherwise arrive as an info event for a time-out that was called off.

Skipped when the cancel succeeded, since a timer removed before it fired never sent anything, and the flush is a selective receive that walks the whole mailbox before giving up.

monotonic_time()

-spec monotonic_time() -> integer().

monotonic_time(Unit)

-spec monotonic_time(erlang:time_unit()) -> integer().

next_deadline()

-spec next_deadline() -> integer() | infinity.

The earliest pending deadline, or infinity if no timers are set.

next_deadline(Schedulable)

-spec next_deadline(fun((pid() | atom()) -> boolean())) -> integer() | infinity.

The earliest pending deadline whose destination Schedulable accepts.

Timers it rejects are stepped over and left in the wheel. They no longer decide when the clock moves, but if it reaches them for some other reason they still fire. See advance_to_next/1 for why that distinction matters.

now_ms()

-spec now_ms() -> integer().

The virtual clock in milliseconds. Raises if no clock is running.

pending()

-spec pending() -> non_neg_integer().

How many timers are currently pending.

read_timer(Ref)

-spec read_timer(reference()) -> non_neg_integer() | false.

running()

-spec running() -> boolean().

Whether a virtual clock is running. When false, every call here delegates to erlang.

send_after(Time, Dest, Msg)

-spec send_after(non_neg_integer(), pid() | atom(), term()) -> reference().

send_after(Time, Dest, Msg, Opts)

-spec send_after(non_neg_integer(), pid() | atom(), term(), list()) -> reference().

set_faults(Faults)

-spec set_faults(fault_opts()) -> ok.

Sets the timer fault policy.

  • drop_p — probability that a timer, once set, never fires. It remains cancellable and readable; it simply produces no message. Models a timer lost with the process or connection it belonged to.
  • skew_ms — timers are created with their deadline perturbed uniformly by up to this many milliseconds either way (never earlier than now). Models a timeout firing in an unlucky window relative to other work.

Skew is applied at creation rather than at firing, so the deadline remains meaningful to read_timer/1 and cancel_timer/1.

drop_p reaches send_after/3 and start_timer/3 only. A deadline armed through arm_after/2 — a rewritten receive ... after, a rewritten timer:sleep/1, or eta_net:call/3's timeout — is skewed but never dropped; see arm_after/2 for why. skew_ms applies to everything, which is what makes it the knob for the tie between two timeouts written with the same number.

sleep/1

-spec sleep(timeout()) -> ok.

What eta_transform rewrites timer:sleep/1 to.

A sleep is receive after T -> ok end inside OTP's timer module, where no transform of user code reaches — so the call site is rewritten instead, to this: the after rewrite applied to a receive with no other clauses. Arm through arm_after/2, wait for the delivery. Under a run the sleeper is correctly unrunnable until the virtual clock delivers the wakeup, so a 60-second sleep costs microseconds and ends at a point the schedule chose. With no clock running, arm_after/2 falls back to erlang:send_after/3 and this is a real sleep.

No disarm: the receive has one clause and always consumes the message. infinity arms nothing, so the receive blocks forever — which is timer:sleep(infinity) exactly. Exempt from drop_p like every self-armed deadline, and subject to skew_ms like every deadline; see arm_after/2.

The one place this must not run is the driver's own process. The clock advances only to deadlines of processes the scheduler can step, so a sleep on the driver arms a deadline nothing will ever reach. That is the existing boundary rule — execute/2 and check/1 must not execute transformed blocking code — applied to one more form of blocking.

start()

-spec start() -> ok.

Starts a clock with default options. See start/1.

start(Opts)

-spec start(#{start_ms => integer(), seed => integer(), faults => fault_opts()}) -> ok.

Starts the virtual clock.

Options:

  • start_ms — the clock's initial value (default 0).
  • seed — seeds the fault RNG, so an injected timer fault schedule replays.
  • faults — see set_faults/1.

Idempotent in the sense that starting over a running clock resets it.

start_timer(Time, Dest, Msg)

-spec start_timer(non_neg_integer(), pid() | atom(), term()) -> reference().

start_timer(Time, Dest, Msg, Opts)

-spec start_timer(non_neg_integer(), pid() | atom(), term(), list()) -> reference().

stats()

-spec stats() -> #{atom() => term()}.

Clock value, pending timer count, and how many timers have fired or been dropped.

stop()

-spec stop() -> ok.

strays()

-spec strays() -> non_neg_integer().

How many distinct timers the clock has refused to advance to — see advance_to_next/1.

Counted at the moment one is stepped over rather than by scanning what is still pending, because a stray is usually gone by the end: some other timer advances the clock past it and fire_due/1 delivers it on the way. The damage is done when it is considered, not when it fires.

eta_run reports this as stray_timers, and audit/1 treats a non-zero count as a system holding a timer the scheduler does not own.

system_time()

-spec system_time() -> integer().

system_time(Unit)

-spec system_time(erlang:time_unit()) -> integer().

timestamp()

-spec timestamp() -> erlang:timestamp().