pcbjam/scripts/common/shims/handlesleep.js
Gergő Törcsvári 9ca2ac1e52
fix(asyncify): layer 3 — serialize root re-entry out of sleep-wake windows + flight recorder
v0.1.22 still trapped with BOTH guards silent: the fatal rewind's target is
the ROOT context, which layer 2 exempted. All four prod stacks are the same
collision — a fiber completes its yield-back to main while main's sleep-wake
rewind is still on the stack (maybeStopUnwind → trampoline →
finishContextSwitch → doRewind(root) → unreachable), two "resume main" paths
interleaved in one tick; the 8ms-earlier "index out of bounds" is the wake
side of the same event.

Root entry is legal and constant in healthy flow; only the wake-window
overlap is fatal. So: serialize, don't refuse. The shim marks the
synchronous wake window (Asyncify.__inSleepWake around wakeUp) and DEFERS a
root finishContextSwitch landing inside it by one macrotask
([wx-asyncify] root-entry-deferred beacon, trampoline retry) — an ordering
change only, nothing dropped. Suspension recording happens before the
deferral branch, so the yielding fiber's validity survives the wake chain
nulling currData.

Plus a flight recorder: a 96-entry ring of asyncify/fiber events (sleeps,
wakes, every context switch with ROOT/wake-depth, refusals, deferrals),
silent in normal operation, auto-dumped with full machine state next to the
first trap signature in the console; window.__wxAsyncifyDump() on demand.
The next prod export reads like a black box, not a stack-shape puzzle.

.ci-cache-epoch 3→4 (wasm cache key omits scripts/**).

Local: fiber-resume-park 2/2 (one refusal beacon), timer-park 1/1, sweep 20
passed, web fatal+follow 2/2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
2026-08-01 14:06:53 +02:00

365 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// === Nested-Asyncify handleSleep currData save/restore (Emscripten #9153) ===
//
// Asyncify.currData is a single-slot global. When a fiber swap runs inside an
// EM_ASYNC_JS Promise await (e.g., wxDialog::ShowModal via startModal), the
// fiber swap overwrites currData with the fiber's asyncify_data, losing the
// sleep's own buffer. On Promise resolution, handleSleep's doRewind then uses
// the wrong buffer and crashes with "index out of bounds" or "unreachable".
//
// Workaround: intercept Asyncify.allocateData to record which pointer belongs to
// the active handleSleep; restore it to Asyncify.currData inside the wakeUp
// callback before handleSleep proceeds to _asyncify_start_rewind + doRewind.
if (typeof Asyncify !== "undefined") {
if (typeof Asyncify.handleSleep === "function"
&& typeof Asyncify.allocateData === "function"
&& !Asyncify.__nestedHandleSleepInstalled) {
// Stack of handleSleep contexts awaiting their allocateData association.
Asyncify.__pendingSleepContexts = [];
// Anomaly reporting (diagnostics only — behavior unchanged). This shim has
// been SILENTLY repairing currData aliasing between concurrent parks since
// it was written; production traps in exactly this family ("index out of
// bounds" / "unreachable executed" during doRewind) keep arriving with no
// way to tell whether the shim fired, mislinked, or was bypassed (fiber
// swaps don't allocate through allocateData). Make every repair and every
// concurrent-park window loud, so a saved console dump answers that.
// Rate-limited per kind: first 10 in full, then every 100th.
// Flight recorder: a capped ring of asyncify/fiber events (never printed
// during normal operation), dumped to the console ONCE when a trap
// signature surfaces — so a prod console export carries the exact event
// sequence and machine state at death instead of just stack shapes.
// window.__wxAsyncifyDump() returns it on demand.
var __recMax = 96;
Asyncify.__rec = [];
var __rec = function(ev) {
var r = Asyncify.__rec;
r.push(((typeof performance !== "undefined" ? performance.now() : 0) | 0) + " " + ev);
if (r.length > __recMax) r.shift();
};
Asyncify.__recPush = __rec;
var __dumpState = function() {
var F = (typeof Fibers !== "undefined") ? Fibers : null;
var pend = Array.isArray(Asyncify.__pendingSleepContexts)
? Asyncify.__pendingSleepContexts.map(function(c) { return c.capturedData || 0; }).join(",")
: "n/a";
var head = "[wx-asyncify] STATE"
+ " state=" + Asyncify.state
+ " currData=" + (Asyncify.currData || 0)
+ " inSleepWake=" + (Asyncify.__inSleepWake || 0)
+ " exportStack=" + (Asyncify.exportCallStack ? Asyncify.exportCallStack.length : -1)
+ " pendingSleeps=[" + pend + "]"
+ (F ? (" nextFiber=" + F.nextFiber
+ " trampolining=" + F.trampolineRunning
+ " root=" + F.__rootFiber
+ " valid=[" + (F.__validSuspensions ? Array.from(F.__validSuspensions).join(",") : "") + "]"
+ " parked=[" + (F.__internallyParked ? Array.from(F.__internallyParked).join(",") : "") + "]"
+ " deferrals=" + (F.__rootDeferrals || 0))
: " (no Fibers)");
return head + "\n[wx-asyncify] RECORDER (oldest first):\n " + Asyncify.__rec.join("\n ");
};
if (typeof window !== "undefined") {
window.__wxAsyncifyDump = __dumpState;
// Auto-dump beside the first trap signatures in the console — the one
// artifact prod reports reliably contain.
var __dumps = 0;
var __onTrap = function(msg) {
if (__dumps >= 2) return;
if (!/index out of bounds|unreachable executed|table index|indirect call signature|null function or function signature|memory access out of bounds/i.test(msg)) return;
++__dumps;
try { console.error(__dumpState()); } catch (e) {}
};
window.addEventListener("error", function(e) {
__onTrap(e && e.error instanceof Error ? e.error.message : String((e && e.message) || ""));
});
window.addEventListener("unhandledrejection", function(e) {
__onTrap(e && e.reason instanceof Error ? e.reason.message : String((e && e.reason) || ""));
});
}
var __wxAsyncifyReport = (function() {
var counts = {};
return function(kind, msg, withStack) {
var n = (counts[kind] = (counts[kind] || 0) + 1);
if (n > 10 && n % 100 !== 0) return;
var line = "[wx-asyncify] " + kind + ": " + msg + " (occurrence " + n + ")";
if (withStack) {
// The stack names WHICH EM_ASYNC_JS parked (__asyncjs__wxWasmYieldToBrowser,
// startModal, js_enumerateFonts, ...) — the missing actor in every prod dump.
try { line += "\n" + String(new Error().stack).split("\n").slice(1, 8).join("\n"); } catch (e) {}
}
console.warn(line);
};
})();
var __originalAllocateData = Asyncify.allocateData.bind(Asyncify);
Asyncify.allocateData = function() {
var ptr = __originalAllocateData();
// Associate with the innermost pending handleSleep not yet linked.
for (var i = Asyncify.__pendingSleepContexts.length - 1; i >= 0; --i) {
var ctx = Asyncify.__pendingSleepContexts[i];
if (!ctx.capturedData) {
ctx.capturedData = ptr;
break;
}
}
return ptr;
};
var __originalHandleSleep = Asyncify.handleSleep.bind(Asyncify);
Asyncify.handleSleep = function(startAsync) {
// A FRESH park (state 0 = Normal) starting while another chain's park is
// still live: the single-slot currData is about to be overwritten. The
// shim's restore below makes the POINTER survive, but nothing protects
// deeper state (fiber swaps, freed buffers, out-of-order wakes) — this
// window is where the trap family lives, and until now it was invisible.
// state 2 (Rewinding) entries are NOT reported: every resume legally
// re-enters handleSleep while rewinding with currData set (verified
// empirically 2026-07-31 — the timer-park e2e produced ~100/s of them
// on a healthy run).
__rec("sleep s=" + Asyncify.state + " cd=" + (Asyncify.currData || 0)
+ " w=" + (Asyncify.__inSleepWake || 0));
if (Asyncify.state === 0 && Asyncify.currData) {
__wxAsyncifyReport(
"concurrent-park",
"handleSleep entered while currData=" + Asyncify.currData,
true);
}
if (Asyncify.state === 1) {
// Parking while an UNWIND is literally in progress is never legal —
// if this ever fires it IS the bug.
__wxAsyncifyReport(
"reentrant-state",
"handleSleep entered mid-unwind (state=1) currData=" + Asyncify.currData,
true);
}
var sleepCtx = { capturedData: null, cleanedUp: false };
Asyncify.__pendingSleepContexts.push(sleepCtx);
var cleanup = function() {
if (sleepCtx.cleanedUp) return;
sleepCtx.cleanedUp = true;
var idx = Asyncify.__pendingSleepContexts.indexOf(sleepCtx);
if (idx !== -1) Asyncify.__pendingSleepContexts.splice(idx, 1);
};
try {
return __originalHandleSleep(function(wakeUp) {
return startAsync(function(result) {
// 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));
if (sleepCtx.capturedData) {
if (Asyncify.currData !== sleepCtx.capturedData) {
// The repair firing. currData=null → the overlapping chain
// already completed (benign overlap, but COUNT it: it proves
// concurrent parks happen on this load). currData=<other> → a
// DIFFERENT chain is parked right now and we are rewinding
// around it — the dangerous interleave.
__wxAsyncifyReport(
Asyncify.currData ? "aliased-wake-live" : "overlapped-wake",
"restoring currData=" + sleepCtx.capturedData +
" over " + (Asyncify.currData || "null") +
" state=" + Asyncify.state,
!!Asyncify.currData);
}
Asyncify.currData = sleepCtx.capturedData;
}
cleanup();
// Mark the synchronous wake window: everything below wakeUp() —
// the rewind, the resumed code running forward, its next unwind —
// executes inside it. A fiber completion whose root-entry lands
// in this window rewinds the root WHILE the wake's own rewind is
// in flight (the four identical prod trap stacks:
// maybeStopUnwind → trampoline → finishContextSwitch →
// doRewind(root) → unreachable). The stale-fiber guard below
// defers such root entries by one macrotask.
Asyncify.__inSleepWake = (Asyncify.__inSleepWake || 0) + 1;
try {
return wakeUp(result);
} catch (e) {
// emscripten_set_main_loop(...,1) parks main() by throwing the
// "unwind" sentinel. When main's LAST pre-park suspension was a
// sleep, main is resumed from THIS wakeUp, so the sentinel
// propagates here instead of into callMain's catch — surfacing as
// an uncaught "unwind" promise rejection. Swallow it exactly like
// callMain/handleException do on the direct path.
if (e === "unwind") {
return;
}
throw e;
} finally {
Asyncify.__inSleepWake -= 1;
}
});
});
} catch (e) {
cleanup();
throw e;
}
};
Asyncify.__nestedHandleSleepInstalled = true;
}
}
// === 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) {
__fcsRec("refuse new=" + newFiber);
++__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;
};
var __fcsRec = (typeof Asyncify !== "undefined" && Asyncify.__recPush)
? Asyncify.__recPush
: function() {};
Fibers.finishContextSwitch = function(newFiber) {
__fcsRec("fcs old=" + (Asyncify.currData ? Asyncify.currData - 20 : 0)
+ " new=" + newFiber
+ (newFiber === Fibers.__rootFiber ? " ROOT" : "")
+ " w=" + (Asyncify.__inSleepWake || 0));
// 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;
// The prod killer (four identical trap stacks, v0.1.1922): 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)) {
__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 ===