Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
task.monotonicNow()/task.wallNow()(configurable wall-clock epoch). Virtual in simulation,performance.now()/Date.now()innoSimulation.task.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 tomax(now, deadline), so a 2,000ms timer can fire before a 1,000ms one, exactly assetTimeoutlateness 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 tofailpointFailureProbability.signal.reason(Nodetimers/promisescontract); 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-ownedAbortSignalimplementation: sleep wakeups are privileged callbacks, user listeners run under a guard (task APIs from a listener fail descriptively; a throwing listener aborts the run).CancellationError+isCancellation(e)alongsideisApplicationFailure(e): retry loops retry simulated failures but must propagate cancellations.failOnLateCompletion);maxSchedulingStepsdistinguishes livelock (budget spent at a fixed virtual time) from an outgrown budget;maxVirtualDurationMsbounds the clock.RecordingTraceSource/ReplayingTraceSourcerecord/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
time.test.ts,trace.test.ts, andscenario.test.ts(a pi-orb-style retry/backoff + shutdown-flush scenario, 20 randomized record+replay iterations). 123 tests total, all green;tsupbuild passes.SimulationTask; timestamps are millisecond floats.🤖 Generated with Claude Code