Skip to content

scheduler: settled tails defer while their instance is not host-enterable - #161

Merged
lannbot merged 1 commit into
mainfrom
fix/settled-tail-deferral
Aug 20, 2026
Merged

scheduler: settled tails defer while their instance is not host-enterable#161
lannbot merged 1 commit into
mainfrom
fix/settled-tail-deferral

Conversation

@lannbot

@lannbot lannbot commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

scheduler: settled tails defer while their instance is not host-enterable

Fixes #156 — the resumeWith half of the window #155 fixed for tick. Design and reachability analysis: the investigation comment on #156. Adjacent finding filed as #160.

The bug

Store.serviceSettled dispatched every settled activation tail through Thread.resumeWith, which re-establishes the reentrance bracket and asserted inst.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:

AssertionError: resumeWith: parked thread's instance is not enterable from the host

Worse than the wrong error channel: resumeWith mutates before asserting (deletes from store.awaiting, flips state), so the assert also stranded the thread and lost the settle. Confirmed reachable on main (jspi mode only), two ways:

  • the held async-dtor bracketcallDtorGated'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 one enterFrom(null) that spans an await, a macro-time window during which any sibling settle serviced by the settlement pump or an export call's driveAsync fired the assert;
  • nested synchronous driving — a dispatched tail's guest segment runs synchronously to its first Suspending call, and a host import's body reaching an embedder stream op re-enters serviceSettled through HostActivity.pump while the first tail's bracket is open.

driveAsync's race-winner path calls resumeWith directly 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:

the deferral predicate !inst.mayEnterFrom(null) is exactly tick's candidate-filter predicate (#155) on the same instance, so tick can never resume a thread of an instance whose tail is deferred — the phantom-state gate the queue exists for is preserved per-instance by construction.

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 becomes hasServiceableSettled() — a deferred-only queue must not wedge the store (its instances are self-excluded from candidates by the same predicate).
  • driveAsync: the two settled.length reads 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 memoized tagAwait tag 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 on pendingHostCalls, 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.
  • The unreachable wedge states (all-deferred with no outstanding host call) stay loud — both the servicing race and the deadlock-probe path fall through to a named deadlock trap instead of busy-idling.
  • 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 serviceSettled iteration or the next driver turn runs) or the async-dtor bracket, whose release edge is a pendingHostCalls settle every driver already races — and callDtorGated registers its leaveTo continuation 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 refuses tick for 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 a pendingHostCalls settle from a setTimeout(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-seeds clean (the relaxed tick gate produced zero schedule deltas). No published-surface change, so the lockstep version is left alone per AGENTS.md.

…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.
@lannbot
lannbot enabled auto-merge August 20, 2026 17:51
@lannbot
lannbot merged commit 2e1e3a3 into main Aug 20, 2026
4 checks passed
@lannbot
lannbot deleted the fix/settled-tail-deferral branch August 23, 2026 16:51
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.

scheduler: resumeWith has the same not-host-enterable window as tick, and the tick fix cannot transfer

2 participants