Two research dossiers under docs/features: - async/: Asyncify currData contention — the clipboard crash and post-idle tool hang as one disease (single global suspension slot, three producers), asyncify internals walkthroughs, prior art, arbiter designs A/B. - wasm-exceptions/: -fexceptions vs -fwasm-exceptions — measured invoke-driven share of the asyncify tax on pcbnew (59% raw / 64% gzip; download 64.5->36 MB if migrated), KiCad dialog-in-catch audit (85 direct / 93 review of 636, catch_audit.py included), binaryen toolchain status (partial EH support since v125; TryTable unsupported), and a catch-arm-hoisting pre-pass design that would obsolete the KiCad-side refactor. Cross-linked with the parked KICAD_WASM_EH end-to-end experiment (docs/wasm-exceptions-experiment.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
25 KiB
Research dossier: Asyncify currData contention in KiCad-WASM — the crash and the hang
This is a research/understanding document, not an implementation plan. Its job is to make the whole machine legible: what suspends ("sleeps"), what doesn't, who owns the one global Asyncify slot, why the main loop is "parked," and exactly what "de-parking" would change. Fix options are listed at the very end as options, not steps.
All line numbers are against the currently-built artifacts:
tests/apps/kicad/pcbnew.js (generated JS runtime), wxwidgets/src/wasm/*.cpp,
kicad/thirdparty/libcontext/libcontext.cpp, kicad/include/tool/coroutine.h,
wxwidgets/src/common/init.cpp.
0. The one-paragraph model
Emscripten Asyncify has two global registers: Asyncify.state
(Normal=0 / Unwinding=1 / Rewinding=2) and Asyncify.currData (a pointer to the save
buffer). They describe "the single suspension currently in flight." The runtime assumes
at most one suspension is live and that it fully rewinds before the next begins.
KiCad-WASM breaks that assumption because three independent subsystems drive those same
two registers: tool coroutines (libcontext → emscripten_fiber_swap), modal dialogs +
clipboard (EM_ASYNC_JS → handleSleep), and the parked main loop. When any two overlap on
the single slot, one of them reads a buffer that no longer belongs to it. Depending on who
clobbers whom, you get either a crash (doRewind on a null/garbage buffer →
index out of bounds) or a hang (a swap unwinds, but its rewind is never issued, so the
target never runs). The clipboard bug and the tool-open hang are the same disease.
1. Vocabulary: what "sleeps" and what does not
"Sleep" here = an Asyncify suspension: the wasm stack is unwound into a buffer, control
returns to JS, JS does work, then the stack is rewound and execution resumes exactly where
it left off. The three primitives _asyncify_start_unwind / stop_unwind / start_rewind / stop_rewind (pcbnew.js:15532-15538) are the only wasm exports involved; all scheduling
is done by JS glue. C++ never writes currData/state — it only spills/restores locals
when the JS-driven state says to.
| Call | Sleeps? | Mechanism | Where |
|---|---|---|---|
emscripten_fiber_swap (tool coroutine swap) |
YES | unwind source fiber + rewind target fiber | pcbnew.js:11557 |
startModal (wxDialog::ShowModal) |
YES | EM_ASYNC_JS → handleSleep, awaits a Promise |
dialog.cpp:201 |
js_clipboardHasText (old IsSupported) |
YES | EM_ASYNC_JS, readText() raced vs 2 s timeout |
clipbrd.cpp:118 |
js_readTextFromClipboard (paste) |
YES | EM_ASYNC_JS, only on user gesture |
clipbrd.cpp GetData |
js_enumerateFonts |
YES | EM_ASYNC_JS |
fontenum.cpp |
js_isClipboardAPIAvailable |
NO | synchronous EM_JS capability probe |
clipbrd.cpp:29 |
ProcessEvents (one main-loop tick) |
NO by itself | plain C call on a fresh stack; only sleeps if something inside it does | evtloop.cpp:19 |
| the rAF main-loop tick | NO | requestAnimationFrame/setTimeout re-enters wasm fresh each frame |
pcbnew.js:11342 |
emscripten_set_main_loop(...,1) "infinite loop" |
NO (not a sleep!) | throw "unwind" — a plain JS exception, not Asyncify |
pcbnew.js:11392 |
The single most important correction: the main loop's "park" is not an Asyncify suspension. It is a thrown JS string. This matters enormously (§5).
2. The three suspension producers, in detail
2a. Tool coroutines = libcontext = emscripten fibers
KiCad runs interactive tools as coroutines (COROUTINE in kicad/include/tool/coroutine.h).
Call/Resume/KiYield switch stacks via libcontext::jump_fcontext (coroutine.h:530,
:548). On wasm, libcontext is not native assembly — it is a shim over emscripten fibers
(libcontext.cpp): make_fcontext → emscripten_fiber_init (:271); jump_fcontext →
emscripten_fiber_swap (:320). So "a tool fiber swap" is literally emscripten_fiber_swap,
which drives Asyncify.currData/state. libcontext keeps its own g_current_context,
per-context resume_epoch (to detect "ghost resumes" where a swap returns without anyone
swapping back, :323), and a [[noreturn]] trampoline wasm_fcontext_entry (:228) that
loops forever so a finished coroutine swaps back instead of returning (a returning fiber would
end the whole program). KICAD_DIAG_COROUTINE (kicad/include/kicad_wasm_diag.h) logs every
jump-enter / save-slot / jump-swap / jump-resume / jump-ghost / entry-call / trampoline-swap
— this is the trace the prior session read.
2b. Modal dialogs (and the old clipboard) = EM_ASYNC_JS = handleSleep
wxDialog::ShowModal (dialog.cpp:245) must block and return an int (native semantics —
hundreds of KiCad sites do if( dlg.ShowModal()==wxID_OK )). A browser main thread cannot
block, so startModal (dialog.cpp:201) is an EM_ASYNC_JS that: suspends the C++ stack via
Asyncify, runs a setTimeout(17ms) loop that calls ProcessEvents so the UI stays live, and
resolves when EndModal (dialog.cpp:286) fires Module._endModal(code). The modal is,
structurally, a coroutine implemented on handleSleep. The old clipboard
IsSupported (clipbrd.cpp:288) used the same handleSleep road via js_clipboardHasText.
2c. The main loop "park"
wxGUIEventLoop::DoRun (evtloop.cpp:85) ends with
emscripten_set_main_loop(ProcessEvents, 0, /*simulate_infinite_loop=*/1) (:107).
Dissected fully in §4–§5.
3. The Asyncify engine (the JS glue), exactly
handleSleep — the EM_ASYNC_JS / sleep road (pcbnew.js:10160)
- First entry,
state==Normal: callstartAsync(wakeUp). IfwakeUpis not called synchronously, a real suspend begins (:10219):state=Unwinding; currData = allocateData(); MainLoop.pause(); start_unwind(). → the C stack unwinds; locals spill intocurrData's buffer. - Promise resolves →
wakeUp(result)(:10169):state=Rewinding; start_rewind(currData); MainLoop.resume(); doRewind(currData).doRewindreads field #8 of the buffer to learn which exported function to re-enter (getDataRewindFuncName,:10143). IfcurrDatais wrong or null here → reads garbage →RuntimeError: index out of bounds. - Re-entry at
state==Rewinding(:10229):state=Normal; stop_rewind(); free(currData); currData=null. The slot is released.
Note the asymmetry: the sleep road pauses/resumes
MainLoop(:10225,:10180). The fiber road (below) does not touchMainLoop.
fiber swap — the coroutine road (pcbnew.js:11557)
function _emscripten_fiber_swap(oldFiber, newFiber) {
if (Asyncify.state === Asyncify.State.Normal) { // leaving a fiber
Asyncify.state = Asyncify.State.Unwinding;
var asyncifyData = oldFiber + 20; // OLD fiber's embedded buffer
Asyncify.setDataRewindFunc(asyncifyData);
Asyncify.currData = asyncifyData; // <-- writes the single slot
_asyncify_start_unwind(asyncifyData);
Fibers.nextFiber = newFiber; // schedule the rewind target
} else { // landing back via rewind
Asyncify.state = Asyncify.State.Normal;
_asyncify_stop_rewind();
Asyncify.currData = null;
}
}
The actual rewind of the target is deferred to Fibers.trampoline (pcbnew.js:11522),
which is invoked from maybeStopUnwind (:10097) once the unwind reaches the bottom
(exportCallStack.length===0, :10098) — note maybeStopUnwind also does
runtimeKeepalivePush() (:10105) "so a rewind can be done later":
trampoline() {
if (!Fibers.trampolineRunning && Fibers.nextFiber) { // GUARD
Fibers.trampolineRunning = true;
do { var f = Fibers.nextFiber; Fibers.nextFiber = 0;
Fibers.finishContextSwitch(f); } while (Fibers.nextFiber);
Fibers.trampolineRunning = false; // only reached if body returns
}
}
finishContextSwitch(newFiber) { // the rewind half
... restore stack limits/pointer ...
if (entryPoint !== 0) { Asyncify.currData = null; dynCall_vi(entryPoint, userData); } // first run
else { var d = newFiber+20; Asyncify.currData = d; Asyncify.state = Rewinding;
_asyncify_start_rewind(d); Asyncify.doRewind(d); } // resume
}
Two fragilities live here:
finishContextSwitchre-enters wasm (doRewind/dynCall_vi). If that re-entered code itself unwinds before returning, thedo/whileis abandoned withFibers.trampolineRunning === true(the:11533reset never runs). Every futureFibers.trampoline()then fails the guard and returns immediately → pendingnextFibernever processed → hang. (This is the precise facet the pastedtry/finally"self-heal" addresses — it forces the guard back tofalse.)- fiber buffers come from
emscripten_fiber_init, notAsyncify.allocateData, so thehandlesleep.jsshim (§3a) is blind to them.
The #9153 shim — what handlesleep.js actually is (scripts/common/shims/handlesleep.js)
pcbnew.js is generated (emscripten link output, then post-processed; it is committed but
overwritten by every build). The handleSleep override is not Emscripten's — it is our
shim, source of truth scripts/common/shims/handlesleep.js, injected verbatim into
pcbnew.js by scripts/common/inject-dyncall-shims.sh (cat "$SHIM_DIR/handlesleep.js" >>)
right after the _emscripten_fiber_swap.isAsync = true; marker (lands ~pcbnew.js:11579).
Build order (docker/build.sh:123-156): link → inject-dyncall-shims.sh → apply-finalize.sh
→ apply-asyncify.sh. inject-dyncall-shims.sh injects, in order: (1) per-signature
dynCall_* bindings, (2) six inline empty-callback fixes, (3) handlesleep.js, (4) optional
diagnostics.js (only with SHIM_DIAGNOSTICS=1 — the source of the [CLIP-DIAG]/[DIAG_SLEEP]
log lines). The trampoline self-heal is not present today.
What the shim does: tag each handleSleep with the buffer it allocated, and in wakeUp
restore Asyncify.currData = thatBuffer right before start_rewind/doRewind — so a fiber
swap that clobbered the slot during the await doesn't make the sleep rewind the wrong buffer.
It fixes exactly one level of nesting and only for sleeps (not fibers).
4. CONTROL FLOW — startup, and how the loop becomes "parked"
run() ─► doRun() ─► callMain() pcbnew.js:21346
└─ entryFunction(argc,argv) == wasmExports["__main_argc_argv"] (C main)
└─ wxEntry ─► wxEntryReal() init.cpp:464
├─ wxTheApp->CallOnInit() (build UI, frames, tools…)
│ └─ [STARTUP BURST: tool coroutines Call/Yield/Resume run here.
│ Each is an emscripten_fiber_swap → currData churns Normal↔set↔null.
│ These WORK because main's real C stack is on exportCallStack,
│ so each unwind reaches bottom, trampoline fires, target rewinds.]
├─ class CallOnExit { ~CallOnExit(){ wxTheApp->OnExit(); } } callOnExit; init.cpp:488
└─ return wxTheApp->OnRun()
└─ MainLoop() ─► wxGUIEventLoop::DoRun() evtloop.cpp:85
└─ emscripten_set_main_loop(ProcessEvents,0,1) evtloop.cpp:107
└─ setMainLoop(...) pcbnew.js:11324
├─ _emscripten_set_main_loop_timing(1,1) :11387
│ └─ runtimeKeepalivePush(); MainLoop.running=true :11270
│ (★ runtime now stays alive even if main "exits")
├─ MainLoop.scheduler() → schedules first rAF tick
└─ if (simulateInfiniteLoop) throw "unwind"; :11392 ◄── THE PARK
The throw "unwind" propagates as a plain JS exception out through every wasm frame of
OnRun/DoRun/... (they are abandoned, not asyncify-saved, no C++ destructors run) up to:
callMain catch(e) ─► handleException(e) pcbnew.js:21362,1391
└─ e == "unwind" → return EXITSTATUS (swallowed silently) :1397
Result of the park:
- The native C stack of
main()(andwxEntryReal,OnRun,DoRun) is gone. CallOnExit::~CallOnExit()(→OnExit()) andwxEntryCleanupReal()NEVER RUN — the app, frames, and tools stay alive. This is the entire purpose ofsimulate_infinite_loop=1.- The runtime stays alive purely via the keepalive counter (★). Each rAF tick now re-enters
wasm fresh through
MainLoop.runner → runIter → callUserCallback(ProcessEvents)(pcbnew.js:11342→11452→10003) on a brand-new C stack.ProcessEvents(evtloop.cpp:19) pumpsProcessPendingEvents+Paint+ every-thirdProcessIdle, then returns; the tick ends; next rAF scheduled. No sleep is involved in a quiet tick.
5. CONTROL FLOW — the HANG (first tool interaction after startup)
A user gesture (the programmatic File→Open, or a w keystroke) makes ProcessEvents dispatch
into TOOL_MANAGER, which Resume()s a tool coroutine:
rAF tick ─► ProcessEvents ─► TOOL_MANAGER ─► coroutine->Resume()/Call()
└─ libcontext::jump_fcontext ─► emscripten_fiber_swap(old,new) currData = old+20; start_unwind
└─ unwind propagates out of ProcessEvents …
└─ maybeStopUnwind (exportCallStack==0?) ─► Fibers.trampoline()
└─ finishContextSwitch(new): currData=new+20; start_rewind; doRewind(new)
└─ tool body runs … yields/returns … swaps back to caller …
In a healthy world this round-trips and currData ends null. The empirical bug state the
prior session measured at idle was Asyncify.state==Normal but Asyncify.currData != null
— i.e. an unwind happened and its rewind was never issued. The swap "parks" forever; the
open never renders; all post-idle tool interactivity is dead (reproduced even on the
fileless /p/mytest/eeschema route by a w keystroke — so it is general, not open-specific).
Why the first post-startup swap, specifically? The two credible mechanisms (not mutually
exclusive), both rooted in §4's throw "unwind":
-
Dangling
currDatafrom the abnormal teardown. The park abandons the C stack via a JS throw without runningstop_unwind/stop_rewindor resetting the Asyncify globals. If the startup burst left an in-flight or half-settled fiber context at the momentDoRunthrew,currDatastays non-null into idle. The nextemscripten_fiber_swapenters thestate==Normalbranch and overwritescurrDatawithold+20, orphaning the dangling buffer; when control later needs the orphaned context, its rewind can never be issued → hang. -
Stuck trampoline guard. If any swap inside
Fibers.trampoline'sdo/whileunwound and never returned (§3, fragility #1),trampolineRunningis stucktrue, so the first post-startup swap'snextFiberis scheduled but the trampoline early-returns and never rewinds it → hang.
Diagnostic that would disambiguate (cheap, no rebuild): log
Asyncify.currData,Asyncify.state, andFibers.trampolineRunning(a) at the last line ofDoRunbefore the throw, (b) on the first rAF tick, (c) at the entry of the first post-startupemscripten_fiber_swap. ComparecurrDataagainstg_main_context's buffer and against any live coroutine'sfiber+20. That tells you which of #1/#2 (or both) is in play.
6. CONTROL FLOW — the CRASH (clipboard), for contrast
post-load idle ─► (wx paste-enable / GetClipboardUTF8) ─► wxClipboard::IsSupported(wxDF_TEXT)
└─ js_clipboardHasText (EM_ASYNC_JS) ─► handleSleep: currData=bufA; MainLoop.pause(); start_unwind
└─ PARKED up to 2 s awaiting readText() (headless ⇒ always full timeout)
├─ during the wait a modal tears down (EndModal:5100) ─► emscripten_fiber_swap
│ └─ currData = fiberF+20 ◄── clobbers bufA in the single slot
├─ 2nd/3rd clipboard polls stack up (log: pendingSleeps→3, one ENTER at state=2)
└─ bufA's Promise resolves ─► handleSleep wakeUp: start_rewind(currData=null/F)
└─ doRewind(null) ─► reads garbage field#8 ─► RuntimeError: index out of bounds
Same slot, opposite victim: here a long-parked sleep is clobbered by a fiber swap (crash); in §5 a fiber swap is stranded by a dangling slot left by the park (hang).
7. "De-parking" in finest detail — the option you hadn't seen
What "de-park" means: change evtloop.cpp:107 to
emscripten_set_main_loop(ProcessEvents, 0, /*simulate_infinite_loop=*/0). Then setMainLoop
does not throw (pcbnew.js:11391 skipped); it returns normally into DoRun, which
returns up the C++ stack. The Asyncify globals are left in the clean state ordinary C++ returns
produce (no abandoned unwind), which removes mechanism §5#1 at the source.
The lifecycle problem it creates (and why =1 exists): if DoRun returns, the unwind of
the C++ stack runs the very teardown the park was hiding:
DoRun returns ─► OnRun returns ─► wxEntryReal:
├─ ~CallOnExit() ─► wxTheApp->OnExit() init.cpp:488
└─ wxEntry ─► wxEntryCleanupReal() init.cpp:433
├─ wxTheApp->CleanUp() (deletes ALL top-level windows, pending objects)
├─ delete app; (destroys wxTheApp) init.cpp:448
└─ DoCommonPostCleanup()
⇒ then C main returns ⇒ callMain: exitJS(ret, implicit=true) pcbnew.js:21360
└─ _proc_exit: keepRuntimeAlive()==true (keepalive ★) ⇒ does NOT abort :1383
⇒ runtime KEEPS RUNNING, rAF keeps firing ProcessEvents …
… but wxTheApp + all windows are already FREED ⇒ next tick touches freed memory
(this is also the null-`IsModal`/`windowClosing` close-path crash).
So the runtime survives (keepalive), but the app is torn down underneath the still-firing main
loop. That is precisely the trap simulate_infinite_loop=1 avoids — not by keeping the
runtime alive (the keepalive counter already does that), but purely by preventing the C++
cleanup from running.
Therefore a correct de-park is two coupled changes, not one:
emscripten_set_main_loop(..., 0)soDoRunreturns with clean Asyncify state, and- suppress the destructive post-
MainLoopteardown so the live app isn't freed. Options for (2), in ascending invasiveness:- (2a) In the wasm
wxApp/event-loop path, make theOnRun→DoRunreturn not fall into~CallOnExit/wxEntryCleanupReal(e.g. a wasm-specificOnRunthat returns through a path which skips cleanup), and instead run cleanup fromUnloadCallback(app.cpp:620, registered:694) onbeforeunload. This keeps teardown for real page exit and removes it from the steady state. - (2b) Keep
wxEntryRealas-is but guardwxEntryCleanupReal/OnExitto be no-ops while the rAF loop is still registered (a "main loop owns lifetime" flag), deferring the real cleanup to unload. - Either way, the rAF loop must remain the sole owner of app lifetime;
ScheduleExit(evtloop.cpp:40,emscripten_cancel_main_loop) becomes the one path that tears down.
- (2a) In the wasm
What de-parking does and does NOT fix:
- Fixes: the dangling-
currData-at-idle mechanism (§5#1) and the null-IsModalclose crash (the teardown no longer fires under the live loop). - Does not, by itself, fix: the fundamental slot-sharing. Two genuinely overlapping
suspensions (a 2 s clipboard sleep crossed by a fiber swap; a modal whose nested
ProcessEventsdrives a tool coroutine) still contend for onecurrData. De-parking removes the base occupant; it does not make concurrent suspensions compose. The trampoline guard facet (§5#2) is also independent — addressed by the self-heal, not by de-parking.
Cost: evtloop.cpp + app.cpp/init.cpp lifecycle change → full wx rebuild + relink
(~10–15 min), and it touches app shutdown, so it needs the test matrix (§9) as a safety net.
The prior session tried a CallAfter-deferral of the call site first and reverted it — which
is useful evidence: deferring the call site didn't help, implying the problem is the
parked-stack/abandoned-unwind topology, not the timing of when the loop is installed.
8. Why we can't just "make the modal not sleep"
The modal must hand KiCad a blocking int ShowModal() (native semantics; rewriting the
call sites is a KiCad change, against policy). In a single-threaded browser the only ways to
"return later from a call that hasn't finished" are (a) block the thread — impossible, freezes
the tab — or (b) suspend the stack (Asyncify/fiber). So the modal is necessarily a
suspension. Re-homing it onto a fiber instead of handleSleep buys nothing (fibers are the
same currData machine). The only durable answer is to make suspensions compose, i.e. fix
the slot, not the modal.
9. How to enumerate "every possible case" as tests
A strong harness already exists: tests/apps/standalone/coroutine*/ (nine probes:
main/nested/nested_ex/embind/mainloop/gl/gl_pt/vcall/wxpt) built by
scripts/build-wasm-test.sh via tests/apps/Makefile.wasm (each links with
-sASYNCIFY=1 -sASYNCIFY_IMPORTS=['emscripten_fiber_swap'] then the same
inject-dyncall-shims.sh), plus tests/e2e/coroutine-nested.spec.ts (8 modal×fiber
scenarios) and coroutine-pthread.spec.ts, all asserting no index out of bounds and
polling a SUMMARY total/passed/failed line. Gaps: it doesn't systematically cover
out-of-order and long-parked overlaps, and it asserts crash-freedom but not liveness
(so it would not catch a hang).
Make it a generated combinatorial product and assert three outcomes per cell:
- Primitives (cells):
S1=EM_ASYNC_JS sleep (modal/clipboard/font),S2=fiber swap (tool coroutine),S3=parked main loop,S4=pthread boundary. - Overlap shape: none / nested-LIFO / interleaved out-of-order / long-parked outer (the 2 s clipboard shape). The last two are the under-tested ones.
- Host context: direct / rAF main-loop tick / embind dispatch / WebGL2 frame / deep stack /
-fexceptionsinvoke wrappers. - Resume target: continuation / virtual call (
invoke_vi→dynCall_vi, thevcall_reprosmoking gun). - Assert per cell: (1) no
index out of bounds/indirect call to null/unwindrejection (no crash); (2) completes within a timeout (no hang — the missing assertion today); (3) returned value correct (no silent wrong-buffer rewind). - Two must-add named scenarios that pin our exact bugs deterministically:
long_parked_sleep_clobbered_by_swap(the clipboard crash) andfiber_swap_after_main_park(the §5 hang — requires the harness to actually install asimulate_infinite_loop=1main loop, then swap a fiber post-park).
10. Fix options (reference only — not a plan)
Same disease, four independent levers; they compose:
- Stage 0 — synchronous clipboard
IsSupported(clipbrd.cpp): deletes the longest-lived sleep (the 2 s idle read). Kills the crash class. Low risk, wx-layer. - Stage 1 — trampoline self-heal (
try/finallyininject-dyncall-shims.sh): forcesFibers.trampolineRunning=falseeven when a swap unwinds mid-loop. De-wedges the guard facet of the hang (§5#2). Cheap, JS-shim-only. Band-aid, not cure. - Stage 2 — de-park
main()(evtloop.cpp+app.cpp/init.cpp, §7): removes the dangling-slot base occupant (§5#1) and the close crash. The structural cure for the hang. Bigger; wx rebuild + lifecycle care. - Stage 3 — per-context
currData(generalizehandlesleep.jsinto a real context stack that also tracks fiber buffers): makes overlapping suspensions compose regardless of order. Highest correctness/risk; only if the §9 matrix still shows overlap failures after 0–2. - Stage 4 — the combinatorial matrix (§9): gates the riskier fixes.
Open questions to resolve before any implementation
- Run the §5 diagnostic — is the idle
currDatathe orphaned-from-park buffer (#1), or is the trampoline guard stuck (#2), or both? This decides whether Stage 1 alone meaningfully helps. - For Stage 2, which suppression shape (2a UnloadCallback-driven cleanup vs 2b guarded
wxEntryCleanupReal) is least invasive given ourwxApp/init.cppfork delta? (Checkscripts/kicad-diff-stats.shand the current wx fork divergence first.) - Does Stage 0 (clipboard) by itself make the load-pcb route stop hanging, or only stop crashing? (If the open hangs even with clipboard synchronous, Stage 2 is required, not optional.)