Make cross-task promise sharing work instead of hanging - #6
Merged
Conversation
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>
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.
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
runTasksnever 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:Probe errors (deadlock, exceeded budgets, trace divergence) have no task stack to throw into, so they fail
runTasksthrough an out-of-bandPromise.racechannel. 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:
FixedEntropySourcethrows on any extra draw) passing unchanged;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.allacross 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,maxVirtualDurationMsviolations 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
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