fix(asyncify): ownership-scoped root deferral — the recorded nested self-rewind, cured

The v0.1.23 flight recorder caught the kill live (console-export-2026-8-1_19-16-8):
dozens of benign fiber round-trips at w=0, the yield cycling healthily on its
buffer — then "fcs … ROOT w=1" and the trap, state frozen at Rewinding with
currData=root+20. The fatal condition, observed rather than inferred: a fiber
round-trip inside the ROOT's OWN sleep-wake continuation re-suspends and
re-rewinds the root nested inside its live wake rewind. Consume-once passed
correctly — it guards a different corruption and stays.

The round-3 deferral was aimed right but unscoped (taxed fiber-owned wakes,
flaked S4). Final form: every fresh sleep is tagged root- or fiber-owned
(fiber ⇔ started inside a finishContextSwitch fiber slice or a fiber-owned
wake; root entries don't count as slices); finishContextSwitch(root) defers
one macrotask ONLY while a root-owned wake is live (Asyncify.__wakingRoot).
Beacon: root-entry-deferred. Verified inert where it must be: zero beacons
across all 13 drift-trio-scenarios logs (26/26 + 25/26-then-26/26 stress —
the single miss carried no beacons, i.e. the pre-existing under-load flake).

Also: resume re-entries no longer push sleep contexts (the v0.1.23 dump
carried ~380 leaked zero-linked entries), and wake events in the recorder are
tagged R/f for ownership.

.ci-cache-epoch 5→6.

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 19:49:38 +02:00
commit 210b079ed9
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 94 additions and 4 deletions

View file

@ -1 +1 @@
5
6

View file

@ -159,6 +159,29 @@ installed at module import in main.tsx, cooperating with the React overlay
moment it disappears — 1 Hz ensure-loop). `fatal-overlay.spec.ts` now also
kills the React root after the fatal and asserts the DOM floor appears.
## Round 4 (2026-08-01 evening) — the recorder caught it: nested self-rewind of the root
v0.1.23 still trapped, but this time with the black box
(console-export-2026-8-1_19-16-8): dozens of benign fiber round-trips at
`w=0`, the yield cycling healthily — then `fcs old=root new=88145920 w=1` /
`fcs … ROOT w=1` and the trap, with the state dump frozen at
`state=2 currData=root+20`. The fatal condition, now OBSERVED rather than
inferred: a fiber round-trip executed inside the ROOT'S OWN sleep-wake
continuation re-suspends and re-rewinds the root nested inside its live wake
rewind — two rewind lifetimes on one context. Consume-once passed correctly
(the suspension was fresh); it guards a different corruption.
The retracted round-3 deferral was aimed right but unscoped: benign
parked-fiber completions run in FIBER-owned wake windows (or at w=0) and
must not pay the hop (that tax flaked S4). **Final form: ownership-scoped
deferral.** Every fresh sleep is tagged root- or fiber-owned (fiber ⇔
started inside a `finishContextSwitch` fiber slice or a fiber-owned wake);
`finishContextSwitch(root)` defers one macrotask ONLY while a ROOT-owned
wake is live (`Asyncify.__wakingRoot`), with the
`root-entry-deferred` beacon. Also fixed: the resume re-entry no longer
pushes sleep contexts (the v0.1.23 dump carried ~380 leaked zero-linked
entries).
## Flight recorder (round 3, targeting instrument)
The shim keeps a 96-entry ring of asyncify/fiber events (sleep entries with

View file

@ -133,7 +133,25 @@ if (typeof Asyncify !== "undefined") {
"handleSleep entered mid-unwind (state=1) currData=" + Asyncify.currData,
true);
}
var sleepCtx = { capturedData: null, cleanedUp: false };
// Only a FRESH park (state 0) allocates data and needs tracking. The
// state-2 resume re-entry returns synchronously through the rewind
// branch — pushing a context for it leaks one per resume (the v0.1.23
// prod dump carried ~380 zero-linked pending contexts).
if (Asyncify.state !== 0) {
return __originalHandleSleep(startAsync);
}
var sleepCtx = {
capturedData: null,
cleanedUp: false,
// Ownership: does this park belong to the ROOT chain (the main
// loop's yield, an embind entry) or to a fiber body's slice? Root
// re-entry during a ROOT-owned wake is the nested-self-rewind that
// kills prod; fiber-owned wakes completing into root are the benign
// bulk (see the fiber guard below).
rootOwned: (typeof Fibers === "undefined")
|| (!Fibers.__inFiberEntry
&& !(Asyncify.__wakingOwnerFiber || false))
};
Asyncify.__pendingSleepContexts.push(sleepCtx);
var cleanup = function() {
@ -149,7 +167,8 @@ if (typeof Asyncify !== "undefined") {
// wakeUp runs from pure JS on Promise resolution. Fiber swaps during
// the await may have overwritten Asyncify.currData. Restore OUR buffer
// so handleSleep's _asyncify_start_rewind and doRewind use it.
__rec("wake buf=" + (sleepCtx.capturedData || 0) + " cdWas=" + (Asyncify.currData || 0));
__rec("wake buf=" + (sleepCtx.capturedData || 0) + " cdWas=" + (Asyncify.currData || 0)
+ (sleepCtx.rootOwned ? " R" : " f"));
if (sleepCtx.capturedData) {
if (Asyncify.currData !== sleepCtx.capturedData) {
// The repair firing. currData=null → the overlapping chain
@ -176,6 +195,10 @@ if (typeof Asyncify !== "undefined") {
// doRewind(root) → unreachable). The stale-fiber guard below
// defers such root entries by one macrotask.
Asyncify.__inSleepWake = (Asyncify.__inSleepWake || 0) + 1;
var prevWakingOwnerFiber = Asyncify.__wakingOwnerFiber || false;
Asyncify.__wakingOwnerFiber = !sleepCtx.rootOwned;
var prevWakingRoot = Asyncify.__wakingRoot || 0;
if (sleepCtx.rootOwned) Asyncify.__wakingRoot = (Asyncify.__wakingRoot || 0) + 1;
try {
return wakeUp(result);
} catch (e) {
@ -191,6 +214,8 @@ if (typeof Asyncify !== "undefined") {
throw e;
} finally {
Asyncify.__inSleepWake -= 1;
Asyncify.__wakingOwnerFiber = prevWakingOwnerFiber;
if (sleepCtx.rootOwned) Asyncify.__wakingRoot = prevWakingRoot;
}
});
});
@ -297,6 +322,38 @@ if (typeof Fibers !== "undefined"
}
var isRoot = newFiber === Fibers.__rootFiber;
// THE prod killer, finally recorded by the flight recorder (v0.1.23 dump,
// event "fcs … ROOT w=1" immediately before the trap): a fiber round-trip
// executed inside the ROOT's OWN sleep-wake continuation re-suspends and
// re-rewinds the root NESTED inside its live wake rewind — two rewind
// lifetimes on one context; asyncify dies with state stuck at Rewinding.
// Fiber-owned wakes completing into root are the benign bulk (the same
// recording shows dozens at w=0 / fiber-owned) and are NOT deferred —
// that unscoped deferral was the retracted S4-flaking version. Only a
// root re-entry during a ROOT-OWNED wake defers, one macrotask, so the
// outer rewind fully settles first.
if (isRoot && (Asyncify.__wakingRoot || 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 inside the root's own "
+ "wake window; retrying next tick (occurrence " + Fibers.__rootDeferrals + ")");
}
__fcsRec("defer ROOT-self new=" + deferred);
var retry = function() {
if (Fibers.trampolineRunning || Fibers.nextFiber) {
setTimeout(retry, 0);
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)) {
@ -328,7 +385,17 @@ if (typeof Fibers !== "undefined"
Fibers.__validSuspensions.delete(newFiber);
}
var ret = __origFinishContextSwitch(newFiber);
// Sleeps started inside an entered FIBER's slice are fiber-owned (see the
// handleSleep wrapper's rootOwned tag). Root entries don't count: the
// main loop's continuation after a root rewind is root-owned by
// definition — that's exactly the chain whose wake must not be re-entered.
if (!isRoot) Fibers.__inFiberEntry = (Fibers.__inFiberEntry || 0) + 1;
var ret;
try {
ret = __origFinishContextSwitch(newFiber);
} finally {
if (!isRoot) Fibers.__inFiberEntry -= 1;
}
// How did the entered fiber's synchronous slice end? Another fiber swap
// (nextFiber set — the trampoline loop continues, proper suspension) or a