fix(asyncify): stale-fiber-rewind guard — layer 2, attribution-proof

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
This commit is contained in:
Gergő Törcsvári 2026-08-01 10:05:27 +02:00
commit a8adfa9843
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
2 changed files with 121 additions and 1 deletions

View file

@ -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 ===