Skip to content

runtime: bound the driver's speculative resume gate to the sole driver (#239) - #240

Merged
lann merged 1 commit into
mainfrom
fix-239-driver-gate
Aug 23, 2026
Merged

runtime: bound the driver's speculative resume gate to the sole driver (#239)#240
lann merged 1 commit into
mainfrom
fix-239-driver-gate

Conversation

@lannbot

@lannbot lannbot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Fixes #239.

What was actually wrong

driveAsync's awaiting-race took a speculative Store.pendingResumptions
entry and held it across Promise.race([chosenTag, ...others]), where
others includes store.pendingHostCalls. That await is bounded only by
when the host answers.

Store.pendingResumptions is a store-wide scheduling gate: Store.tick
refuses while it is non-empty, and every driveAsync yields at its top while
store.hasPendingResumptions() holds, under a 10,000-hop bound. So a
second driver on the same store spun there and died in ~311ms with

driveAsync: a resumed-activation claim was never released
(the activation neither parked, finished, nor trapped)

— an internal-bug detector firing on a perfectly ordinary suspended guest.

This is the same-store half of #210, which fixed only the cross-store half by
making the gate per-store. tests/cross_store_driver_test.ts's header already
described the entry as "held for the entire duration of a guest's wait on a
slow host import"
; nobody asked what happens when the second driver is on the
store actually doing the waiting.

The reported shape is not the minimal one

#239 reports a detached spawn_local guest task dropping an in-flight
async-import future — wit-bindgen's specified cancellation path, which issues
subtask.cancel, parks the frame mid-activation, and leaves the settlement
pump holding the entry with no export call outstanding. That reproduces, and
so does a strictly smaller shape with no cancellation and no detached task
at all
:

const slow = e["block-for"](1000n);   // an export that parks on a slow host import
await e.ping();                        // a second, ordinary export call
// -> AssertionError, ~311ms in

The issue's own variation matrix corroborates the diagnosis: V4 and V4c did
not wedge precisely because an export call's driver was live, so the fallback
pump never became a second driver.

The fix

Two parts, both in runtime/src/exec/boundary.ts:

  • The speculative entry is taken only by the sole driver
    (storeDriverDepth(store) === 1). It gates other Store.tick callers and
    never the awaiting loop itself, so it can only ever constrain concurrent
    drivers and HostActivity.pump's synchronous drain. The resumption
    invariant does not rest on it — that is the driverDepth note's mechanisms
    (a) resumeWith's synchronous store.awaiting deletion and (b) tagAwait's
    per-promise memoization, plus the resumption site's own membership /
    promise-identity / dispatchableTail re-checks.

  • A per-store driver-arrival one-shot rides every park in the loop, fired
    by any driveAsync that finds itself at depth > 1. The incumbent wakes
    within a microtask, drops the entry on its way out of the race, and
    re-evaluates done() — which is exactly the stand-down the fallback pumps
    were always supposed to perform, now prompt instead of "whenever the host
    happens to answer". The driverDepth block's "bounded overlap window" is
    bounded for the first time.

The 10,000-hop assert is retained: with the entry's lifetime bounded by
another driver's arrival, it is once again the internal-bug detector it was
meant to be.

Tests

runtime/tests/same_store_driver_test.ts — the #210 probe's same-store
sibling. Four properties: the arriving driver returns promptly; the gate still
gates a sole driver (the CONTROL, mirroring the cross-store test's tail); the
entry is dropped while a second driver is live; and the release removes only
the entry it added — the #158 property, which a blanket-clear "fix" would
violate while passing the other three.

examples/guests/cancel-import — a new wit-bindgen guest fixture carrying
every route into the wedge (poll-once-then-drop, timeout race, detached
mid-frame park, two concurrent export calls, plus a ping health poll),
driven end-to-end by runtime/tests/integration/e2e_cancel_import_test.ts.

Every new test was verified to fail against the reverted fix with the exact
assertion above, and the #158 test against a pendingResumptions.clear()
mutation.

Not fixed here

A host-import subtask's on_cancel is a no-op (subtask.onCancel = () => {}),
so a guest's subtask.cancel on an in-flight host import still blocks until
that import completes naturally — measured with this fixture: cancel issued at
t=312ms, returned at t=4010ms for a 4000ms import. That is why polymorph-iroh's
dial timeout would still be unusable even with the wedge gone, and it needs an
embedder-API decision rather than a scheduler fix. Filed separately as #241.
This diff does not change it in either direction.

Gates

just gates — exit 0. Notable lanes: runtime 651/0; conformance 1257/0 with no
expectation changes (1254/0 under seed 1); sched-seeds 648/0 under both seeds;
sm/node/bun pinned shells all "OK, matches expectation"; chromium + firefox
including worker and shared-worker realms; smoke-tls PASS; smoke-c0 legs 1-4
PASS.

No published-surface change, so no breaking/* label and no version bump; the
conventions goldens are byte-identical.

#239)

`driveAsync`'s awaiting-race took a speculative `Store.pendingResumptions`
entry and held it across `Promise.race([chosenTag, ...others])`, where
`others` includes `store.pendingHostCalls` — an await bounded only by when
the HOST answers. That set is a store-wide scheduling gate: `Store.tick`
refuses while it is non-empty and every `driveAsync` yields at its top under
a 10,000-hop bound. So a SECOND driver on the same store spun there and died
in ~311ms on

  driveAsync: a resumed-activation claim was never released
  (the activation neither parked, finished, nor trapped)

an internal-bug detector firing on a perfectly ordinary suspended guest.
This is the same-store half of #210, which fixed only the cross-store half by
making the gate per-store; `tests/cross_store_driver_test.ts`'s header already
described the entry as "held for the entire duration of a guest's wait on a
slow host import".

The reported route was a detached `spawn_local` guest task dropping an
in-flight async-import future — wit-bindgen's specified cancellation path,
which issues `subtask.cancel` and parks the frame mid-activation with no
export call outstanding, leaving the settlement pump holding the entry. It
reduces much further: two concurrent export calls and one slow suspending
import are enough, with no cancellation and no detached task anywhere.

Fix, in two parts:

  * the speculative entry is taken only by the SOLE driver
    (`storeDriverDepth(store) === 1`). It gates other `Store.tick` callers,
    never the awaiting loop itself, so it can only ever constrain concurrent
    drivers and `HostActivity.pump`'s drain; the resumption invariant does not
    rest on it (see the `driverDepth` note's mechanisms (a) and (b)).

  * a per-store driver-arrival one-shot rides every park in the loop, fired by
    any `driveAsync` that finds itself at depth > 1. The incumbent wakes within
    a microtask, drops the entry on its way out, and re-evaluates `done()` —
    which is the stand-down the fallback pumps were always supposed to perform,
    now prompt rather than "whenever the host happens to answer". The
    `driverDepth` block's "bounded overlap window" is bounded for the first
    time.

Tests. `same_store_driver_test.ts` is the #210 probe's same-store sibling:
the arriving driver returns promptly, the gate still gates a sole driver, the
entry is dropped while a second driver is live, and — the #158 property — the
release removes only the entry it added. `examples/guests/cancel-import` is a
new wit-bindgen fixture carrying all three routes into the wedge, driven
end-to-end by `e2e_cancel_import_test.ts`. Every test was verified to fail
against the reverted fix.

Not fixed here, and unchanged by this diff: a host-import subtask's
`on_cancel` is a no-op, so a guest's `subtask.cancel` on an in-flight host
import still blocks until that import completes naturally (measured: cancel
issued at t=312ms, returned at t=4010ms for a 4000ms import). Filed
separately.
@lann
lann merged commit 9739a65 into main Aug 23, 2026
4 checks passed
@lannbot
lannbot deleted the fix-239-driver-gate branch August 23, 2026 16:50
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.

driveAsync wedges permanently when a guest cancels an in-flight import while the host is idle

2 participants