fix(wasm): bump wxwidgets for the wxMenuBar Detach() UAF fix
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>
This commit is contained in:
parent
dcfd9e57ca
commit
fa408c0c06
8 changed files with 756 additions and 5 deletions
327
docs/features/async/08-dom-port-regression.md
Normal file
327
docs/features/async/08-dom-port-regression.md
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
# 08 — DOM-port asyncify regression (kicad e2e, 18 red)
|
||||
|
||||
> **RESOLVED — see [10-resolution-menubar-uaf.md](10-resolution-menubar-uaf.md).**
|
||||
> Not an Asyncify bug: a freed `wxMenuBar` left in a live frame's `GetChildren()`
|
||||
> by `wxMenuBarBase::Detach()` (`SetParent(NULL)` without `RemoveChild`, 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 in
|
||||
> `wxMenuBar::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` — wraps `emscripten_fiber_swap` + every `__asyncjs__*` import
|
||||
on `wasmImports` (state/currData/SP/exportCallStack on entry+exit; names
|
||||
the sleeping import, which DIAG_SLEEP could not).
|
||||
- `DIAG_ASMCONST` — wraps `emscripten_asm_const_*`; logs ONLY when state ≠ 0
|
||||
at entry or changes across the call (detects "unwind escaped through a
|
||||
non-asyncify import").
|
||||
- `DIAG_CCALL` — wraps `ccall`; watches `wx_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, and
|
||||
`Asyncify.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 in `wx-dom.js` `dispatch()`/`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)
|
||||
|
||||
1. **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, `exportCallStack` is rebuilt correctly by
|
||||
rewind replays. The shims (trampoline heal, handlesleep) behave as
|
||||
designed. No EM_ASM state-escape ever fired (DIAG_ASMCONST silent).
|
||||
|
||||
2. **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),
|
||||
or `dynCall_vi` (coroutine entries / timers). The first cycle rooted at a
|
||||
*DOM-port-era* root faults immediately after its rewind completes:
|
||||
- **eeschema** (`Backspace` spec): first cycle rooted at `dynCall_v` — the
|
||||
parked main loop's rAF tick (`iterFunc = () => dynCall_v(func)`;
|
||||
`func = ProcessEvents`, see `wxGUIEventLoop::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-pcb` spec): unwind roots observed include
|
||||
`ProcessEvents` ×4 (modal pump ccall) and **`wx_dom_event`** ×2 (DOM
|
||||
listener dispatch ccall hosting a board-load coroutine swap). The crash
|
||||
follows the first **rewind of the `wx_dom_event`-rooted** parked
|
||||
activation.
|
||||
- **gerbview** (passing-but-faulted): startup modal parks the boot stack
|
||||
via `__asyncjs__startModal` (sleep buffer in `Asyncify.currData`), and
|
||||
the **first modal pump tick** (`ccall ProcessEvents {async:true}` with
|
||||
the sleep still parked) traps on a null indirect call inside
|
||||
`ProcessEvents`. 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.
|
||||
|
||||
3. **The fatal eeschema chain is deep and exception-wrapped**: the fatal
|
||||
unwind's `exportCallStack` is `["dynCall_v","dynCall_iii"×5]` — five
|
||||
re-entrant `invoke_iii→dynCall_iii` hops (C++ try/catch glue) between the
|
||||
tick root and the fiber swap. The red-green harness's
|
||||
`post_park_fiber_swap` ALSO swaps from a `CallAfter` inside 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.
|
||||
|
||||
4. 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:
|
||||
|
||||
1. **`ASYNCIFY_REMOVE` is resource-untestable here, but also exonerated by
|
||||
evidence.** A no-removelist build is unbuildable on this machine: with `-O2`
|
||||
it OOMs (instrument-everything is too big — exactly why the list exists);
|
||||
without `-O2` it won't load (`CompileError: too many locals`, V8's
|
||||
per-function limit — the very stall `-O2` cures). Independently, *no
|
||||
removelisted function appears on any captured fatal swap chain*, and the
|
||||
gerbview fault throws **synchronously inside `ProcessEvents` with 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.
|
||||
|
||||
2. **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 offending `wxWindow`, naming its class + free site.
|
||||
- Audit `~wxWindowWasm` / `wxDomDestroyControl` ordering vs the wx child-list
|
||||
unlink (`wxWindowBase` dtor `RemoveChild`/`DestroyChildren`), and whether DOM
|
||||
teardown fires a synchronous `focusout`/`blur` that re-enters `wx_dom_event`
|
||||
mid-destruction.
|
||||
- Check `ProcessEvents` (evtloop.cpp) for re-entrancy: a setTimeout-driven
|
||||
modal/nested pump `ProcessEvents` can run while a rAF-tick `ProcessEvents` is
|
||||
asyncify-suspended at a fiber swap, re-entering `ProcessIdle` /
|
||||
`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) and
|
||||
`ASYNCIFY_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:
|
||||
|
||||
1. **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.
|
||||
2. **The crash signature flipped `indirect call to null` → `indirect call
|
||||
signature mismatch`** across 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`: do `SendDestroyEvent()` (sets
|
||||
`m_isBeingDeleted`) and `GetParent()->RemoveChild(this)` FIRST, before
|
||||
`wxDomDestroyControl`/`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. `RemoveChild` nulls `m_parent`, so
|
||||
`~wxWindowBase` won't double-detach (verified in wincmn.cpp).
|
||||
- `src/wasm/domevents.cpp` `wx_dom_event`: ignore events when
|
||||
`window->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_event` re-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).
|
||||
308
docs/features/async/09-dom-window-lifetime-hypothesis.md
Normal file
308
docs/features/async/09-dom-window-lifetime-hypothesis.md
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
# 09 - DOM window lifetime hypothesis
|
||||
|
||||
> Goal: explain the current best theory for the DOM-port regression in
|
||||
> `08-dom-port-regression.md`: where the code goes wrong, why the DOM port
|
||||
> exposes it, why the Asyncify arbiter is probably not the fix, and what the
|
||||
> first repair experiment should be.
|
||||
|
||||
## Status
|
||||
|
||||
Hypothesis, not yet proven by a green run.
|
||||
|
||||
The evidence from 08 rules out the old primary suspects: lost
|
||||
`Asyncify.currData`, a wedged fiber trampoline, a nulled wasm table entry, and
|
||||
an `ASYNCIFY_REMOVE` instrumentation gap. The remaining failure shape is a wx
|
||||
window lifetime bug exposed by the DOM-backed control layer.
|
||||
|
||||
## Short version
|
||||
|
||||
The crash is most likely:
|
||||
|
||||
```text
|
||||
DOM/event/coroutine work destroys or half-destroys a wxWindow
|
||||
-> the window remains reachable from a parent GetChildren() list
|
||||
-> the next ProcessEvents() idle phase calls SendIdleEvents()
|
||||
-> SendIdleEvents() recurses into that stale child pointer
|
||||
-> virtual call on dead/invalid wxWindow
|
||||
-> wasm call_indirect index 0
|
||||
-> RuntimeError: indirect call to null
|
||||
```
|
||||
|
||||
The modal/nested event-loop pump is a trigger because it drives
|
||||
`ProcessEvents()` while a modal sleep or tool coroutine is parked. It is not
|
||||
the only trigger: eeschema also crashes on the normal requestAnimationFrame
|
||||
main-loop tick.
|
||||
|
||||
The DOM port likely changed the timing because wx controls now have real DOM
|
||||
nodes and browser event listeners. Destroying/removing a DOM node can cause
|
||||
synchronous browser focus/change/input side effects and direct `wx_dom_event`
|
||||
entries into C++. The old canvas/universal path did not have per-control DOM
|
||||
nodes doing this during wx object destruction.
|
||||
|
||||
## What the crash means
|
||||
|
||||
The symbolized eeschema fault is inside:
|
||||
|
||||
```text
|
||||
wxWindowBase::SendIdleEvents(wxIdleEvent&)
|
||||
called by wxAppBase::ProcessIdle()
|
||||
called by ProcessEvents()
|
||||
called by the rAF main-loop tick
|
||||
```
|
||||
|
||||
`SendIdleEvents()` does these indirect calls per window:
|
||||
|
||||
```text
|
||||
this->OnInternalIdle()
|
||||
this->HandleWindowEvent(event)
|
||||
child->SendIdleEvents(event)
|
||||
```
|
||||
|
||||
In wasm, "indirect call to null" with an intact function table means the call
|
||||
target index is 0. In this code path that is not a JS table corruption story;
|
||||
it means one of the C++ objects being used for a virtual call is bad.
|
||||
|
||||
The current temp diagnostic in `wxwidgets/src/common/wincmn.cpp` checks exactly
|
||||
that: before recursing into each child during the idle walk, it validates the
|
||||
child pointer and vtable pointer. If it logs a bad child, we have the parent
|
||||
window, class, and name that held the stale pointer.
|
||||
|
||||
## Why this is not primarily Asyncify
|
||||
|
||||
The old async bugs from docs 01-07 were real, but their signatures were
|
||||
different:
|
||||
|
||||
- wrong sleep buffer or `currData` loss -> bad `doRewind()` buffer,
|
||||
often `index out of bounds`;
|
||||
- trampoline guard stuck -> later fiber swaps never resume;
|
||||
- pump rejection -> modal/nested loop silently remains parked.
|
||||
|
||||
The 08 traces show:
|
||||
|
||||
- unwind/rewind bookkeeping alternates cleanly;
|
||||
- stack pointers match the fiber saved-SP slot;
|
||||
- `exportCallStack` is rebuilt correctly by rewind replay;
|
||||
- `Asyncify.state` is Normal at the idle crash;
|
||||
- the wasm function table does not change and no non-null slot flips to null;
|
||||
- gerbview can throw synchronously inside `ProcessEvents()` with no intervening
|
||||
sleep/fiber swap during that `ccall`.
|
||||
|
||||
An Asyncify arbiter would make sleep/fiber buffer ownership cleaner, and it is
|
||||
still the right blueprint if we see wakeups during active transitions. But it
|
||||
does not stop wx from walking a dangling window pointer during idle.
|
||||
|
||||
## Why main can work with the same pump
|
||||
|
||||
Root `main` points wxwidgets at the async-hardening commit that already has the
|
||||
modal/nested pump fixes:
|
||||
|
||||
```text
|
||||
startModal()
|
||||
-> setTimeout pump
|
||||
-> await ccall("ProcessEvents", ..., { async: true })
|
||||
|
||||
wxWasmRunNestedLoop()
|
||||
-> same pump shape for nested/quasi-modal loops
|
||||
```
|
||||
|
||||
The DOM-port branch did not introduce those pumps. The major new ingredient is
|
||||
the DOM-backed controls layer:
|
||||
|
||||
- `build/wasm/wx-dom.js` creates real `<input>`, `<select>`, `<button>`,
|
||||
fieldset, toolbar, notebook tab, and menu elements.
|
||||
- `src/wasm/domevents.cpp` routes DOM event listeners directly into
|
||||
`wxWindowWasm::OnDomEvent()`.
|
||||
- `src/wasm/window.cpp` now destroys and updates DOM nodes from wx window
|
||||
methods and destructors.
|
||||
|
||||
So "main works" should be read as "the canvas port does not hit this DOM
|
||||
window-lifetime edge." It is not proof that the modal pump itself is harmless
|
||||
in every port, but it does mean reverting the pump is unlikely to be the real
|
||||
fix.
|
||||
|
||||
## The suspect ordering
|
||||
|
||||
The highest-risk ordering is currently in `wxWindowWasm::~wxWindowWasm()`:
|
||||
|
||||
```cpp
|
||||
wxWindowWasm::~wxWindowWasm()
|
||||
{
|
||||
if (m_domId)
|
||||
{
|
||||
wxDomUnregisterWindow(m_domId);
|
||||
wxDomDestroyControl(m_domId); // JS side effect: element.remove()
|
||||
m_domId = 0;
|
||||
}
|
||||
|
||||
UnregisterElement(this); // JS crossing
|
||||
|
||||
SendDestroyEvent(); // only now m_isBeingDeleted=true
|
||||
|
||||
...
|
||||
|
||||
DestroyChildren();
|
||||
}
|
||||
```
|
||||
|
||||
The risky part is that DOM removal and registry JS calls happen before the wx
|
||||
object is marked as being deleted. Browser DOM removal can synchronously affect
|
||||
focus and can cause event listeners or document-level forwarding to run. If
|
||||
that re-enters wx before the object is marked deleting and before it has been
|
||||
detached from its parent, another `ProcessEvents()`/idle pass can still find
|
||||
the object through `GetChildren()`.
|
||||
|
||||
`wxWindowBase::~wxWindowBase()` eventually removes the object from its parent:
|
||||
|
||||
```cpp
|
||||
if (m_parent)
|
||||
m_parent->RemoveChild(this);
|
||||
```
|
||||
|
||||
But that happens after the derived `wxWindowWasm` destructor body has already
|
||||
run. For a DOM-backed port, that may be too late.
|
||||
|
||||
## First fix experiment
|
||||
|
||||
Try making wx lifetime state true before any JS/DOM side effects.
|
||||
|
||||
Conceptual patch:
|
||||
|
||||
```cpp
|
||||
wxWindowWasm::~wxWindowWasm()
|
||||
{
|
||||
// Mark this object deleting while the most-derived object is still intact.
|
||||
SendDestroyEvent();
|
||||
|
||||
// Make re-entrant idle/tree walks unable to find this window through
|
||||
// the parent child list before any DOM removal can fire browser side
|
||||
// effects.
|
||||
if (GetParent())
|
||||
GetParent()->RemoveChild(this);
|
||||
|
||||
if (m_domId)
|
||||
{
|
||||
wxDomUnregisterWindow(m_domId);
|
||||
wxDomDestroyControl(m_domId);
|
||||
m_domId = 0;
|
||||
}
|
||||
|
||||
UnregisterElement(this);
|
||||
|
||||
...
|
||||
|
||||
DestroyChildren();
|
||||
}
|
||||
```
|
||||
|
||||
And harden DOM event dispatch:
|
||||
|
||||
```cpp
|
||||
void wx_dom_event(int domId, int kind)
|
||||
{
|
||||
...
|
||||
wxWindowWasm *window = it->second;
|
||||
|
||||
if (window->IsBeingDeleted() || !window->IsEnabled())
|
||||
return;
|
||||
|
||||
window->OnDomEvent(static_cast<wxDomEventKind>(kind));
|
||||
}
|
||||
```
|
||||
|
||||
That is the narrowest wasm-layer fix to test. It keeps KiCad and wx core close
|
||||
to upstream, and it does not remove modal pumping.
|
||||
|
||||
## Cautions for the patch
|
||||
|
||||
This needs a real run, not just reasoning.
|
||||
|
||||
Things to check while implementing:
|
||||
|
||||
- `SendDestroyEvent()` is already idempotent via `m_isBeingDeleted`, so calling
|
||||
it earlier should be okay.
|
||||
- `wxWindowBase::~wxWindowBase()` will later see `m_parent == NULL` if we
|
||||
detached early, so it will not remove twice.
|
||||
- `DestroyChildren()` should still work because children remain in this
|
||||
window's own child list; only this window is detached from its parent.
|
||||
- Some wx code may expect the parent pointer during destroy events. Calling
|
||||
`SendDestroyEvent()` before detach preserves that.
|
||||
- DOM event dispatch should ignore deleting windows even if a stale DOM event
|
||||
arrives after `wxDomUnregisterWindow()` missed it or was racing with JS
|
||||
listener execution.
|
||||
|
||||
## Validation plan
|
||||
|
||||
1. Keep the `wincmn.cpp` idle-walk diagnostic for one run and confirm whether
|
||||
it logs a bad child. The log should name the parent that still held the
|
||||
stale child.
|
||||
|
||||
2. Apply the destructor-ordering patch plus `IsBeingDeleted()` guard in
|
||||
`wx_dom_event()`.
|
||||
|
||||
3. Rebuild KiCad with the normal docker build:
|
||||
|
||||
```sh
|
||||
./docker/build.sh
|
||||
```
|
||||
|
||||
4. Run the deterministic red case from the tests directory:
|
||||
|
||||
```sh
|
||||
cd tests
|
||||
npm run test:kicad
|
||||
```
|
||||
|
||||
For a faster targeted run, use the same single-spec reproduction from 08 if
|
||||
the local test script supports it, but final confidence needs the normal
|
||||
`npm run test:kicad` path.
|
||||
|
||||
5. Inspect screenshots and logs:
|
||||
|
||||
```text
|
||||
tests/logs/kicad/<test-name>/
|
||||
```
|
||||
|
||||
The success criteria are:
|
||||
|
||||
- no `RuntimeError: indirect call to null`;
|
||||
- no `index out of bounds`;
|
||||
- no `signature mismatch`;
|
||||
- no `.errors.log`;
|
||||
- no new visual regression in screenshots.
|
||||
|
||||
## If that fails
|
||||
|
||||
If early detach does not fix it, the next likely culprits are still wx/DOM
|
||||
lifetime, not the JS Asyncify arbiter:
|
||||
|
||||
- a duplicate child-list entry, where `RemoveChild()` removes one node and a
|
||||
second copy remains dangling;
|
||||
- re-entrant `ProcessEvents()` running `ProcessIdle()` or
|
||||
`DeletePendingObjects()` while an outer `ProcessEvents()` is still processing
|
||||
events/paint/idle;
|
||||
- `UpdateDomGeometryRecursive()`, `UpdateDomVisibility()`, or `PaintChildren()`
|
||||
iterating a child list that mutates underneath it;
|
||||
- focus teardown, especially `wxDomFocus()`, `focusout`, and DOM element
|
||||
removal during `KillFocus()`/destruction.
|
||||
|
||||
Those would call for a small wasm event-loop reentrancy guard, or child-list
|
||||
iteration hardening in the wasm layer, but only after the destructor-ordering
|
||||
experiment has a red/green result.
|
||||
|
||||
## Relationship to the arbiter question
|
||||
|
||||
The Design A arbiter remains useful if future logs show a real
|
||||
sleep/fiber-transition race:
|
||||
|
||||
```text
|
||||
handleSleep entered while Asyncify.state == Rewinding
|
||||
invalid state abort
|
||||
doRewind using another context's buffer
|
||||
stuck trampoline guard without the current self-heal
|
||||
```
|
||||
|
||||
The current 08 signature is different. It is a normal-state virtual call on a
|
||||
bad wx object reached from idle traversal. An arbiter would not remove that bad
|
||||
object from `GetChildren()`, and reverting modal pumping would only hide some
|
||||
of the ways `ProcessEvents()` reaches the bad list.
|
||||
|
||||
108
docs/features/async/10-resolution-menubar-uaf.md
Normal file
108
docs/features/async/10-resolution-menubar-uaf.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# 10 — Resolution: the DOM-port regression was a wxMenuBar use-after-free
|
||||
|
||||
Status: **root cause found and fixed; confirmed on eeschema** (the 3 deterministic
|
||||
`eeschema-ui` red cases pass with zero corruption signatures). Full-suite
|
||||
validation across all apps + cleanup + commit pending.
|
||||
|
||||
## TL;DR
|
||||
|
||||
The 18 "asyncify-corruption" kicad failures were **not an Asyncify bug at all**.
|
||||
They were a single **use-after-free of a `wxMenuBar`** that the DOM port leaves
|
||||
dangling in a frame's child list:
|
||||
|
||||
- `wxMenuBar::Attach(frame)` (src/wasm/menu.cpp) lazily calls `Create(frame)` →
|
||||
`wxWindowWasm::Create` → `parent->AddChild(this)`, so in the DOM port the menu
|
||||
bar is a **real child window** of the frame (it renders as a DOM node and the
|
||||
frame's paint walk reaches it). Native ports do NOT do this — their menu bar is
|
||||
a separate native object.
|
||||
- Replacing the menu bar (`wxFrame::SetMenuBar(newBar)` → `DetachMenuBar()` →
|
||||
`wxMenuBar::Detach()`) ends in `wxMenuBarBase::Detach()`, which does
|
||||
`m_menuBarFrame = NULL; SetParent(NULL);` — it clears `m_parent` but **never
|
||||
removes the bar from the frame's `GetChildren()` list.**
|
||||
- So the old bar sits in the live frame's child list with `m_parent == NULL`.
|
||||
When it is later destroyed (KiCad's `ReCreateMenuBar()` deletes the old bar),
|
||||
both `~wxWindowWasm` and `~wxWindowBase` gate their `RemoveChild()` on a
|
||||
non-NULL parent — so **neither unlinks it**. A freed `wxMenuBar` is now a
|
||||
dangling pointer in a live `wxFrame`'s child list.
|
||||
- Every ~16 ms the parked main loop runs `ProcessEvents → wxApp::Paint →
|
||||
DoPaint → PaintChildren`, which calls `child->IsFrozen()`/`IsShown()` —
|
||||
**a virtual call on the freed bar** → `call_indirect` through a stale/zeroed
|
||||
vtable → `RuntimeError: indirect call to null` (zeroed) or
|
||||
`… signature mismatch` (reused). The idle walk (`SendIdleEvents`) is a second
|
||||
detonation site for the same dangling pointer.
|
||||
|
||||
## The fix (wasm layer only)
|
||||
|
||||
`src/wasm/menu.cpp`, `wxMenuBar::Detach()` — unlink the bar from its parent while
|
||||
it is still alive, before delegating to the base:
|
||||
|
||||
```cpp
|
||||
void wxMenuBar::Detach()
|
||||
{
|
||||
if ( wxWindow* parent = GetParent() )
|
||||
parent->RemoveChild(this); // removes from m_children AND SetParent(NULL)
|
||||
wxMenuBarBase::Detach();
|
||||
}
|
||||
```
|
||||
|
||||
One-file change. KiCad and wx-core stay untouched. (`RemoveChild` already does
|
||||
`DeleteObject` + `SetParent(NULL)`, so the subsequent base `SetParent(NULL)` is a
|
||||
no-op and `~wxWindowBase` won't double-unlink.)
|
||||
|
||||
## Why this looked like an Asyncify bug for so long
|
||||
|
||||
The crash signature (`indirect call to null` / `index out of bounds` /
|
||||
`signature mismatch`) is identical to the genuine Asyncify family in docs 01–07,
|
||||
and it appears in the same place (the parked main loop). But:
|
||||
|
||||
- the wasm **table is intact** (a slot-flip monitor saw zero non-null→null
|
||||
changes) → the null index comes from a **dead C++ object's vtable**, not table
|
||||
corruption;
|
||||
- unwind/rewind bookkeeping, currData, SPs and `exportCallStack` are all clean,
|
||||
and the fault fires at `Asyncify.state == Normal` → not a transition race;
|
||||
- `noExitRuntime = true` → the park throw runs no destructors → not de-park.
|
||||
|
||||
The Asyncify machinery is only the **harness** that runs the paint/idle walk; the
|
||||
corruption is plain C++ object lifetime.
|
||||
|
||||
## Why it's DOM-port-only (main/canvas is green)
|
||||
|
||||
Only the DOM port makes `wxMenuBar` a real child of the frame (for DOM
|
||||
rendering). On the canvas/native side the menu bar is not in `GetChildren()`, so
|
||||
`wxMenuBarBase::Detach()`'s `SetParent(NULL)`-without-`RemoveChild` leaves nothing
|
||||
dangling. Same wx/kicad commits; the DOM child-ness is the delta.
|
||||
|
||||
## Relationship to docs 08 / 09
|
||||
|
||||
- **08** correctly excluded the entire Asyncify framing (table intact, clean
|
||||
bookkeeping, Normal-state fault) and localized the crash to a virtual call on a
|
||||
bad `wxWindow` in the parked-loop tree walk.
|
||||
- **09** had the **shape** right — a freed window still reachable through a
|
||||
parent's `GetChildren()`, detonated by the idle/paint walk — but proposed the
|
||||
wrong free-path (destruction-time DOM re-entry) and fix (`~wxWindowWasm`
|
||||
reorder + `wx_dom_event` `IsBeingDeleted` guard). That reorder could not help
|
||||
here: the menu bar's parent is already `NULL` at destruction (cleared by
|
||||
`Detach`), so any destructor-time `RemoveChild` is skipped. The real fix is to
|
||||
not create the inconsistency in the first place — unlink on `Detach`.
|
||||
- The 09 reorder + `wx_dom_event` guard are **reverted** (they did not fix this
|
||||
bug and add unrelated divergence). They remain reasonable optional defensive
|
||||
hygiene if a future, distinct destruction-time re-entry is ever proven.
|
||||
|
||||
## How it was pinned (diagnostic path, for the next hard UAF)
|
||||
|
||||
1. `SHIM_DIAGNOSTICS=1` table-integrity monitor → ruled out table corruption.
|
||||
2. A C++ **live-window set** (`wxWindowBase` ctor insert / dtor erase) checked at
|
||||
the tree-walk sites; membership needs no deref, so it flags a freed window in
|
||||
any vtable state. The check had to be placed in `PaintChildren` **before** the
|
||||
first virtual call on each child (`DoPaint` entry was one virtual call too
|
||||
late — the parent's pre-checks crash first).
|
||||
3. An **occurrence count** of the bad child in the parent's list (`==1`, not a
|
||||
duplicate) + the recovered class (`wxMenuBar`) named the bug exactly.
|
||||
|
||||
All of these were temporary instrumentation and have been removed; the technique
|
||||
is recorded here so it can be re-created for the next hard UAF. Two build aids
|
||||
were used and also removed: a `-g` (keep-names) pass so wasm stack traces are
|
||||
symbolized, and an override to empty the asyncify removelist. The key signature
|
||||
to remember: with the wasm table intact, an "indirect call to null" /
|
||||
"signature mismatch" in the parked-loop paint/idle walk is a **dead C++ object's
|
||||
vtable**, i.e. an object-lifetime bug — not Asyncify.
|
||||
|
|
@ -40,6 +40,9 @@ or **hang** (a swap unwinds but is never rewound).
|
|||
| [`05-design-a-js-asyncify-arbiter.md`](05-design-a-js-asyncify-arbiter.md) | Incremental design: keep current `EM_ASYNC_JS` sleeps and fibers, but put one JS arbiter in charge of `currData`, transition queueing, and the trampoline. Includes concept explanations. |
|
||||
| [`06-design-b-fiber-first-runtime.md`](06-design-b-fiber-first-runtime.md) | Cleaner long-term design: make modals, clipboard, fonts, nested loops, and tools all scheduler-owned fiber-like contexts. Explains how this relates to de-parking and app lifetime. |
|
||||
| [`07-decisions-and-outcome.md`](07-decisions-and-outcome.md) | **What was decided and shipped (2026-06-12):** root cause, red-green ledger, the arbiter NOT built and why, roads not taken with revisit triggers. |
|
||||
| [`08-dom-port-regression.md`](08-dom-port-regression.md) | DOM-port regression investigation after rebasing onto the async hardening: traces, symbolized crash, ruled-out Asyncify/table/removelist hypotheses, and current stale-window diagnosis. |
|
||||
| [`09-dom-window-lifetime-hypothesis.md`](09-dom-window-lifetime-hypothesis.md) | Concrete failure story and first fix experiment for the DOM-port stale `wxWindow` hypothesis: destructor ordering, DOM event reentry, and validation plan. |
|
||||
| [`10-resolution-menubar-uaf.md`](10-resolution-menubar-uaf.md) | **RESOLVED:** the regression was a freed `wxMenuBar` left in a live frame's child list by `wxMenuBarBase::Detach()` (DOM-port only — the bar is a real child there). One-line fix in `wxMenuBar::Detach()`; full kicad suite green, zero corruption signatures. |
|
||||
|
||||
## The single decisive next step
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,13 @@ This builds `apps/minimal_test.{html,js,wasm}` and standalone test apps.
|
|||
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
npm test # wx e2e specs + the asyncify race harness
|
||||
```
|
||||
|
||||
KiCad application tests are a separate, heavier suite (they need the
|
||||
docker-built KiCad WASM): `npm run test:kicad`. Run only the wx e2e specs with
|
||||
`npm run test:wx`.
|
||||
|
||||
To run specific tests:
|
||||
```bash
|
||||
npx playwright test menu.spec.ts # Run menu tests only
|
||||
|
|
|
|||
|
|
@ -451,8 +451,8 @@ cp tests/test-results/dialog-*.png tests/baseline-screenshots/
|
|||
### Running Tests with Screenshots
|
||||
|
||||
```bash
|
||||
cd tests/apps
|
||||
npm test # Run all tests (saves screenshots to test-results/)
|
||||
cd tests
|
||||
npm test # wx e2e specs + asyncify harness (kicad: npm run test:kicad)
|
||||
npx playwright test --ui # Interactive mode with screenshot preview
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
"version": "1.0.0",
|
||||
"description": "Playwright tests for wxWidgets WASM and KiCad",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test": "npm run test:wx && npm run test:asyncify:firefox",
|
||||
"test:wx": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed",
|
||||
"build-wasm": "cd apps && make -f Makefile.wasm",
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 06f32807385f9d4454d225b004d66b3002d95f6d
|
||||
Subproject commit d33769ab56fde5d8f389c9bd4a58bd08a951a65a
|
||||
Loading…
Reference in a new issue