Skip to content

Make cross-task promise sharing work instead of hanging - #6

Merged
schani merged 1 commit into
mainfrom
cross-task-promises
Jul 30, 2026
Merged

Make cross-task promise sharing work instead of hanging#6
schani merged 1 commit into
mainfrom
cross-task-promises

Conversation

@schani

@schani schani commented Jul 30, 2026

Copy link
Copy Markdown
Member

Fixes the silent hang when a simulation task awaits a promise settled by another task (the singleflight/coalescing shape): the pending timer that would settle the promise never fired, and runTasks never resolved.

The bug

The scheduler's task states are facts except one: "parked" and "blocked" are exact because the scheduler holds the wake mechanism, but "running" is a prediction — "this task will re-enter the scheduler at its next park or finish." Awaiting a promise the scheduler doesn't manage breaks exactly that prediction: the task suspends without re-entering, the microtask queue drains, and nobody ever calls the scheduler again.

The fix: quiescence probes

Whenever the scheduler hands control to user code, it arms a one-shot probe (setImmediate). A macrotask runs only after the entire microtask queue has drained, so a task still marked running when the probe fires is provably suspended on an unmanaged promise. The probe treats such tasks as blocked:

  • parked tasks keep running (at the current virtual time);
  • pending timers fire one at a time, yielding after each fire so settling cascades (e.g. a deadline abort listener resolving a deferred) drain before the next scheduling decision;
  • if nothing can progress — no candidates, no timers — the run fails loudly with a deadlock report naming the stuck tasks ("awaiting a promise not managed by the simulation") instead of hanging silently.

Probe errors (deadlock, exceeded budgets, trace divergence) have no task stack to throw into, so they fail runTasks through an out-of-band Promise.race channel. Stale probes across runs are neutralized by a run token.

Compatibility

Previously-working workloads never reach the probe's scheduling path: the synchronous path is unchanged and no extra entropy is drawn. Verified by:

  • the full suite (FixedEntropySource throws on any extra draw) passing unchanged;
  • 20 golden traces (969 records: sleeps, deadlines with cancellation, CV/mutex, failpoints) recorded on the pre-fix scheduler replaying identically and fully consumed on the fixed one.

Two documented consequences: tasks resumed by a promise settling run in native promise-reaction order (deterministic and replayable, but not part of the entropy-explored schedule space), and the quiescence inference assumes a closed world — real async I/O inside tasks remains outside the library's contract.

Tests

Twelve new tests in time.test.ts (cross-task promise sharing), each verified to fail against the code it guards, all hang-guarded by a real-time race: the direct repro from the bug report, singleflight with resume-order determinism, a checkpoint-parked worker progressing at t=0 while a task is foreign-suspended, a deferred settled with no timer involved, Promise.all across tasks, a timer whose only effect settles a foreign promise, a later timer not firing before a settling cascade drains, runtime-settled promises never false-deadlocking, genuine deadlock failing loudly + instance poisoning + late zombie settles staying harmless, maxVirtualDurationMs violations from the probe path, inert post-completion probes, stale probes across back-to-back runs, and 10× record/replay determinism of the cross-task shape.

Docs

  • README: new "Cross-task promises" section and updated design notes.
  • NEW-ARCH.md: writes up a simpler all-quiescence scheduler (quiescence as the only scheduling point), its pros/cons, and why it's not adopted now (it breaks the sync error-injection contract that lets tasks recover from transient entropy-guard trips, and adds a macrotask hop per scheduling step).

Version bumped to 0.4.1 for a patch release.

🤖 Generated with Claude Code

A task awaiting a promise settled by another task — the singleflight/
coalescing shape — silently hung the simulation: the scheduler's
"running" state is a prediction ("this task will re-enter at its next
park"), and awaiting an unmanaged promise breaks exactly that
prediction, so no timer ever fired again and runTasks never settled.

The scheduler now arms a one-shot quiescence probe (a macrotask)
whenever it hands control to user code. Since macrotasks run only after
the entire microtask queue has drained, a task still marked running
when the probe fires is provably suspended on an unmanaged promise. The
probe treats such tasks as blocked: parked tasks keep running, pending
timers fire one at a time — yielding after each fire so settling
cascades (e.g. a deadline abort listener resolving a deferred) drain
before the next decision — and if nothing can progress the run fails
loudly with a deadlock report naming the stuck tasks instead of hanging.
Probe errors reject runTasks through an out-of-band channel, since
there is no task stack to throw into.

Previously-working workloads never reach the probe's scheduling path:
the synchronous path is unchanged, no extra entropy is drawn, and
golden traces recorded on the old scheduler replay unchanged.

NEW-ARCH.md writes up a simpler all-quiescence architecture and the
trade-offs that kept it out for now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@schani
schani merged commit 3f0cfd9 into main Jul 30, 2026
2 checks passed
@schani
schani deleted the cross-task-promises branch July 30, 2026 15:17
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