From a8adfa9843a5f4647ed89b9cb9a860cbcae04a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Sat, 1 Aug 2026 10:05:27 +0200 Subject: [PATCH] =?UTF-8?q?fix(asyncify):=20stale-fiber-rewind=20guard=20?= =?UTF-8?q?=E2=80=94=20layer=202,=20attribution-proof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.1.21 still trapped with ZERO jump-refused beacons: the fatal swap PASSED the C++ swap_suspended guard. Mechanism (async/16 round 2): a fresh JS entry executing while g_current_context still points at a parked fiber gets attributed to that fiber — fiber_swap writes a fresh, valid-LOOKING foreign suspension into the parked fiber's struct and re-marks the flag. The flag lies; the resume rewinds garbage. This guard tracks truth at the emscripten-fiber layer (handlesleep.js wraps Fibers.finishContextSwitch): - valid suspensions = real swap-outs (currData == oldFiber+20 when the trampoline runs), consumed on rewind; - internally-parked = an entered slice that ended in a handleSleep park (currData set, no nextFiber) — quarantined until a GENUINE swap-out, where genuine means the fiber's pending sleep has resolved (__pendingSleepContexts), so a laundering write cannot lift it; - entering a quarantined or suspension-less fiber is REFUSED ([wx-asyncify] fiber-resume-refused, ghost contract). The ROOT context is exempt from quarantine and refusal: its rewound continuation runs the whole main loop, whose routine yield park says nothing about a fiber body — the first build of this guard quarantined main off that signal and starved every coroutine return (19 collab e2e reds, empty results). Root = the old side of the first switch ever. .ci-cache-epoch 2→3: the wasm output cache key omits scripts/**. Red/green: fiber-resume-park.spec.ts scenario 2 (laundered resume → exactly one refusal beacon, both coroutines complete); full fiber-heavy sweep green (21 passed). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4 --- .ci-cache-epoch | 2 +- scripts/common/shims/handlesleep.js | 120 ++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/.ci-cache-epoch b/.ci-cache-epoch index 0cfbf08..00750ed 100644 --- a/.ci-cache-epoch +++ b/.ci-cache-epoch @@ -1 +1 @@ -2 +3 diff --git a/scripts/common/shims/handlesleep.js b/scripts/common/shims/handlesleep.js index 64f18d6..d2da53f 100644 --- a/scripts/common/shims/handlesleep.js +++ b/scripts/common/shims/handlesleep.js @@ -137,3 +137,123 @@ if (typeof Asyncify !== "undefined") { } } // === End nested-Asyncify handleSleep fix === + +// === Stale-fiber-rewind guard (the decoded 2026-07/08 prod board-load trap) === +// +// A fiber whose body asyncify-parks inside handleSleep is suspended in a way +// the fiber machinery cannot see: its struct still holds the CONSUMED data of +// its last real swap-out. The C++ libcontext guard (swap_suspended) closes the +// simple case, but caller attribution can be poisoned — a fresh JS entry that +// jumps while g_current_context still points at a parked fiber writes a fresh +// suspension INTO that parked fiber's struct, so the flag lies. This guard is +// attribution-proof: it tracks validity at the emscripten-fiber layer itself. +// +// A fiber becomes safely resumable ONLY when a real swap-out writes its +// suspension — observable here because fiber_swap sets Asyncify.currData to +// oldFiber's asyncify data (fiber+20) and finishContextSwitch runs before +// anything else touches it. Consuming a suspension (the rewind path) removes +// it. A suspended-path entry for a fiber with NO live suspension is exactly +// the stale rewind that produced "unreachable executed" + a poisoned runtime +// (docs/features/async/16) — REFUSE it: the dropped dispatch ghost-resolves +// (the jump-ghost contract), the parked body completes via its own wake. +if (typeof Fibers !== "undefined" + && typeof Fibers.finishContextSwitch === "function" + && !Fibers.__staleRewindGuardInstalled) { + // Fibers whose last swap-out wrote a live (unconsumed) suspension. + Fibers.__validSuspensions = new Set(); + // Fibers whose last slice ended in a handleSleep park instead of a swap-out: + // their body is mid-sleep, so entering them is unsafe no matter what their + // struct holds (a misattributed jump may have written a valid-LOOKING + // foreign suspension into it). + Fibers.__internallyParked = new Set(); + // fiber → the sleep buffer its internal park is waiting on. A LATER + // "swap-out" of that fiber is genuine only if this sleep has resolved + // (its context left __pendingSleepContexts) — a misattributed jump from a + // fresh JS entry writes the fiber's struct while the sleep is still + // pending, and must not launder the fiber back into the valid set. + Fibers.__parkSleepBuf = new Map(); + + var __origFinishContextSwitch = Fibers.finishContextSwitch.bind(Fibers); + var __fiberRefusals = 0; + + var __refuseFiber = function(newFiber, why) { + ++__fiberRefusals; + if (__fiberRefusals <= 10 || __fiberRefusals % 100 === 0) { + console.warn("[wx-asyncify] fiber-resume-refused: fiber=" + newFiber + " " + why + + " (occurrence " + __fiberRefusals + ")"); + } + // No context is entered. The unwind that got us here already completed + // (state Normal); clear the dangling currData so the next fresh park + // does not read a foreign pointer. + Asyncify.currData = null; + }; + + Fibers.finishContextSwitch = function(newFiber) { + // The swap that scheduled this switch just suspended its old fiber and + // left currData = oldFiber+20 (fiber_swap's unwind path); record that + // suspension as live — and a GENUINE swap-out also ends any internal + // park. Genuine means the fiber's pending sleep (if any) has resolved; + // otherwise this is a misattributed fresh-entry jump writing into a + // parked fiber's struct, and the fiber must stay quarantined. + // finishContextSwitch only runs for genuine fiber switches, so currData + // here is never a handleSleep buffer. + if (Asyncify.currData) { + var oldFiber = Asyncify.currData - 20; + // The very first switch is always main → coroutine: remember the ROOT + // context. The root is exempt from quarantine below — after a rewind + // into it, execution continues into the whole main loop (which parks in + // its yield as a matter of course); reading that park as "the entered + // fiber is mid-body" quarantined MAIN and starved every coroutine + // return (empty collab results across the board on the first build of + // this guard). + if (Fibers.__rootFiber === undefined) { + Fibers.__rootFiber = oldFiber; + } + var parkBuf = Fibers.__parkSleepBuf.get(oldFiber); + var stillParked = parkBuf !== undefined + && Array.isArray(Asyncify.__pendingSleepContexts) + && Asyncify.__pendingSleepContexts.some(function(c) { return c.capturedData === parkBuf; }); + if (!stillParked) { + Fibers.__validSuspensions.add(oldFiber); + Fibers.__internallyParked.delete(oldFiber); + Fibers.__parkSleepBuf.delete(oldFiber); + } + } + + var isRoot = newFiber === Fibers.__rootFiber; + var HEAPU32v = (typeof GROWABLE_HEAP_U32 === "function") ? GROWABLE_HEAP_U32() : HEAPU32; + var entryPoint = HEAPU32v[((newFiber + 12) >>> 2) >>> 0]; + if (!isRoot && Fibers.__internallyParked.has(newFiber)) { + __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 (!Fibers.__validSuspensions.has(newFiber)) { + __refuseFiber(newFiber, "has no live suspension - rewinding would replay stale data"); + return; + } + Fibers.__validSuspensions.delete(newFiber); + } + + var ret = __origFinishContextSwitch(newFiber); + + // How did the entered fiber's synchronous slice end? Another fiber swap + // (nextFiber set — the trampoline loop continues, proper suspension) or a + // handleSleep park (currData holds a sleep buffer — the body is mid-sleep + // and must not be entered until it properly swaps out). Never applied to + // the root: its rewound continuation runs the whole main loop, whose + // routine yield park says nothing about a fiber body. + if (!isRoot && !Fibers.nextFiber && Asyncify.currData) { + Fibers.__internallyParked.add(newFiber); + Fibers.__parkSleepBuf.set(newFiber, Asyncify.currData); + } + + return ret; + }; + + Fibers.__staleRewindGuardInstalled = true; +} +// === End stale-fiber-rewind guard ===