pcbjam/docs/features/async/01-background-and-findings.md
Viktor Vaczi 66ce367703 docs: asyncify currData + wasm-exceptions research dossiers
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>
2026-06-12 17:02:59 +02:00

5.7 KiB

01 — Background: the session, the test, and the three bugs

How we got here

The work began with: "load a real PCB design in the PCB editor … use the open menu inside KiCad and open it … create a test, make screenshots." That investigation lives in Claude session 3301ab3a-c457-4359-be9a-dcf4dcfc9fbc.jsonl (started 2026-05-28). It produced:

  • The e2e test: tests/kicad/load-pcb.spec.ts — drives File→Open, injects demo boards into MEMFS, waits for LoadBoard, screenshots. Parametrized over two demos: microwave (RF-polygon heavy) and pic_programmer (~698 KB real layout). Probe variant: tests/kicad/load-pcb-probe.spec.ts.
  • Logs: tests/logs/kicad/load-pcb/…microwave….log.
  • Findings docs: docs/docs/features/fix-asyncify-O2-and-modal-promise-rejection/rtree-debug-findings.md and …/clipboard-asyncify-findings.md.

Three distinct errors appeared along the way. They are easy to conflate but have different root causes.


Bug 1 — rtree Classify assert during LoadBoard()FIXED

A WASM-only 32-bit integer overflow in KiCad geometry: kicad/libs/kimath/src/geometry/shape_poly_set.cpp:1927 instantiated RTree<intptr_t, intptr_t, 2, intptr_t>. The 4th template arg (ELEMTYPEREAL) is the wide float type for RectSphericalVolume's sumOfSquares. Every other RTree in KiCad uses double; this one used intptr_t. On native (intptr_t==int64_t) the bug is latent; on wasm32 (intptr_t==int32_t) a microwave polygon's area (~1.79e14) wraps int32 to garbage (coverSplitArea = -2099823776), breaking PickSeedsseed0==seed1==0Classify(0,1) trips ASSERT(!m_taken[0]) at rtree.h:1771 → WASM Aborted() during load.

Fix (committed): intptr_tdouble.

  • kicad 07d8130d"shape_poly_set: use double instead of intptr_t for RTree ELEMTYPEREAL"
  • root 31ff88e"tests: load-pcb e2e for microwave + pic_programmer demos" (bumps submodule)

This bug is not part of the async story. Listed only so it isn't re-confused with the others.


Bug 2 — clipboard index out of bounds crash, after the board renders

Symptoms (Firefox): RuntimeError: index out of bounds (microwave) / indirect call to null (pic_programmer), preceded by [wxClipboard] Cannot check clipboard content: Clipboard operation timed out. The board is already fully painted; this is downstream noise that the load-pcb test deliberately filtered out.

Mechanism (verified against current code): wxClipboard::IsSupported() (wxwidgets/src/wasm/clipbrd.cpp:288-311) answers "is text available?" by calling js_clipboardHasText() (clipbrd.cpp:118-142), an EM_ASYNC_JS that does navigator.clipboard.readText() raced against a 2-second timeout. The just-loaded headless page has no clipboard permission, so each call suspends via Asyncify for the full 2 s. IsSupported is a synchronous-by-contract predicate that the UI-update / paste-enable path calls repeatedly, so these suspends stack (pendingSleeps→3 in the diagnostics) and, crossed by a modal teardown's fiber swap, the single Asyncify.currData slot is clobbered → doRewind(null/garbage) → crash.

The clipboard-asyncify-findings.md doc labels its fix "Applied," but the code is still buggywxwidgets HEAD 6fb2eac257 still calls the async IsSupported. The fix was designed but never committed.

Full control flow in 02-asyncify-internals.md §"The crash".


Bug 3 — the tool-open hang, after idle (the real blocker for rendering)

From the live trace (libcontext KICAD_DIAG_COROUTINE): the programmatic File→Open hangs inside the first tool fiber swap after startup. Trace ends at a jump-swap with no following entry-call/jump-resume — the swap unwinds but the target fiber never runs. Key observations from that session:

  1. The same coroutine completes many swap cycles during the startup burst, then the first swap after startup hangs.
  2. It is not specific to the open — on the fileless /p/mytest/eeschema route, a w keystroke after the startup burst triggers a fiber resume that hangs identically. So all post-idle tool interactivity is affected.
  3. At idle, measured Asyncify.state == Normal but Asyncify.currData set — i.e. a swap unwound and its rewind was never issued.

The session attributed this to the main loop "parking" main() in the single currData slot via wxwidgets/src/wasm/evtloop.cpp:107 emscripten_set_main_loop(ProcessEvents, 0, /*simulate_infinite_loop=*/1).

Important correction (see §"De-parking" in 02): simulate_infinite_loop=1 is not an Asyncify park — it is a plain throw "unwind" (pcbnew.js:11392) that discards the native main() stack. The dangling-currData-at-idle it produces is the leading suspect for the hang, but the precise mechanism (orphaned buffer vs. stuck trampoline guard) is an open empirical question answered by a one-line diagnostic.

There is also a related null-IsModal / windowClosing close crash: when beforeunload fires during the suspended-open window, the close path crashes on a null vtable slot. That is a lifecycle bug (teardown firing under the live loop), adjacent to the hang.


The connective insight

Bug 2 (crash) and Bug 3 (hang) are the same disease: one global currData/state register shared by three producers. Crash vs. hang is just which producer loses the race for the slot:

  • Crash: a long-parked sleep (the 2 s clipboard read) is clobbered by a fiber swap.
  • Hang: a fiber swap is stranded because the slot is dirtied/occupied (by the parked main loop's abnormal teardown), so its rewind is never issued.

Everything else in this dossier follows from that one observation.