Skip to content

deadlock probe: re-check pendingHostCalls before the verdict - #77

Merged
lann merged 1 commit into
mainfrom
deadlock-probe-hostcall-recheck
Aug 11, 2026
Merged

deadlock probe: re-check pendingHostCalls before the verdict#77
lann merged 1 commit into
mainfrom
deadlock-probe-hostcall-recheck

Conversation

@lann

@lann lann commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

A5's sync-fast-path poll exposes a sampling race in driveAsync's deadlock probe: the probe's precondition (pendingHostCalls.size === 0 && !hasResumingThread()) can expire during the probe's own macrotask turn without the awaiting membership changing — the same activation resumes off an engine continuation chunk (pin (j)), runs sync ready-polls, and re-parks through the A1 arm, registering a fresh pendingHostCalls entry. The re-check only compares awaiting membership and readyCandidates(), so the verdict fires against a live workload with hostCalls=1 at trap time.

Observed on polymorph-iroh's upstream-iroh spike (tokio parked on timer + synthetic-socket pollables, woken by detached host bridges) the moment its host adopted the A5 kernel — DELTIC_DRIVE_TRACE=1:

[drive #3] driveAsync branch=deadlock-probe ready=0 waiting=0{} awaiting=1 hostCalls=0 claim=false
[drive #4] driveAsync branch=deadlock-probe:progressed=false ready=0 waiting=1{SuspensionPoint[not-ready]} awaiting=1 hostCalls=1 claim=false
error: Trap: wasm trap: deadlock detected: event loop cannot make further progress …

The same workload under the pre-A5 always-Promise poll never left the window open (every park held a registered A1 promise at every sample instant), which is why this only surfaced now.

Fix: re-check the full precondition at verdict time; a grown pendingHostCalls (or a resuming thread) ⇒ re-probe, exactly like a membership change. A genuine deadlock still traps — nothing external exists to register a host call, so its re-check finds hostCalls=0 again (deadlock_test.ts pins stay green).

Gates: just test-runtime 363/0 (deadlock pins exercised, not ignored — fixtures built); just conformance 1254 passed / 0 failed / 95 xfail; the exposing workload (relay echo + WebRTC live migration) runs end-to-end against this branch with the A5 kernel.

On a pinned regression test: the race needs "probe macrotask turn interleaved between an A1 settle and the next A1 park of the same activation" — I did not find a deterministic in-suite shape for it (the exposing repro is a real scheduler race under a live tokio guest). polymorph-iroh's spike gates it externally as of its next pin bump; if you want an in-repo pin, a traceDrive-level injection point seems like the least-contrived option — happy to iterate.

The probe's precondition (pendingHostCalls empty, no resuming thread)
can expire during the probe's own macrotask turn WITHOUT the awaiting
set changing: the same activation resumes off an engine continuation
chunk (jspi pin (j) — a sync-completing Suspending import defers its
continuation), runs, and re-parks through the A1 arm, which registers a
fresh pendingHostCalls entry. The membership re-check sees the same
single activation, readyCandidates is empty (the new park is genuinely
waiting on an external event), and the verdict fires against a live
workload — with hostCalls=1 at trap time.

Exposed by wasi-shims' A5 poll: its sync fast path lets the guest run
stretches of ready-polls with no registered park, so the probe can
sample the store between a settled A1 promise (which self-deletes from
pendingHostCalls) and the next genuine park. The always-Promise poll
shape A5 replaced never left that window open, which is why the same
workload (polymorph-iroh's upstream-iroh spike: tokio parked on
timer + synthetic-socket pollables, woken by detached host bridges)
was green before A5 and trapped after:

  [drive #3] driveAsync branch=deadlock-probe ready=0 waiting=0{}
             awaiting=1 hostCalls=0 claim=false
  [drive #4] driveAsync branch=deadlock-probe:progressed=false
             ready=0 waiting=1{SuspensionPoint[not-ready]} awaiting=1
             hostCalls=1 claim=false
  -> trap (old code): 'every suspended activation is waiting on a
     suspension only this scheduler could resume'

Re-checking the full precondition turns the sampled race into a
re-probe, exactly like a membership change. A genuine deadlock still
traps: its re-check finds hostCalls=0 again (nothing external exists to
register one), pinned by tests/jspi/deadlock_test.ts.

Gates: test-runtime 363/0 (deadlock pins green); conformance 1254
passed / 0 failed / 95 xfail; the exposing workload (iroh spike: relay
echo + WebRTC migration) runs end-to-end against this runtime with the
A5 kernel.
@lann
lann merged commit a67ee83 into main Aug 11, 2026
4 checks passed
@lannbot
lannbot deleted the deadlock-probe-hostcall-recheck 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.

1 participant