Skip to content

Deterministic virtual time: clocks, sleep, deadlines, cancellation, and timer-aware replay - #3

Merged
schani merged 11 commits into
mainfrom
timers
Jul 20, 2026
Merged

Deterministic virtual time: clocks, sleep, deadlines, cancellation, and timer-aware replay#3
schani merged 11 commits into
mainfrom
timers

Conversation

@schani

@schani schani commented Jul 20, 2026

Copy link
Copy Markdown
Member

Implements TIMERS-SPEC.md (added as the first commit): deterministic virtual time for the simulation, so timeout-heavy scenarios run instantly, replay exactly, and use the same business logic in production.

What's new

  • Virtual clocktask.monotonicNow() / task.wallNow() (configurable wall-clock epoch). Virtual in simulation, performance.now()/Date.now() in noSimulation.
  • Deterministic sleeptask.sleep(ms, reason, { signal? }) parks the task with a pending timer. Timer deadlines are lower bounds: when no task is runnable, an entropy-chosen pending timer — any of them — fires and the clock advances to max(now, deadline), so a 2,000ms timer can fire before a 1,000ms one, exactly as setTimeout lateness permits in production. Firing order is an ordinary recorded entropy decision; a single pending timer is a forced choice that consumes no entropy. The pick distribution is a configurable policy (pickTimerIndex), analogous to failpointFailureProbability.
  • Cooperative cancellation — aborting a sleep's signal removes its timer and rejects with signal.reason (Node timers/promises contract); pre-aborted signals reject immediately with no timer and no entropy. task.createDeadline(ms, reason) returns a cancellable handle; task.withTimedSignal(f, ms, reason) scopes a timeout and guarantees no work started under it is still running when it returns — it never force-interrupts. In simulation the deadline signal is a determined-owned AbortSignal implementation: sleep wakeups are privileged callbacks, user listeners run under a guard (task APIs from a listener fail descriptively; a throwing listener aborts the run).
  • Cancellation ≠ failureCancellationError + isCancellation(e) alongside isApplicationFailure(e): retry loops retry simulated failures but must propagate cancellations.
  • Timer-aware diagnostics — sleeping tasks never trigger false deadlocks; deadlock reports include park reasons and call out aborted-but-uncancelled deadline signals; work completing after its signal aborted is reported (warning, or a failure with failOnLateCompletion); maxSchedulingSteps distinguishes livelock (budget spent at a fixed virtual time) from an outgrown budget; maxVirtualDurationMs bounds the clock.
  • Typed trace with explicit timer recordsRecordingTraceSource/ReplayingTraceSource record/validate the wall-clock epoch, entropy draws, and timer create/cancel/fire events in order. This catches divergence that entropy alone cannot: forced choices consume no entropy, so a changed reason or deadline on a single pending timer would otherwise go unnoticed. The entropy-only sources still work, minus timer validation.

Notes

  • All 20 acceptance tests from TIMERS-SPEC.md §14 are implemented across time.test.ts, trace.test.ts, and scenario.test.ts (a pi-orb-style retry/backoff + shutdown-flush scenario, 20 randomized record+replay iterations). 123 tests total, all green; tsup build passes.
  • Spec §15's open questions resolved pragmatically: time lives on SimulationTask; timestamps are millisecond floats.
  • The one-park-per-task invariant is preserved: a sleep occupies the task's single resolve slot exactly like a blockpoint, and the timer-fire and abort paths disarm each other. Park rollback on synchronous scheduler throws (the recent entropy-guard fixes) extends to sleep timers and abort hooks.
  • Version bumped to 0.4.0.

🤖 Generated with Claude Code

schani and others added 11 commits July 20, 2026 07:22
Renamed from the working draft DETERMINED-REQ.md. Specifies virtual
clocks, deterministic sleep, lower-bound timer semantics, cancellation
via AbortSignal, record/replay of timer events, and acceptance tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cancellation must be distinguishable from failure: retry loops catch and
retry ApplicationFailures but must always propagate cancellations — a
retry loop that swallows an abort defeats the shutdown that requested it.
CancellationError carries the deadline reason and virtual abort time for
diagnostics; isCancellation also recognizes the platform's DOMException
AbortError/TimeoutError so the predicate works in production too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scheduler gains a virtual monotonic clock (plus a configurable
wall-clock epoch) and pending timers. task.sleep(ms, reason) parks the
task in its single resolve slot, blocked, with a timer that flips the
slot back to schedulable when it fires.

Timer deadlines are lower bounds: when no task is runnable, an
entropy-chosen pending timer — any of them — fires, and the clock
advances to max(now, deadline). A later-deadline timer can fire before
an earlier one, exactly as setTimeout lateness permits in production;
firing order is an entropy decision like any other, and a single
pending timer is a forced choice that consumes no entropy.

Deadlock detection is now timer-aware: a pending timer prevents false
deadlock, and the report includes virtual time plus each blocked task's
park reason. Negative sleep durations throw TypeError in both modes
(setTimeout would clamp silently, and only in production); zero
durations yield through the scheduler without a time advance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A sleep may take an AbortSignal. Aborting it removes the pending timer
and rejects the sleep with signal.reason — the same contract as Node's
cancellable setTimeout from timers/promises. The abort listener runs
synchronously in the aborter's stack, but only flips the sleeper's park
slot to schedulable-with-rejection: the aborter continues until it
parks, and the entropy scheduler picks up the aborted sleeper later.

Timer firing and abort disarm each other, so a task can never be woken
twice. A sleep given an already-aborted signal rejects immediately —
the abort event has already fired and will never fire again — without
registering a timer or consuming entropy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
createDeadline returns a handle owning both the timer and the signal;
cancel() removes the pending timer so a completed operation's timeout
can never wake anything later. withTimedSignal wraps an operation with
a deadline and cancels it when the operation settles. It does not
force-interrupt: cancellation is cooperative, and in return, when it
returns, no work started under it is still running.

In simulation the signal is a determined-owned implementation of the
AbortSignal interface. Sleep wakeups on it are privileged callbacks
that run before any user listener; only user listeners run under a
safety guard that makes task-API calls from listener context fail
descriptively, and a throwing listener aborts the simulation instead
of being swallowed by report-and-continue dispatch.

Deadline expiry aborts the signal with a CancellationError carrying
the reason and virtual abort time. Deadlock reports now call out
aborted-but-uncancelled signals — the signature of a deadline that
could not interrupt a non-cancellable wait — and work completing
after its signal aborted is reported (warning by default, a failure
with failOnLateCompletion).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The clock-facing API runs against real time: performance.now/Date.now
clocks, cancellable real-timer sleep with the same negative-duration
validation as simulation, deadlines backed by AbortController that
abort with the same CancellationError shape, and withTimedSignal
cancelling its timer on completion so nothing leaks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
maxSchedulingSteps bounds the number of scheduling decisions (task
unblocks plus timer firings). When it trips, the failure reports
whether virtual time was still advancing: a budget spent at a fixed
virtual time is the signature of a zero-duration-timer or checkpoint
livelock, while exhaustion with time advancing means the scenario
outgrew its budget. maxVirtualDurationMs bounds how far a timer firing
may advance the clock. Aborting a run also clears its pending timers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entropy-only traces cannot detect timer divergence in general: forced
choices consume no entropy by design (the sample() single-element
rule), so a run with a single pending timer — or a single task —
leaves no entropy footprint in which a changed reason or deadline
could be noticed. The trace is now a typed sequence of entropy and
timer records (run-start with the wall-clock epoch, timer creation
with reason and deadline, cancellation, firing with virtual time),
validated in order during replay with descriptive divergence errors,
plus assertFullyConsumed for the unused-events case.

RecordingTraceSource/ReplayingTraceSource implement both EntropySource
and the new TimerTraceSink; SimulationImpl feeds timer events to its
entropy source iff it implements the sink, so plain entropy sources
keep working unchanged. Sleep's timer registration moved out of the
promise executor so a divergence throw propagates synchronously
instead of rejecting an abandoned promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Analogous to failpointFailureProbability: unbiased picking can make
time gallop — choosing a pending 24-hour retention timer over a 100ms
heartbeat jumps the clock a day. That is legal (a suspended process
does the same) but usually a poor default search policy, so the pick
can now be biased, e.g. strongly toward the earliest deadline with
occasional late firings. The policy shapes exploration only; replay
replays the recorded entropy and validates the timer records. A single
pending timer stays a forced choice: no policy call, no entropy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…0.4.0

The end-to-end scenario exercises the whole feature set the way pi-orb
will: a producer feeding a persistence worker that commits with random
simulated latency, failpoint-driven failures, and retry backoff, plus
a shutdown that interrupts idle sleeps via cancellation while the
worker keeps flushing until no records remain. Twenty randomized
iterations each record a full trace and replay it to the identical
event sequence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers the standard-signal contract that the framework doesn't exercise
internally: throwIfAborted, listener removal, one-shot abort semantics
(idempotence, listeners added after abort never fire), registration
order, onabort handlers, option shapes, the dispatchEvent rejection,
and internal-callback ordering/detach — plus the documented
throwIfAborted polling pattern on a deadline signal in simulation.
Takes abort-signal.ts to 100% line/branch coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@schani
schani merged commit cca525b into main Jul 20, 2026
@schani
schani deleted the timers branch July 20, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant