From ae33a100c2e674d33b848a68d3c4b995141ba7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Sat, 1 Aug 2026 15:53:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(asyncify):=20consume-once=20root=20suspensi?= =?UTF-8?q?ons=20=E2=80=94=20replaces=20the=20wake-window=20deferral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferral (9ca2ac1) modeled the wrong condition and taxed every parked fiber completion with a macrotask hop: under CI load that stretched three-client apply chains and flaked drift-trio S4 twice consecutively (26/26 green locally under stress) — retracted. The actual fatal state, readable in all four prod stacks once seen: a SECOND rewind of the same root suspension. Root suspends once per fiber_swap out of it; two parked fibers completing against one root suspension epoch (a tool fiber + a collab fiber both waking around open:settled) each drive finishContextSwitch(root) — the second rewinds already-consumed data → "unreachable executed" → poisoned runtime, with the wake-side "index out of bounds" as the sibling symptom. Cure: stop exempting root from the validity check the shim already keeps. First consumption proceeds synchronously — zero added latency anywhere; the second is refused ([wx-asyncify] "root suspension already consumed") — the yielded fiber stays properly suspended and resumable, root continues via its real pending resume, libcontext's ghost-epoch contract enforced one layer lower. Root remains exempt only from the internally-parked quarantine (its yield park is routine). .ci-cache-epoch 4→5 (the epoch-4 cache holds the retracted deferral shim). Local: fiber 2/2 + timer 1/1, firefox sweep 20 passed, drift-trio-scenarios kicad-chromium 26/26 under 3-worker stress, web fatal+follow 2/2. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4 --- .ci-cache-epoch | 2 +- docs/features/async/16-fiber-resume-guard.md | 26 +++++++--- scripts/common/shims/handlesleep.js | 54 +++++++------------- 3 files changed, 40 insertions(+), 42 deletions(-) diff --git a/.ci-cache-epoch b/.ci-cache-epoch index b8626c4..7ed6ff8 100644 --- a/.ci-cache-epoch +++ b/.ci-cache-epoch @@ -1 +1 @@ -4 +5 diff --git a/docs/features/async/16-fiber-resume-guard.md b/docs/features/async/16-fiber-resume-guard.md index 3eaa356..eaffb7b 100644 --- a/docs/features/async/16-fiber-resume-guard.md +++ b/docs/features/async/16-fiber-resume-guard.md @@ -127,12 +127,26 @@ the fiber's `finishContextSwitch` rewind of `main+20` — replaying frames over live state. The 8 ms-earlier "index out of bounds" is the wake side of the same collision. -Root entry is legal and constant in healthy flow; ONLY the wake-window -overlap is fatal. **Layer 3: serialize, don't refuse.** The shim marks the -synchronous wake window (`Asyncify.__inSleepWake` around `wakeUp()`); -`finishContextSwitch(root)` inside that window is DEFERRED one macrotask -(`[wx-asyncify] root-entry-deferred` beacon) and re-fired via the trampoline -once the wake has settled. Ordering change only — nothing is dropped. +**Layer 3, first attempt (wake-window deferral) — RETRACTED same day:** +deferring every root entry inside a sleep-wake window taxed EVERY parked +fiber completion with a macrotask hop; under CI load that stretched +three-client apply chains and flaked drift-trio S4 twice in a row (green +26/26 locally). It also modeled the wrong condition. + +**Layer 3, final: consume-once root suspensions.** The actual fatal state is +a SECOND rewind of the same root suspension: root suspends once per +`fiber_swap` out of it, but two parked fibers completing against one root +suspension epoch (a tool fiber + a collab fiber both waking around +`open:settled`) each trigger `finishContextSwitch(root)` — the second rewinds +already-consumed data → "unreachable executed". The shim already records +every suspension (including root's); the fix is simply to stop exempting +root from the validity check: first consumption proceeds synchronously (zero +added latency anywhere), the second is refused +(`[wx-asyncify] fiber-resume-refused: root suspension already consumed`) — +the yielded fiber stays properly suspended and resumable, root continues via +its real pending resume, same contract as libcontext's ghost epochs enforced +one layer lower. Root stays exempt ONLY from the internally-parked +quarantine (its yield park is routine — the 19-red lesson). ## The white screen, round 3 (fixed at the DOM level) diff --git a/scripts/common/shims/handlesleep.js b/scripts/common/shims/handlesleep.js index b87fc46..563b4f1 100644 --- a/scripts/common/shims/handlesleep.js +++ b/scripts/common/shims/handlesleep.js @@ -297,48 +297,32 @@ if (typeof Fibers !== "undefined" } var isRoot = newFiber === Fibers.__rootFiber; - - // The prod killer (four identical trap stacks, v0.1.19–22): a fiber - // completes and re-enters the ROOT while a sleep wake's own rewind is - // still on the stack — two "resume main" paths interleaved in one tick, - // doRewind(root) replays over live state, "unreachable executed", - // poisoned runtime. Root entry is legal and constant in healthy flow; - // ONLY the wake-window overlap is fatal. Defer it by one macrotask so - // the wake settles first — ordering change only, nothing is dropped. - if (isRoot && (Asyncify.__inSleepWake || 0) > 0) { - var deferred = newFiber; - Fibers.__rootDeferrals = (Fibers.__rootDeferrals || 0) + 1; - if (Fibers.__rootDeferrals <= 10 || Fibers.__rootDeferrals % 100 === 0) { - console.warn("[wx-asyncify] root-entry-deferred: fiber completion landed inside a " - + "sleep-wake window; retrying next tick (occurrence " - + Fibers.__rootDeferrals + ")"); - } - __fcsRec("defer root new=" + deferred); - var retry = function() { - if (Fibers.trampolineRunning || Fibers.nextFiber) { - setTimeout(retry, 0); // another switch in flight — wait our turn - return; - } - __fcsRec("defer-retry new=" + deferred); - Fibers.nextFiber = deferred; - Fibers.trampoline(); - }; - setTimeout(retry, 0); - return; - } - var HEAPU32v = (typeof GROWABLE_HEAP_U32 === "function") ? GROWABLE_HEAP_U32() : HEAPU32; var entryPoint = HEAPU32v[((newFiber + 12) >>> 2) >>> 0]; if (!isRoot && Fibers.__internallyParked.has(newFiber)) { + // Root is exempt from THIS check only: it "parks" in the main loop's + // yield as a matter of course (quarantining it starved every coroutine + // return — 19 collab e2e reds on the first guard build). __refuseFiber(newFiber, "is asyncify-parked mid-body (sleep in flight)"); return; } - if (!isRoot && entryPoint === 0) { - // Suspended-fiber path: about to rewind newFiber+20. (The root is - // exempt: re-entering it with an older suspension is the long-standing - // ghost-resume flow, resolved by libcontext's epoch machinery.) + if (entryPoint === 0) { + // Suspended-fiber path: about to rewind newFiber+20 — root INCLUDED. + // Consume-once semantics are the actual prod killer's cure (all four + // trap stacks, v0.1.19–22): each fiber_swap suspension is rewindable + // exactly once. Two fibers completing against ONE root suspension + // epoch (a tool fiber and a collab fiber both waking around + // open:settled) makes the second finishContextSwitch(root) rewind + // already-consumed data → "unreachable executed" → poisoned runtime. + // Refusing the second consumption loses nothing: the fiber that + // yielded stays properly suspended (recorded above), and the root + // continues via its real pending resume (its own sleep wake or the + // next fresh JS entry) — the same contract as libcontext's + // ghost-resume epochs, enforced one layer lower. if (!Fibers.__validSuspensions.has(newFiber)) { - __refuseFiber(newFiber, "has no live suspension - rewinding would replay stale data"); + __refuseFiber(newFiber, isRoot + ? "root suspension already consumed - a second rewind would replay stale frames" + : "has no live suspension - rewinding would replay stale data"); return; } Fibers.__validSuspensions.delete(newFiber);