pcbjam/scripts/common/shims/handlesleep.js
Gergő Törcsvári 84a40d4492
diag(asyncify): write-time instrumentation + local warm-load repro findings
The prod differential ladder finished: staged byte VOLUME on a warm load is
the only trigger left (V1a siblings-without-lib-tables dies, V1b +120 files
survives, V1c sibling KiCad files renamed byte-for-byte dies, V1d Leonardo +
123MB of inert markdown dies on loads 3-4; 14MB never dies). 3D models,
collab/ydoc/presence, lib tables, sibling KiCad handling and file count are
all exonerated — volume only loads the dice on the underlying race.

That made the crash reproducible locally for the first time in six campaigns:
a persistent browser profile + a 110MB project fails every warm load with the
exact prod signature. Iteration is now ~12 minutes instead of a release cycle.

Shim: every fiber switch now records the departing side's remaining asyncify
buffer and its recorded rewind entry (rem=/rf=), which is what identified the
unrewindable capture and disproved buffer overflow. The deferral family is
closed for good — a microtask-deferred retry on a clean empty stack died
identically to the nested rewind, because the suspension is broken at write
time, not by nesting.

Shell: log the origin stack when wx reports the top window destroyed. That
notification fires from ~wxTopLevelWindowWasm for ANY top-level window, so a
transient frame dying mid-load navigates the user out of the editor — a real
bug in its own right, found while chasing the empty flight-recorder dumps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
2026-08-03 11:05:10 +02:00

423 lines
22 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
+ " fcsTotal=" + (F.__fcsTotal || 0)
+ " rootHotTotal=" + (F.__rootHotTotal || 0)
+ " 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);
}
// 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() {
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)
+ (sleepCtx.rootOwned ? " R" : " f"));
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;
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) {
// 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;
Asyncify.__wakingOwnerFiber = prevWakingOwnerFiber;
if (sleepCtx.rootOwned) Asyncify.__wakingRoot = prevWakingRoot;
}
});
});
} 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) {
// Cumulative, scroll-proof counters (the 96-event ring holds <1s at idle
// tick rate — differential-repro dose measurements need totals).
Fibers.__fcsTotal = (Fibers.__fcsTotal || 0) + 1;
if (newFiber === Fibers.__rootFiber && (Asyncify.__inSleepWake || 0) > 0) {
Fibers.__rootHotTotal = (Fibers.__rootHotTotal || 0) + 1;
}
// rem = free bytes left in the old side's asyncify buffer after its unwind
// finished (asyncify_data layout: [data]=write ptr, [data+4]=buffer end).
// A deep capture that exhausts the 512K fiber buffer overflows SILENTLY in
// release — rem at/below 0 here is the smoking gun for an unrewindable
// suspension (the 2026-08-03 deferred-retry trap hypothesis).
var __remStr = "";
if (Asyncify.currData) {
var __H = (typeof GROWABLE_HEAP_U32 === "function") ? GROWABLE_HEAP_U32() : HEAPU32;
__remStr = " rem=" + (__H[((Asyncify.currData + 4) >>> 2) >>> 0] - __H[(Asyncify.currData >>> 2) >>> 0])
// The rewind entry recorded for this suspension (setDataRewindFunc
// wrote exportCallStack[0] at unwind start) vs the export stack NOW —
// a capture taken under a NESTED export whose recorded entry is the
// outer _main-style bottom is unrewindable (the 2026-08-03 trap).
+ " rf=" + (Asyncify.getDataRewindFuncName ? Asyncify.getDataRewindFuncName(Asyncify.currData) : "?")
+ " es=[" + (Asyncify.exportCallStack || []).join("|") + "]";
}
__fcsRec("fcs old=" + (Asyncify.currData ? Asyncify.currData - 20 : 0)
+ " new=" + newFiber
+ (newFiber === Fibers.__rootFiber ? " ROOT" : "")
+ " w=" + (Asyncify.__inSleepWake || 0) + __remStr);
// 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;
// DEFERRAL FAMILY CLOSED (2026-08-03, round 6 — the definitive finding).
// Deferral at this layer can never work: by the time finishContextSwitch
// runs, _asyncify_start_unwind has ALREADY written the suspension, and a
// root suspension captured inside main's own live wake window is broken
// AT WRITE TIME — emscripten_fiber_swap records the rewind entry as
// exportCallStack[0] (the re-invoked main export) while the capture only
// spans the swap-site frames (1.3KB vs a valid fresh-entry's ~10KB). A
// microtask-deferred retry on a clean empty stack trapped IDENTICALLY to
// the nested rewind. Prevention lives where it must: the libcontext C++
// guard refuses the jump BEFORE the unwind starts (jump-refused-hot-main,
// keyed on __wakingRoot via EM_JS) — same ghost contract as the parked
// guard. The consume-once/quarantine checks below remain the backstop
// for laundered attributions the C++ layer cannot see.
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 (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.1922): 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, 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);
}
// 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
// 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 ===