Submodule pointer bump for the wxMenuBar dangling-child fix that resolved the 18 DOM-port kicad e2e failures. Also adds the async-debug dossier (docs/features/async/08-10) root-causing the 'indirect call to null' / 'signature mismatch' family to the menu-bar use-after-free, and folds the asyncify harness into 'npm test' (new test:wx for wx-only); tests/README + WHATWORKS updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 KiB
08 — DOM-port asyncify regression (kicad e2e, 18 red)
RESOLVED — see 10-resolution-menubar-uaf.md. Not an Asyncify bug: a freed
wxMenuBarleft in a live frame'sGetChildren()bywxMenuBarBase::Detach()(SetParent(NULL)withoutRemoveChild, which only dangles in the DOM port because the bar is a real child there). The paint/ idle walk then virtual-calls the dead object. Fix: unlink inwxMenuBar::Detach(). The notes below are the (correct) elimination trail that got there.
Status: investigation in progress (instrumented traces complete; symbolized builds pending). Continues the dossier 01–07; same disease family, new producers.
Symptom
After rebasing feature/wx-dom-port onto main (which carries all of 07's
fixes — handlesleep buffer capture, trampoline self-heal, modal LIFO, pump
resolve-on-error, sync clipboard IsSupported), the kicad suite fails 18/54
with RuntimeError: indirect call to null (47×), index out of bounds (5×),
signature mismatch (1×). Main (canvas port) is green with the identical
wx/kicad commits and identical shim set — verified injected in our built apps
(handlesleep __pendingSleepContexts, trampoline try{do{…}}finally,
_wxModalResolvers all present in tests/apps/kicad/*.js).
First fault fires before any test interaction (1–5 s after "Creating app"), during startup modal / first schematic-load coroutine cycles.
Instrumentation used
SHIM_DIAGNOSTICS=1 scripts/common/inject-dyncall-shims.sh <app.js> —
extended this session with (all logging-only, kept in
scripts/common/shims/diagnostics.js):
DIAG_IMPORT— wrapsemscripten_fiber_swap+ every__asyncjs__*import onwasmImports(state/currData/SP/exportCallStack on entry+exit; names the sleeping import, which DIAG_SLEEP could not).DIAG_ASMCONST— wrapsemscripten_asm_const_*; logs ONLY when state ≠ 0 at entry or changes across the call (detects "unwind escaped through a non-asyncify import").DIAG_CCALL— wrapsccall; watcheswx_dom_event,wx_dom_mouse,ProcessEvents; flags entry at state ≠ 0 and state changes across sync calls.DIAG_DOREWIND— now also logs SP, the fiber's saved-SP slot, andAsyncify.exportCallStack.- The old GL/dynCall tracer (sections 7–8) is now opt-in via
Module.__DIAG_GL_TRACE(solved-bug tooling, floods boot logs). - Temp
[DOM_DIAG]logging inwx-dom.jsdispatch()/wxForwardMouse(TEMPDBG, to be removed).
Reproduction: 30-second single-spec runs, fully deterministic:
npx playwright test --config=playwright-kicad.config.ts --project=firefox --grep "Backspace deletes the selection" (after npm run setup:kicad;
note npm run test:kicad -- --grep=… does NOT forward args through the
nested npm scripts).
Established facts (from traces)
-
Bookkeeping is clean. Unwinds/rewinds of the main-context buffer strictly alternate (25 U / 25 R, no double-unwind), SPs match the fiber's saved-SP slot at every rewind,
exportCallStackis rebuilt correctly by rewind replays. The shims (trampoline heal, handlesleep) behave as designed. No EM_ASM state-escape ever fired (DIAG_ASMCONST silent). -
The discriminator is the activation that hosts the park. Fiber-swap cycles work in the dozens when the parked activation is rooted at
__main_argc_argv(pre-park boot),dynCall_iii(embind/collab entry), ordynCall_vi(coroutine entries / timers). The first cycle rooted at a DOM-port-era root faults immediately after its rewind completes:- eeschema (
Backspacespec): first cycle rooted atdynCall_v— the parked main loop's rAF tick (iterFunc = () => dynCall_v(func);func = ProcessEvents, seewxGUIEventLoop::DoRun→emscripten_set_main_loop(ProcessEvents, 0, 1)). Rewind completes (state 2→0 at the swap site), main continues, and ~40 ms later wasm traps on an indirect call through a null pointer inside wx event dispatch (invoke_iii → dynCall_iii → … call_indirect null). Every subsequent tick faults at the same site. 100% reproducible (3/3 runs). - pcbnew (
load-pcbspec): unwind roots observed includeProcessEvents×4 (modal pump ccall) andwx_dom_event×2 (DOM listener dispatch ccall hosting a board-load coroutine swap). The crash follows the first rewind of thewx_dom_event-rooted parked activation. - gerbview (passing-but-faulted): startup modal parks the boot stack
via
__asyncjs__startModal(sleep buffer inAsyncify.currData), and the first modal pump tick (ccall ProcessEvents {async:true}with the sleep still parked) traps on a null indirect call insideProcessEvents. Main's pump resolve-on-error catches it, cancels the modal (→library_manager.cpp(451) assert "table"), app survives, spec passes. Same null-call disease, caught instead of fatal.
- eeschema (
-
The fatal eeschema chain is deep and exception-wrapped: the fatal unwind's
exportCallStackis["dynCall_v","dynCall_iii"×5]— five re-entrantinvoke_iii→dynCall_iiihops (C++ try/catch glue) between the tick root and the fiber swap. The red-green harness'spost_park_fiber_swapALSO swaps from aCallAfterinside the parked tick and is GREEN — but its chain is shallow (no invoke hops, small frames). So the root alone is not sufficient; chain content matters. -
The crash is always post-rewind, in normally-executing code, through a pointer that should not be null — i.e. memory/state corruption planted during the unwind/rewind of these specific chains, not a JS-level machinery failure.
Symbolized fault (named build: ASYNCIFY_KEEP_NAMES=1)
Rebuilt eeschema/gerbview from the docker volume's pre-finalize wasm with the
name section kept, deployed over tests/apps/kicad/*.wasm (no JS rebuild), and
re-ran. The faults are now fully named.
eeschema (Backspace spec) — crash stack (.errors.log):
RuntimeError: indirect call to null
at wxWindowBase::SendIdleEvents(wxIdleEvent&)
at dynCall_iii / invoke_iii
at wxAppBase::ProcessIdle()
at ProcessEvents
at dynCall_v
at iterFunc ← the parked main loop's rAF tick
at callUserCallback → runIter → MainLoop_runner → requestAnimationFrame
…
at wxGUIEventLoop::DoRun() ← _emscripten_set_main_loop(ProcessEvents,0,1)
at wxEventLoopBase::Run() → wxAppConsoleBase::MainLoop() → OnRun()
So the crash is a null call_indirect inside wxWindowBase::SendIdleEvents,
on a clean rAF tick (dynCall_v → ProcessEvents → ProcessIdle), i.e. in the
parked main loop's continuation. State is Normal at the crash; the
corruption was planted earlier and detonates on the next idle walk.
The fatal cycle (named [DIAG_SWAPSTACK], unwind side): a normally
dispatched wx event drives a KiCad tool-coroutine fiber swap —
wxEvtHandler::ProcessEvent → EDA_BASE_FRAME::ProcessEvent
→ SCH_EDIT_FRAME::KiwayMailIn(KIWAY_EXPRESS&) → SCH_BASE_FRAME::SyncView()
→ TOOL_MANAGER::ResetTools(RESET_REASON) → CancelTool()
→ TOOL_MANAGER::processEvent → dispatchInternal → COROUTINE::Resume()
→ COROUTINE::jumpIn → jump_fcontext [emscripten_fiber_swap, state 0→1]
and the matching yield back —
SCH_SELECTION_TOOL::Main → TOOL_INTERACTIVE::Wait(TOOL_EVENT_LIST const&)
→ COROUTINE::jumpOut → jump_fcontext [fiber_swap]
… returning into: doRewind → finishContextSwitch → trampoline → maybeStopUnwind
→ dynCall_v → iterFunc (the rAF tick)
KiCad runs tools as libcontext coroutines; the wasm libcontext shim
(libcontext::wasm_fcontext_entry) maps each jump_fcontext to an
emscripten_fiber_swap. The "main caller" context (the fiber tools yield back
to) is buffer 9553280+20; rewind roots progress __main_argc_argv (pre-park)
→ dynCall_iii (event-dispatch ticks) → dynCall_v (the bare rAF tick). The
crash detonates right after a dynCall_v-rooted (rAF-tick) context is rewound
following a tool round-trip.
gerbview (faulted-but-passing) — same family, earlier trigger: a startup
modal (__asyncjs__startModal, the "no libraries" dialog) parks its sleep;
the modal pump's first ProcessEvents ({async:true}, sleep buffer still
in currData) hits the identical null call_indirect inside event/idle/paint
processing. Main's pump-resolve-on-error catches it → EndModal 5101 →
library_manager.cpp(451) assert "table" (a consequence of the forced
cancel, not the cause). App survives, spec passes — same disease, caught.
Refined reading: the disease is the documented "indirect call to null" asyncify-rewind family (07), surfacing in wx event/idle processing that runs while a suspension is in flight (parked main rAF tick, or a modal sleep). The KiCad tool-coroutine swap machinery and the wx event loop are byte-identical to the canvas port — so the DOM port either (a) moved a removelist'd (uninstrumented) function onto one of these suspend chains, or (b) the parked-main / object-lifetime interaction (dossier 02 §7 "de-park main") now gets exercised by the DOM port's boot/event timing where canvas didn't.
REFRAMING — it is NOT asyncify memory corruption
Two decisive experiments collapsed the hypothesis space:
-
ASYNCIFY_REMOVEis resource-untestable here, but also exonerated by evidence. A no-removelist build is unbuildable on this machine: with-O2it OOMs (instrument-everything is too big — exactly why the list exists); without-O2it won't load (CompileError: too many locals, V8's per-function limit — the very stall-O2cures). Independently, no removelisted function appears on any captured fatal swap chain, and the gerbview fault throws synchronously insideProcessEventswith no fiber-swap/sleep between ccall-enter and throw — a removelist gap can only corrupt during an unwind/rewind, so it cannot explain a synchronous throw. -
The wasm table is fully intact at the crash. A table-integrity monitor (diagnostics.js §12: baseline snapshot after boot + 60 ms scans for non-null→null slot flips and length changes) reports
baseline len=49105 nonNull=49104(only index 0, the conventional null) and zero flips and zero length changes through the crash. So the "indirect call to null" is not a nulled table slot and not an addFunction/asyncify table bug.
With the table intact, a null call_indirect is an index of 0 — and
wxWindowBase::SendIdleEvents (wincmn.cpp:2836) makes exactly these indirect
calls per window: OnInternalIdle() (virtual), HandleWindowEvent(event)
(virtual ProcessEvent + handler functors), then recurses child-> SendIdleEvents over GetChildren(). The null is a virtual call on a bad
wxWindow — a freed/dangling child still linked in the tree (its vtable slot
reads 0), or a half-constructed one — reached when the idle walk recurses into
it.
Working root cause
A use-after-free / dangling wxWindow in the window tree, detonated by the
idle-event tree walk that the parked main loop runs (ProcessEvents → wxAppBase::ProcessIdle → SendIdleEvents). The asyncify/coroutine activity is
the trigger/timing, not the corruption: a tool-coroutine round-trip (or
modal teardown) during ProcessEvents frees or invalidates a window, and the
ProcessIdle later in that same pump walks the now-dangling child. gerbview is
the same shape with a modal pump driving ProcessEvents over a tree perturbed
by the startup modal.
Ruled OUT, with evidence: de-park teardown (noExitRuntime=true), missing/мис-
injected shims (all present), EM_ASM re-entry (DIAG_ASMCONST silent), asyncify
buffer/SP corruption (clean alternation, matching SPs), wasm-table corruption
(monitor clean), ASYNCIFY_REMOVE gap (synchronous throw + no removed fn on
chains).
This relocates the bug from the asyncify substrate (where 01–07 lived) to
wxWindow lifetime under the parked-loop's re-entrant idle/event
processing — i.e. our wasm event-loop + DOM-window-lifecycle layer
(src/wasm/evtloop.cpp ProcessEvents/ProcessIdle, src/wasm/window.cpp
window destruction, the element registry), not Asyncify itself. The DOM port's
event/lifecycle timing opens the window the canvas port's did not.
Next experiments (need a kicad relink — not yet run)
- Build eeschema with
-sASSERTIONS=1(and/or-sSAFE_HEAP=1): turns the bare "indirect call to null" into a named bad-pointer / freed-window report and should fire AT the offendingwxWindow, naming its class + free site. - Audit
~wxWindowWasm/wxDomDestroyControlordering vs the wx child-list unlink (wxWindowBasedtorRemoveChild/DestroyChildren), and whether DOM teardown fires a synchronousfocusout/blurthat re-enterswx_dom_eventmid-destruction. - Check
ProcessEvents(evtloop.cpp) for re-entrancy: a setTimeout-driven modal/nested pumpProcessEventscan run while a rAF-tickProcessEventsis asyncify-suspended at a fiber swap, re-enteringProcessIdle/DeletePendingObjects(which frees windows) — classic UAF source. The pump has no re-entrancy guard.
Suspects under test (symbolized builds running)
- ASYNCIFY_REMOVE list (
scripts/common/apply-asyncify.sh): functions excluded from instrumentation (V8-size workaround, predates the committed post-asyncify-O2). An uninstrumented function on a suspend chain silently corrupts: it cannot save/restore its frame; its post-call code runs during the unwind and re-runs from the top during the rewind. The list is identical on main — the DOM port may have moved one of these onto a suspend chain (e.g.PCB_EDIT_FRAME::setupUIConditions,match,COLOR_SETTINGS::COLOR_SETTINGS,BuildBitmapInfo). - Two debug knobs added to
apply-asyncify.sh:ASYNCIFY_KEEP_NAMES=1(keep name section → named wasm stack traces) andASYNCIFY_REMOVE_OVERRIDE=""(instrument everything). Named eeschema + gerbview builds are being produced from the docker volume's pre-finalize artifacts to (a) name the fatal chain + crash site, (b) test whether an empty removelist cures the fault.
Pinpointing the window — live-set probe + the fix under test
Followed up with a C++ live-window probe (temp, in wincmn.cpp): std::unordered_set
of every wxWindowBase (insert in ctor, erase in dtor) + a ptr→class map recorded
while alive; checked at SendIdleEvents ENTRY (membership needs no deref, so it
flags a dead this reached as a top-level OR child window, in any vtable state).
Two results, both informative:
- The probe never fired — every window the idle walk enters is "alive" by ctor/dtor accounting. But this does not exonerate the window-tree hypothesis: a child-list entry pointing at freed-then-reused memory has its address back in the set (the new owner), so membership passes. The live-set is blind to address reuse.
- The crash signature flipped
indirect call to null→indirect call signature mismatchacross the rebuild (same spec, same path:rAF → dynCall_v(ProcessEvents) → … → trap). A stable structural null would not move; a function index read from freed memory does — null when the slot was zeroed, a valid-but-wrong-type index when the bytes were recycled.
Together these are the fingerprint of a use-after-free on reused memory: the
bad call_indirect index comes from a freed object whose contents shift with heap
layout (so adding instrumentation perturbs the symptom). This is exactly the
window-lifetime UAF of doc 09, and the reuse behavior reinforces — not weakens —
the early-detach fix: removing a window from its parent's GetChildren() list
before it can be freed and its address recycled removes the stale entry the idle
walk trips over.
Fix under test (wasm layer only — doc 09's first experiment):
src/wasm/window.cpp~wxWindowWasm: doSendDestroyEvent()(setsm_isBeingDeleted) andGetParent()->RemoveChild(this)FIRST, beforewxDomDestroyControl/UnregisterElement— so a synchronous browser focus/blur from DOM-node removal (or a parked-loop idle pass) can no longer reach this half-destroyed window through its parent.RemoveChildnullsm_parent, so~wxWindowBasewon't double-detach (verified in wincmn.cpp).src/wasm/domevents.cppwx_dom_event: ignore events whenwindow->IsBeingDeleted().
If still red after this, the remaining viable shapes (doc 09 §"If that fails")
are a duplicate child-list entry, re-entrant ProcessEvents/DeletePendingObjects,
or child-list iteration mutated mid-walk — and the right tool to name the exact
object becomes -fsanitize=address (defeats reuse-aliasing), resources permitting.
De-park hypothesis — KILLED
The generated JS sets noExitRuntime = Module["noExitRuntime"] || true
(eeschema.js:3560), so keepRuntimeAlive() is always true. When
_emscripten_set_main_loop(ProcessEvents,0,1) throws "unwind" to park, the
exception is swallowed by handleException (e == "unwind" → return
EXITSTATUS, js:1552) without running exit()/atexit/wxEntryCleanup
(maybeExit is gated on !keepRuntimeAlive()). So the park does NOT tear down
main's C++ objects — the dossier 02 §7 "throw orphans a live buffer / app torn
down under the browser loop" concern does not apply here. Corroborated by the
trace: ~25 event/idle ticks run cleanly after the park before the crash; a
free-at-park bug would fault on the first idle walk, not the 25th. The fault is
planted by a specific later suspend chain, not by the park itself.
Killed hypotheses
- Missing shim injection (all three shims verified present).
- Unwind escaping through EM_ASM DOM side effects (DIAG_ASMCONST silent in
all traces; the synchronous
focusin → dispatch → wx_dom_eventre-entry observed at boot runs entirely at state 0). - Buffer overwrite by overlapping activations (strict U/R alternation).
- Stack-pointer mismatch between unwind and rewind (SPs equal everywhere, fiber saved-SP slots consistent).