scheduler: settled tails defer while their instance is not host-enterable - #161
Merged
Conversation
…able The resumeWith half of the #155 window (issue #156). `Thread.resumeWith` re-establishes the reentrance bracket for a settled activation tail and ASSERTED host-enterability first; under the shared synthetic root (plan v3 amendment 4) a host entry into any instance locks every sibling, so servicing a sibling's tail in that window crashed — and, because resumeWith mutates before asserting, stranded the thread and lost the settle. Reachable (jspi only) through the held async-dtor bracket (#85's pinned hold-until-settle, the one enterFrom(null) that spans an await) and through nested synchronous driving (a dispatched tail's guest segment reaching HostActivity.pump through a host import). The #155 fix cannot transfer (a settle is not a scheduling choice), so: tails whose instance is neither poisoned nor host-enterable are now DEFERRED IN PLACE — left in the queue, skipped by dispatch — until the lock releases. Safe because the deferral predicate is exactly tick's candidate-filter predicate on the same instance, so the phantom-state gate is preserved per-instance by construction; per-instance settle order is unchanged, and the cross-instance relaxation is conforming schedule nondeterminism. Poisoned tails still dispatch (resumeWith's early return retires them; a poisoned leaf never unlocks). tick's settled gate becomes hasServiceableSettled(), and driveAsync gets the matching plumbing: no hot spin on a deferred-only queue, both awaiting races exclude threads whose tails the queue owns (a settled memoized tag re-wins instantly, a livelock), the all-deferred case parks on pendingHostCalls (the lock holder's own entry), and the resulting unreachable wedge states stay loud instead of busy-idling. Design and reachability analysis: issue #156. Adjacent finding filed as #160 (the held dtor bracket's scheduler-dependent completion). Gates: just gates (full pass; conformance 1257/0 with no expectation changes, sched-seeds clean — the relaxed tick gate produced zero deltas). No published-surface change; lockstep version untouched. Fixes #156.
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.
scheduler: settled tails defer while their instance is not host-enterable
Fixes #156 — the
resumeWithhalf of the window #155 fixed fortick. Design and reachability analysis: the investigation comment on #156. Adjacent finding filed as #160.The bug
Store.serviceSettleddispatched every settled activation tail throughThread.resumeWith, which re-establishes the reentrance bracket and assertedinst.mayEnterFrom(null)first. Under the shared synthetic root, a host entry into any instance of the graph locks every sibling, so servicing a sibling's tail in that window crashed:Worse than the wrong error channel:
resumeWithmutates before asserting (deletes fromstore.awaiting, flips state), so the assert also stranded the thread and lost the settle. Confirmed reachable onmain(jspi mode only), two ways:callDtorGated's hold-until-settle (resources: own-drop dtor bypasses may_enter gating and trap-poisoning; JS-initiated drops skip the promising entry #85, pinned) is the runtime's oneenterFrom(null)that spans an await, a macro-time window during which any sibling settle serviced by the settlement pump or an export call'sdriveAsyncfired the assert;serviceSettledthroughHostActivity.pumpwhile the first tail's bracket is open.driveAsync's race-winner path callsresumeWithdirectly and had the same exposure.The fix
A tail whose instance is neither poisoned nor host-enterable is deferred in place — left in the queue, skipped by dispatch — until the lock releases. The property that makes this safe:
Per-instance settle order is unchanged; cross-instance order relaxes only when enterability defers a tail, which is conforming schedule nondeterminism (in the reference the tail runs atomically inside the entered bracket, so a host entry admitted during a park necessarily orders before that activation's tail). Poisoned tails still dispatch:
resumeWith's poison early-return retires them, and a poisoned leaf never unlocks, so deferring them would leak.Mechanics:
Store.serviceSettled: scan-from-head loop — stale entries (thread resumed elsewhere) removed on sight; first dispatchable tail spliced out and dispatched, then rescan (dispatch runs guest code synchronously and can re-enter this function); deferred entries stay in place. Shared predicate:dispatchableTail.Store.tick: the settled gate becomeshasServiceableSettled()— a deferred-only queue must not wedge the store (its instances are self-excluded from candidates by the same predicate).driveAsync: the twosettled.lengthreads become serviceable-checks (a deferred-only queue otherwise hot-spins the loop — no await in the cycle); both awaiting races exclude threads whose tails the queue owns (their memoizedtagAwaittag is already settled — racing them re-wins instantly, an unbounded microtask chain that starves the very host-call settle that would release the lock); the all-deferred case parks onpendingHostCalls, which provably contains the lock-holder's own entry (the dtor bracket registers there; synchronous brackets cannot span the loop's await); the race-winner site gets the dispatch guard.Thread.resumeWith's assert stays, documented as the internal backstop.Liveness
Every deferred tail's lock-holder is either a synchronous extent (re-checked when the enclosing
serviceSettlediteration or the next driver turn runs) or the async-dtor bracket, whose release edge is apendingHostCallssettle every driver already races — andcallDtorGatedregisters itsleaveTocontinuation before inserting the promise, so per-promise FIFO delivers the unlock before any driver's race continuation observes the settle.Tests
task_test.ts(synthetic-root section): defer-then-service across a held host entry; the phantom-state gate still refusestickfor a serviceable tail; poisoned tails retire while locked; stale entries are removed regardless of enterability.settled_deferral_test.ts(driver-level): B's tail queued, A's host entry held, unlock arriving via apendingHostCallssettle from asetTimeout(0)— mirrors the dtor bracket's shape; pins park-not-spin, the race-set exclusion, and the all-deferred fallthrough. New shapes verified to fail (assert or stall) on the unfixed runtime.Gates
just gates— full pass. Conformance: 1416 commands, 1257 passed, 0 failed, no expectation changes;sched-seedsclean (the relaxed tick gate produced zero schedule deltas). No published-surface change, so the lockstep version is left alone per AGENTS.md.