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>
This commit is contained in:
parent
0cbb6738ec
commit
66ce367703
16 changed files with 3252 additions and 0 deletions
107
docs/features/async/01-background-and-findings.md
Normal file
107
docs/features/async/01-background-and-findings.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# 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 `PickSeeds` → `seed0==seed1==0` →
|
||||
`Classify(0,1)` trips `ASSERT(!m_taken[0])` at `rtree.h:1771` → WASM `Aborted()` during load.
|
||||
|
||||
**Fix (committed):** `intptr_t` → `double`.
|
||||
- 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
|
||||
> buggy** — `wxwidgets` HEAD `6fb2eac257` still calls the async `IsSupported`. The fix was
|
||||
> designed but never committed.
|
||||
|
||||
Full control flow in [`02-asyncify-internals.md`](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.
|
||||
320
docs/features/async/02-asyncify-internals.md
Normal file
320
docs/features/async/02-asyncify-internals.md
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
# 02 — The machine: Asyncify internals and control flows
|
||||
|
||||
This is the legible model: what suspends, who owns the single slot, and exact line-by-line
|
||||
control flow for the park, the hang, the crash, and **de-parking**.
|
||||
|
||||
---
|
||||
|
||||
## §0. One-paragraph model
|
||||
|
||||
Emscripten Asyncify has **two global registers**: `Asyncify.state`
|
||||
(`Normal=0 / Unwinding=1 / Rewinding=2`) and `Asyncify.currData` (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
|
||||
because **three subsystems drive those same two registers**: tool coroutines (libcontext →
|
||||
`emscripten_fiber_swap`), modal dialogs + clipboard (`EM_ASYNC_JS` → `handleSleep`), and the
|
||||
parked main loop. Overlap on the single slot → one context reads a buffer that is no longer its
|
||||
own → crash or hang.
|
||||
|
||||
---
|
||||
|
||||
## §1. What "sleeps" and what does not
|
||||
|
||||
"Sleep" = an **Asyncify suspension**: the wasm stack is *unwound* into a buffer, control returns
|
||||
to JS, JS runs, then the stack is *rewound*. The only wasm exports involved are
|
||||
`_asyncify_start_unwind / stop_unwind / start_rewind / stop_rewind` (`pcbnew.js:15532-15538`);
|
||||
**all scheduling is 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:76` |
|
||||
| `js_writeTextToClipboard` / `js_clearClipboard` | **YES** | `EM_ASYNC_JS` | `clipbrd.cpp:38`, `:145` |
|
||||
| `js_enumerateFonts` | **YES** | `EM_ASYNC_JS` | `fontenum.cpp:33` |
|
||||
| `js_isClipboardAPIAvailable` / `js_isFontAccessAPIAvailable` | **NO** | synchronous `EM_JS` capability probe | `clipbrd.cpp:29`, `fontenum.cpp:25` |
|
||||
| `emscripten_async_call`, `emscripten_async_run_in_main_runtime_thread` | **NO** | timer/main-thread dispatch, not a suspend | `timer.cpp:80`, `utils.cpp:162/183` |
|
||||
| `ProcessEvents` (one main-loop tick) | **NO** by itself | plain C call on a fresh stack; sleeps only if something *inside* 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 (§7).
|
||||
|
||||
The complete inventory of EM_ASYNC_JS "sleep" sites is **6**, all in `wxwidgets/src/wasm/`
|
||||
(the KiCad tree has **zero** EM_ASYNC_JS / `emscripten_sleep`): `dialog.cpp:201`,
|
||||
`clipbrd.cpp:38/76/118/145`, `fontenum.cpp:33`. This is why a fix can stay in the wasm layer.
|
||||
|
||||
---
|
||||
|
||||
## §2. The three suspension producers
|
||||
|
||||
### 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` (`:287`); `jump_fcontext` →
|
||||
`emscripten_fiber_swap` (`:320`). So **"a tool fiber swap" is literally `emscripten_fiber_swap`,
|
||||
which drives `Asyncify.currData`/`state`.** Each `wasm_fcontext` embeds its **own** asyncify
|
||||
buffer (`libcontext.cpp:98` `char asyncify_stack[64*1024]`, `ASYNCIFY_STACK_SIZE` at `:34`),
|
||||
bound at `emscripten_fiber_init` (`:287`); the main stack is fiberized via
|
||||
`emscripten_fiber_init_from_current_context` (`:210-212`). libcontext keeps its own
|
||||
`g_current_context`, a per-context `resume_epoch` to detect "ghost resumes" (`:323`), and a
|
||||
`[[noreturn]]` trampoline `wasm_fcontext_entry` (`:228`) that loops forever so a finished
|
||||
coroutine swaps back instead of returning. `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`.
|
||||
|
||||
### 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 calling `ProcessEvents` so the UI stays live, and
|
||||
resolves when `EndModal` (`dialog.cpp:286`) fires `Module._endModal(code)`. **The modal is,
|
||||
structurally, a coroutine on `handleSleep`.** The old clipboard `IsSupported` (`clipbrd.cpp:288`)
|
||||
used the same road.
|
||||
|
||||
### 2c. The main loop "park"
|
||||
`wxGUIEventLoop::DoRun` (`evtloop.cpp:85`) ends with
|
||||
`emscripten_set_main_loop(ProcessEvents, 0, /*simulate_infinite_loop=*/1)` (`:107`). Dissected
|
||||
in §4 and §7.
|
||||
|
||||
---
|
||||
|
||||
## §3. The Asyncify engine (the JS glue)
|
||||
|
||||
### handleSleep — the EM_ASYNC_JS / sleep road (`pcbnew.js:10160`)
|
||||
- First entry, `state==Normal`: call `startAsync(wakeUp)`. If `wakeUp` is not called
|
||||
synchronously, a real suspend begins (`:10219`):
|
||||
`state=Unwinding; currData = allocateData(); MainLoop.pause(); start_unwind()`.
|
||||
- Promise resolves → `wakeUp(result)` (`:10169`):
|
||||
`state=Rewinding; start_rewind(currData); MainLoop.resume(); doRewind(currData)`.
|
||||
`doRewind` reads **field #8 of the buffer** to learn *which exported function to re-enter*
|
||||
(`getDataRewindFuncName`, `:10143`). **If `currData` is wrong or null here → garbage →
|
||||
`RuntimeError: index out of bounds`.**
|
||||
- Re-entry at `state==Rewinding` (`:10229`): `state=Normal; stop_rewind(); free(currData);
|
||||
currData=null`.
|
||||
|
||||
> Asymmetry: the sleep road pauses/resumes `MainLoop` (`:10225`, `:10180`). The fiber road does
|
||||
> **not** touch `MainLoop`.
|
||||
|
||||
### fiber swap — the coroutine road (`pcbnew.js:11557`)
|
||||
```js
|
||||
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`),
|
||||
invoked from **`maybeStopUnwind`** (`:10097`) once the unwind reaches bottom
|
||||
(`exportCallStack.length===0`) — `maybeStopUnwind` also `runtimeKeepalivePush()`es (`:10105`):
|
||||
```js
|
||||
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:**
|
||||
1. `finishContextSwitch` re-enters wasm (`doRewind`/`dynCall_vi`). If that re-entered code
|
||||
itself unwinds before returning, the `do/while` is abandoned with `trampolineRunning===true`
|
||||
(the reset never runs). **Every future `Fibers.trampoline()` then fails the guard** → pending
|
||||
`nextFiber` never processed → **hang.** (This is the facet the pasted `try/finally`
|
||||
"self-heal" targets.)
|
||||
2. fiber buffers come from `emscripten_fiber_init`, **not** `Asyncify.allocateData`, so the
|
||||
`handlesleep.js` shim is **blind to them.**
|
||||
|
||||
### The #9153 shim — what `handlesleep.js` is, and where it comes from
|
||||
`tests/apps/kicad/pcbnew.js` is **generated** (emscripten link, then post-processed; committed
|
||||
but overwritten by every build). The handleSleep override is **not** Emscripten's — its source
|
||||
of truth is **`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 (~`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` — source of the `[CLIP-DIAG]`/`[DIAG_SLEEP]` log
|
||||
lines). The trampoline self-heal is **not present today.** The asyncify pass itself
|
||||
(`apply-asyncify.sh`) uses imports `env.invoke_*,env.__asyncjs__*,env.emscripten_fiber_swap`
|
||||
(`:33`), a large-function removelist (`:37-50`), then a `-O2` shrink pass (`:72`).
|
||||
|
||||
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/...` (abandoned, *not* asyncify-saved, *no* C++ destructors) 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()` (and `wxEntryReal`/`OnRun`/`DoRun`) is **gone**.
|
||||
- **`CallOnExit::~CallOnExit()` (→ `OnExit()`) and `wxEntryCleanupReal()` NEVER RUN** — the app,
|
||||
frames, and tools stay alive. *This is the entire purpose of `simulate_infinite_loop=1`.*
|
||||
- The runtime stays alive purely via the keepalive counter (★). Each rAF tick 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`)
|
||||
pumps `ProcessPendingEvents` + `Paint` + every-third `ProcessIdle`, then returns. **No sleep
|
||||
in a quiet tick.**
|
||||
|
||||
---
|
||||
|
||||
## §5. Control flow — the HANG (first tool interaction after startup)
|
||||
|
||||
```
|
||||
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 …
|
||||
```
|
||||
Healthy idle ends with `currData == null`. The measured bug state was **`state==Normal` but
|
||||
`currData != null`** — an unwind happened, its rewind was never issued; the swap parks forever;
|
||||
all post-idle tool interactivity dies.
|
||||
|
||||
**Why the *first post-startup* swap?** Two credible mechanisms, both rooted in §4's `throw
|
||||
"unwind"` (not mutually exclusive):
|
||||
|
||||
1. **Dangling `currData` from the abnormal teardown.** The throw abandons the C stack **without**
|
||||
running `stop_unwind/stop_rewind` or resetting the Asyncify globals. If the startup burst left
|
||||
an in-flight / half-settled fiber context at the moment `DoRun` threw, `currData` stays
|
||||
non-null into idle. The next `emscripten_fiber_swap` enters the `state==Normal` branch and
|
||||
**overwrites** `currData` with `old+20`, orphaning the dangling buffer; the orphan can never
|
||||
be rewound → hang.
|
||||
2. **Stuck trampoline guard.** If a swap inside `Fibers.trampoline`'s `do/while` unwound and never
|
||||
returned (§3 fragility #1), `trampolineRunning` is stuck `true`, so the first post-startup
|
||||
swap's `nextFiber` is scheduled but the trampoline early-returns → hang.
|
||||
|
||||
> **Decisive diagnostic (cheap, no rebuild — JS-only):** log `Asyncify.currData`,
|
||||
> `Asyncify.state`, `Fibers.trampolineRunning` at (a) the last line of `DoRun` *before* the
|
||||
> throw, (b) the first rAF tick, (c) the entry of the first post-startup `emscripten_fiber_swap`.
|
||||
> Compare `currData` against `g_main_context`'s buffer and any live coroutine's `fiber+20`. That
|
||||
> tells you which of #1/#2 (or both) is in play — and whether a `currData` authority alone fixes
|
||||
> it or de-parking is required.
|
||||
|
||||
---
|
||||
|
||||
## §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: a long-parked **sleep** clobbered by a **fiber swap** (crash) vs.
|
||||
in §5 a **fiber swap** stranded by a dirtied slot (hang).
|
||||
|
||||
---
|
||||
|
||||
## §7. "De-parking" in finest detail
|
||||
|
||||
**What it 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. Asyncify globals
|
||||
are left in the clean state ordinary C++ returns produce — removing mechanism §5#1 at the source.
|
||||
|
||||
**The lifecycle trap it creates (why `=1` exists):** if `DoRun` returns, the C++ unwind runs the
|
||||
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 crash).
|
||||
```
|
||||
So the runtime survives (keepalive), but the app is torn down under the still-firing 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.**
|
||||
|
||||
**A correct de-park is therefore two coupled changes:**
|
||||
1. `emscripten_set_main_loop(..., 0)` so `DoRun` returns with clean Asyncify state, **and**
|
||||
2. **suppress the destructive post-`MainLoop` teardown** so the live app isn't freed. Options:
|
||||
- **(2a)** a wasm-specific `OnRun`/event-loop path whose return does *not* fall into
|
||||
`~CallOnExit`/`wxEntryCleanupReal`, with real cleanup driven from **`UnloadCallback`**
|
||||
(`app.cpp:620`, registered `:694`) on `beforeunload`.
|
||||
- **(2b)** keep `wxEntryReal` but guard `wxEntryCleanupReal`/`OnExit` to no-op while the rAF
|
||||
loop is registered (a "main loop owns lifetime" flag), deferring real cleanup to unload.
|
||||
Either way the rAF loop becomes the sole owner of app lifetime; `ScheduleExit`
|
||||
(`evtloop.cpp:40`, `emscripten_cancel_main_loop`) is the one teardown path.
|
||||
|
||||
**What de-parking fixes / doesn't:**
|
||||
- **Fixes:** the dangling-`currData`-at-idle mechanism (§5#1) and the null-`IsModal` close crash.
|
||||
- **Does not, by itself, fix:** the fundamental slot-sharing — two genuinely overlapping
|
||||
suspensions still contend for one `currData`. De-parking removes the *base occupant*; it does
|
||||
not make concurrent suspensions compose. The trampoline-guard facet (§5#2) is independent.
|
||||
|
||||
**Cost:** `evtloop.cpp` + `app.cpp`/`init.cpp` change → full wx rebuild + relink, touches
|
||||
shutdown. A prior `CallAfter`-deferral of the call site was tried and reverted — evidence the
|
||||
problem is the *abandoned-unwind topology*, not the timing of when the loop is installed.
|
||||
|
||||
---
|
||||
|
||||
## §8. Why we can't just "make the modal not sleep"
|
||||
|
||||
`ShowModal()` must hand KiCad a **blocking `int`** (native semantics; rewriting 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 buys nothing (fibers are the same `currData` machine). The durable answer is to make
|
||||
suspensions **compose**, i.e. fix the slot — see [`03-solutions-and-prior-art.md`](03-solutions-and-prior-art.md).
|
||||
123
docs/features/async/03-solutions-and-prior-art.md
Normal file
123
docs/features/async/03-solutions-and-prior-art.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# 03 — Is there a real solution? Per-context buffers and prior art
|
||||
|
||||
## §1. Yes — one data buffer per context
|
||||
|
||||
**The single global slot is NOT a WebAssembly or Binaryen law. It is a choice in Emscripten's
|
||||
high-level JS runtime.** "Store one for each place; each unwind/rewind pair has its own data
|
||||
slot" is the standard, supported solution.
|
||||
|
||||
- **The Binaryen Asyncify pass is multi-buffer by design.** `asyncify_start_unwind(dataPtr)` and
|
||||
`asyncify_start_rewind(dataPtr)` *take the buffer pointer as an argument* — the pass has no
|
||||
global "current buffer." Alon Zakai's Asyncify writeup: *"In this example we have just one such
|
||||
structure … to implement something like coroutines **you would use one data structure for
|
||||
each.**"*
|
||||
- **The single slot lives in Emscripten's `libasync.js`**, as global `Asyncify.currData` plus the
|
||||
guard in `whenDone()`: `assert(!Asyncify.asyncPromiseHandlers, 'cannot have multiple async
|
||||
operations in flight at once')`. The docs state the rule plainly: *"It is not safe to start an
|
||||
async operation while another is already running."* That guard, not the wasm, is what we trip.
|
||||
|
||||
**We already implement per-context buffers — for fibers.** Each tool coroutine is a
|
||||
`wasm_fcontext` embedding its **own** asyncify buffer (`libcontext.cpp:98`,
|
||||
`char asyncify_stack[64*1024]`); `emscripten_fiber_t` itself carries an `asyncify_data` per
|
||||
fiber. So the coroutine half is *already* "one slot per place." **The bug is not missing buffers
|
||||
— two subsystems blindly write the one global `currData`:** fiber swaps set `currData = fiber+20`
|
||||
(`pcbnew.js:11549/11565`); `handleSleep` sets `currData = allocateData()` (a fresh malloc,
|
||||
`:10223`). When a fiber swap happens *inside* a sleep's await (modal `startModal` pumps
|
||||
`ProcessEvents`, which yields a tool coroutine), the swap overwrites the sleep's buffer pointer →
|
||||
crash; or a parked context's pointer is lost → hang. **`handlesleep.js` is a one-level patch of
|
||||
exactly this.**
|
||||
|
||||
**The hard rule that makes the fix tractable:** with Asyncify only **one** context may be
|
||||
*actively* unwinding/rewinding at any instant (the Normal/Unwinding/Rewinding state machine is
|
||||
per-module-instance), but **N contexts may be PARKED simultaneously**, each frozen in its own
|
||||
buffer, resumable in any order. Single-threaded cooperative scheduling means one-active-at-a-time
|
||||
is all we ever need. The cure is **never losing a parked context's buffer pointer.** Fibers do
|
||||
that by storing the pointer in the fiber struct; the fix is to give the sleep side the same
|
||||
discipline under one authority.
|
||||
|
||||
**Why raw nested `handleSleep` can never work (Emscripten #9153, WONTFIX):** a `start_unwind`
|
||||
serializes the *entire* live C stack from that point to the top into *one* buffer. You cannot
|
||||
unwind inner frame `g` while keeping outer frame `f` alive — one live C stack per instance. To
|
||||
make `f` and `g` independently suspendable they must live on **separate fibers (separate C stacks
|
||||
+ separate buffers).** This is the architectural reason the modal (a `handleSleep` over the whole
|
||||
stack) and the tool coroutine inside it (its own fiber) are the colliding pair.
|
||||
|
||||
## §2. Prior art — who has built "many suspended stacks" on Asyncify
|
||||
|
||||
- **Emscripten Fibers** (PR #9859, by *Akaricchi*, merged by *kripken*, 2020) — the official
|
||||
mechanism: one `asyncify_data` per fiber; `Asyncify.currData` is the transient "which fiber is
|
||||
moving this microsecond" pointer. Docs: fibers "supersede the legacy coroutine API" and are "a
|
||||
building block for asynchronous control flow constructs, such as coroutines."
|
||||
- **QEMU wasm port** — `CoroutineEmscripten` allocates its own `asyncify_stack` per coroutine and
|
||||
delegates switching to `emscripten_fiber_swap`. In production for virtio/block I/O.
|
||||
- **TinyGo** — goroutines on wasm via Asyncify, one buffer per goroutine, a JS scheduler
|
||||
trampoline that rewinds the next ready goroutine.
|
||||
- **Pyodide / CPython** — used Asyncify for `run_sync` and hit the single-operation reentrancy
|
||||
wall (keyboard-interrupt-during-await left the promise pending forever, #2141).
|
||||
|
||||
So "store one per place" is the textbook answer, shipped by several serious C/C++ runtimes.
|
||||
|
||||
## §3. We are not switching to JSPI
|
||||
|
||||
JSPI (the browser-native stack-switching alternative to Asyncify) is **out of scope** — we are
|
||||
staying on Asyncify. In short, it does not fit this codebase: it cannot replace the *intra-wasm*
|
||||
`emscripten_fiber_swap` tool coroutines (they cross no JS boundary), it is incompatible with
|
||||
`emscripten_set_main_loop` (our entire architecture), and combining it with our
|
||||
pthreads/`PROXY_TO_PTHREAD` build is unsupported. A prior session reached the same conclusion in
|
||||
`research/threading_2.md`. The fix therefore stays entirely within Asyncify, in the wasm/shim
|
||||
layer.
|
||||
|
||||
## §4. The achievable fix for us — a unified per-context `currData` authority
|
||||
|
||||
This generalizes `handlesleep.js`. It fits our fibers + parked-main-loop architecture, is
|
||||
supported by the current toolchain, and stays entirely in the **wasm/shim layer** (no KiCad
|
||||
changes).
|
||||
|
||||
**Maintain one registry of suspension contexts**, each entry = `{ dataPtr, rewindId, kind:
|
||||
fiber|sleep, status }`. Make that registry the *single authority* that owns `Asyncify.currData`:
|
||||
at every unwind/rewind transition, set `currData` from the context being resumed — never let a
|
||||
raw `fiber_swap` or `handleSleep` write it blindly.
|
||||
|
||||
**Two hook points (already identified in our tree):**
|
||||
- **Fiber-swap path:** `_emscripten_fiber_swap` (`pcbnew.js:11557`, source `libcontext.cpp:321`).
|
||||
Each coroutine already owns its buffer (`wasm_fcontext::asyncify_stack`), so here the job is to
|
||||
*register/track*, not allocate.
|
||||
- **handleSleep path:** `Asyncify.handleSleep`/`allocateData` (`pcbnew.js:10121-10241`). The 6
|
||||
EM_ASYNC_JS sleep sites that funnel through it: `dialog.cpp:201` (`startModal`),
|
||||
`clipbrd.cpp:38/76/118/145`, `fontenum.cpp:33`.
|
||||
|
||||
**Failure modes the authority must respect (from QEMU/TinyGo/Pyodide experience):**
|
||||
1. **Wrong buffer/entry on rewind** → always `doRewind` via the buffer's interned `rewind_id`,
|
||||
never a hardcoded export.
|
||||
2. **Use-after-free** → never free a context's buffer while it is still parked; only on
|
||||
completion/cancel. (Today `handleSleep` `_free`s at `pcbnew.js:10233`; a unified owner must not
|
||||
free a buffer another context still references.)
|
||||
3. **Buffer too small** → traps with `unreachable`; sizes are 64 KB (fiber) / `StackSize` (sleep)
|
||||
— keep generous.
|
||||
4. **Out-of-order resolution + shared globals** → buffers are independent, but the C heap/wasm
|
||||
globals are shared; resuming in a different order than parked can violate invariants (ordinary
|
||||
coroutine reentrancy hazard, not asyncify-specific).
|
||||
5. **`wakeUp` re-entering wasm mid-rewind** → fire resumptions from a clean JS stack
|
||||
(`setTimeout(wakeUp,0)`) so a Promise `.then` can't start a rewind while another is in flight.
|
||||
|
||||
How this composes with de-parking and the clipboard fix is in
|
||||
[`04-decisions-tests-open-questions.md`](04-decisions-tests-open-questions.md).
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
**Asyncify internals / fibers / prior art**
|
||||
- Kripken: Pause and Resume WebAssembly with Binaryen's Asyncify — https://kripken.github.io/blog/wasm/2019/07/16/asyncify.html
|
||||
- Binaryen Asyncify.cpp — https://github.com/WebAssembly/binaryen/blob/main/src/passes/Asyncify.cpp
|
||||
- Emscripten fiber.h — https://github.com/emscripten-core/emscripten/blob/main/system/include/emscripten/fiber.h , docs https://emscripten.org/docs/api_reference/fiber.h.html
|
||||
- Emscripten PR #9859 (Fibers API) — https://github.com/emscripten-core/emscripten/pull/9859
|
||||
- Issue #9153 (nested asyncify, WONTFIX) — https://github.com/emscripten-core/emscripten/issues/9153
|
||||
- Issue #16291 / #18412 ("cannot have multiple async operations in flight") — https://github.com/emscripten-core/emscripten/issues/16291
|
||||
- QEMU wasm coroutine backend — https://www.mail-archive.com/qemu-devel@nongnu.org/msg1113010.html
|
||||
- TinyGo goroutines — https://aykevl.nl/2019/02/tinygo-goroutines
|
||||
- Pyodide issue #2141 (run_sync reentrancy wall) — https://github.com/pyodide/pyodide/issues/2141
|
||||
- Emscripten async docs — https://emscripten.org/docs/porting/asyncify.html
|
||||
|
||||
**In-repo**
|
||||
- `research/threading_2.md` — prior async/threading research.
|
||||
121
docs/features/async/04-decisions-tests-open-questions.md
Normal file
121
docs/features/async/04-decisions-tests-open-questions.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# 04 — How the fixes relate, the test matrix, open questions
|
||||
|
||||
> The goal is **one universal mechanism**, not patches scattered around. This file classifies the
|
||||
> candidate fixes by *root cause* so it's clear what is part of the one solution, what is
|
||||
> subsumed, and what is genuinely separate.
|
||||
|
||||
## §1. The candidate levers, by root cause
|
||||
|
||||
| Lever | Root cause it addresses | Relationship to the universal fix |
|
||||
|---|---|---|
|
||||
| **Sync clipboard `IsSupported`** | A *separate* semantic/UX bug: a cheap predicate doing a 2 s permission-gated async read on the idle path. | **NOT needed for correctness.** A correct per-context authority makes the 2 s sleep *safe* even when overlapped. Keep it only as an independent UX/perf improvement, or drop it. |
|
||||
| **Trampoline self-heal** (`try/finally` around `Fibers.trampoline`) | A *different register*, `Fibers.trampolineRunning`, getting stuck `true` after a mid-loop unwind. | **Subsumed.** A real authority must own the trampoline anyway; "always reset the guard" becomes one of its invariants, not a standalone bolt-on. |
|
||||
| **De-park `main()`** (`simulate_infinite_loop=0` + suppress teardown) | The `throw "unwind"` abandoning Asyncify state outside any scheduler's control, plus the null-`IsModal` lifecycle crash. | **Orthogonal** to `currData`. Whether it's required is decided by one measurement (§2). If required, it is *part of* the universal design (the main loop becomes a normal scheduler participant), not a hack. |
|
||||
| **Per-context `currData` authority** | The single global slot shared by overlapping suspensions. | **The core.** This is the one universal mechanism. |
|
||||
|
||||
### Why de-parking doesn't automatically fall out of the authority
|
||||
The authority governs `currData` during *normal* unwind/rewind transitions. But
|
||||
`simulate_infinite_loop=1` is a **`throw "unwind"`** (`pcbnew.js:11392`) — a JS exception that
|
||||
**bypasses all asyncify bookkeeping, including the authority's.** Two orthogonal design axes:
|
||||
|
||||
- **D1 — how does `main()` avoid returning into destructive cleanup?** (throw, vs. de-park +
|
||||
suppress cleanup)
|
||||
- **D2 — how is `currData` managed across overlaps?** (single slot, vs. unified authority)
|
||||
|
||||
The authority is purely D2; the throw is purely D1. They don't subsume each other. You *can*
|
||||
build the authority while keeping the throw — its safety then hinges entirely on §2.
|
||||
|
||||
## §2. The one measurement that sets the scope
|
||||
|
||||
> **Is `Asyncify.currData` clean (null, no context parked) at the instant `wxGUIEventLoop::DoRun()`
|
||||
> executes the `throw "unwind"`?**
|
||||
|
||||
- **If yes** → the throw abandons nothing; the **per-context authority alone is the complete
|
||||
universal solution.** No de-parking needed.
|
||||
- **If no** (a startup coroutine or the main fiber is still parked at the throw) → the throw
|
||||
orphans a live buffer *outside the authority's control*. Then the clean answer is to **remove
|
||||
the throw** (de-park), so the authority never has to recover from an abnormal teardown. (The
|
||||
alternative — "reconcile/reset `currData` at the next tick" — is exactly the kind of paper-over
|
||||
we want to avoid.)
|
||||
|
||||
**The diagnostic (cheap, JS-only, no full rebuild):** add `console.log`s of
|
||||
`Asyncify.currData` / `Asyncify.state` / `Fibers.trampolineRunning` at:
|
||||
1. the last line of `DoRun` *before* `emscripten_set_main_loop(...,1)` (C side, `evtloop.cpp`),
|
||||
2. the first `MainLoop.runner` rAF tick (`pcbnew.js:11342`),
|
||||
3. the entry of the first post-startup `_emscripten_fiber_swap` (`pcbnew.js:11557`).
|
||||
Compare `currData` to `g_main_context`'s buffer (libcontext) and to any live coroutine's
|
||||
`fiber+20`. This disambiguates §5 mechanism #1 (orphaned buffer) vs. #2 (stuck guard) in
|
||||
[`02-asyncify-internals.md`](02-asyncify-internals.md), and answers the yes/no above.
|
||||
|
||||
**Run this before designing anything.**
|
||||
|
||||
## §3. The universal, hack-free framing
|
||||
|
||||
The cleanest single design is **one cooperative Asyncify scheduler in which *every* suspendable
|
||||
thing is a registered context with its own buffer** — tool coroutines, modals, clipboard, fonts,
|
||||
**and the main loop itself** — where the scheduler is the sole owner of `currData` *and* the
|
||||
fiber trampoline, and the main loop participates normally instead of being parked via a
|
||||
state-abandoning `throw`.
|
||||
|
||||
In that framing:
|
||||
- the per-context `currData` authority is the **core** (D2),
|
||||
- the trampoline self-heal is an **internal invariant** of it,
|
||||
- making the main loop a normal scheduler participant **is** de-parking (D1) — a consequence of
|
||||
"no special-case suspension may abandon state," not a bolt-on,
|
||||
- the clipboard sync change is **out of scope** — a separate UX fix.
|
||||
|
||||
The §2 measurement decides whether the scheduler must own the main loop from the start (cleaner)
|
||||
or can be scoped to coroutines + sleeps and leave the main loop alone.
|
||||
|
||||
## §4. Tests — enumerating "every possible case"
|
||||
|
||||
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 `-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:** no systematic coverage of *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 under-tested.
|
||||
- **Host context:** direct / rAF main-loop tick / embind dispatch / WebGL2 frame / deep stack /
|
||||
`-fexceptions` invoke wrappers.
|
||||
- **Resume target:** continuation / **virtual call** (`invoke_vi→dynCall_vi`, the `vcall_repro`
|
||||
smoking gun).
|
||||
- **Assert per cell:** (1) no `index out of bounds` / `indirect call to null` / `unwind`
|
||||
rejection (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) and `fiber_swap_after_main_park`
|
||||
(the §5 hang — the harness must install a `simulate_infinite_loop=1` main loop, then swap a
|
||||
fiber post-park).
|
||||
|
||||
## §5. Open questions to resolve before any implementation
|
||||
|
||||
1. **Run the §2 diagnostic.** Is idle `currData` the orphaned-from-park buffer (§5#1 in 02), a
|
||||
stuck trampoline guard (§5#2), or both? This decides whether the authority alone suffices or
|
||||
the main loop must be de-parked, and whether the trampoline self-heal is meaningful on its own.
|
||||
2. **For de-parking, which suppression shape** (2a UnloadCallback-driven cleanup vs. 2b guarded
|
||||
`wxEntryCleanupReal`) is least invasive given our `wxApp`/`init.cpp` fork delta? Check
|
||||
`scripts/kicad-diff-stats.sh` and current wx fork divergence first.
|
||||
3. **Does sync-clipboard alone stop the load-pcb route hanging, or only stop crashing?** If the
|
||||
open hangs even with clipboard synchronous, the authority (and likely de-park) is required, not
|
||||
optional.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
This dossier was reconstructed from: Claude session
|
||||
`3301ab3a-c457-4359-be9a-dcf4dcfc9fbc.jsonl`; the in-repo
|
||||
`docs/docs/features/fix-asyncify-O2-and-modal-promise-rejection/{rtree,clipboard}-*-findings.md` and
|
||||
`research/threading_*.md`; direct reading of the generated `tests/apps/kicad/pcbnew.js` runtime,
|
||||
the `wxwidgets/src/wasm/` and `kicad/thirdparty/libcontext/` sources, and the
|
||||
`scripts/common/` build pipeline; plus web research into Emscripten/Binaryen Asyncify and fibers
|
||||
(sources listed in [`03-solutions-and-prior-art.md`](03-solutions-and-prior-art.md)).
|
||||
686
docs/features/async/05-design-a-js-asyncify-arbiter.md
Normal file
686
docs/features/async/05-design-a-js-asyncify-arbiter.md
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
# 05 - Design A: JS Asyncify arbiter
|
||||
|
||||
> Goal: fix the current system with the smallest architectural move. Keep
|
||||
> `EM_ASYNC_JS` modal/clipboard/font calls and Emscripten fibers, but introduce one JS-side
|
||||
> authority that owns `Asyncify.currData`, `Asyncify.state` transitions, the fiber trampoline,
|
||||
> and resume queueing.
|
||||
|
||||
## Status
|
||||
|
||||
This is a design note, not an implementation. It explains an incremental universal fix:
|
||||
instead of adding one-off patches for clipboard, modal dialogs, quasi-modal loops, and fibers,
|
||||
we put one scheduler in front of all suspension paths.
|
||||
|
||||
The core idea is:
|
||||
|
||||
```text
|
||||
Many contexts may be parked.
|
||||
Only one context may be actively unwinding or rewinding at a time.
|
||||
Asyncify.currData is not durable state. It is a temporary register loaded from the current context.
|
||||
```
|
||||
|
||||
That is exactly the distinction that makes the design work.
|
||||
|
||||
## Concepts
|
||||
|
||||
### What is a call stack?
|
||||
|
||||
A call stack is the chain of active function calls for one line of execution.
|
||||
|
||||
If C++ is here:
|
||||
|
||||
```cpp
|
||||
main()
|
||||
-> wxEntry()
|
||||
-> wxDialog::ShowModal()
|
||||
-> startModal()
|
||||
```
|
||||
|
||||
then local variables and return addresses for all of those calls are on the stack. Normally, a
|
||||
function returns by walking back up that chain.
|
||||
|
||||
In browser WebAssembly, we cannot block the browser main thread while waiting for a Promise. So
|
||||
if C++ wants to pretend that `ShowModal()` blocks, the stack must be saved somewhere and resumed
|
||||
later.
|
||||
|
||||
### What is Asyncify?
|
||||
|
||||
Asyncify is Binaryen/Emscripten's way to save and restore a WebAssembly call stack.
|
||||
|
||||
At the low level, it is buffer-oriented:
|
||||
|
||||
```text
|
||||
asyncify_start_unwind(dataPtr)
|
||||
asyncify_stop_unwind()
|
||||
asyncify_start_rewind(dataPtr)
|
||||
asyncify_stop_rewind()
|
||||
```
|
||||
|
||||
`dataPtr` points at an `asyncify_data` structure. The important fact is that the Wasm-level
|
||||
mechanism already accepts a buffer pointer. It does not require there to be only one buffer.
|
||||
|
||||
Emscripten's JS runtime adds a convenience layer:
|
||||
|
||||
```js
|
||||
Asyncify.state // Normal, Unwinding, Rewinding
|
||||
Asyncify.currData // pointer to the buffer currently being used
|
||||
```
|
||||
|
||||
That convenience layer is where our contention lives. `currData` is a single slot.
|
||||
|
||||
### What is a coroutine?
|
||||
|
||||
A coroutine is a function that can pause in the middle and later continue from the same place.
|
||||
|
||||
Ordinary function:
|
||||
|
||||
```text
|
||||
call -> run to completion -> return
|
||||
```
|
||||
|
||||
Coroutine:
|
||||
|
||||
```text
|
||||
call -> run a bit -> yield -> later resume -> run more -> yield/return
|
||||
```
|
||||
|
||||
KiCad's interactive tools are coroutine-shaped. A routing tool, drawing tool, or selection tool
|
||||
often waits for user input, yields to the UI, then resumes with the next event.
|
||||
|
||||
### What is a fiber?
|
||||
|
||||
A fiber is a stackful coroutine. It has its own stack.
|
||||
|
||||
That matters because a stackful coroutine can pause deep inside ordinary C++ calls without
|
||||
turning every caller into a callback or Promise. From the C++ side it can still look synchronous.
|
||||
|
||||
In this repo, KiCad's `libcontext` shim maps KiCad coroutines to Emscripten fibers:
|
||||
|
||||
```text
|
||||
KiCad COROUTINE
|
||||
-> libcontext jump_fcontext
|
||||
-> emscripten_fiber_swap
|
||||
-> Asyncify unwind/rewind
|
||||
```
|
||||
|
||||
Each `wasm_fcontext` already has its own `asyncify_stack` buffer in
|
||||
`kicad/thirdparty/libcontext/libcontext.cpp`. So the fiber half already understands the key
|
||||
pattern: one context, one buffer.
|
||||
|
||||
### What is a trampoline?
|
||||
|
||||
A trampoline is a small piece of code whose job is to bounce control into another execution
|
||||
context.
|
||||
|
||||
In Emscripten's fiber implementation, `_emscripten_fiber_swap(oldFiber, newFiber)` starts by
|
||||
unwinding the old fiber. Once the unwind reaches the bottom of the JS/Wasm call boundary,
|
||||
Emscripten must start the new fiber's rewind. That second half cannot happen directly from the
|
||||
middle of the old fiber. It happens from `Fibers.trampoline()`.
|
||||
|
||||
So the rough flow is:
|
||||
|
||||
```text
|
||||
old fiber calls emscripten_fiber_swap(old, new)
|
||||
-> old stack unwinds into old.asyncify_data
|
||||
-> control reaches JS boundary
|
||||
-> Fibers.trampoline() runs
|
||||
-> trampoline loads new.asyncify_data
|
||||
-> trampoline starts rewind into new fiber
|
||||
```
|
||||
|
||||
The trampoline is needed because a fiber swap is two operations separated by the unwind reaching
|
||||
the edge of the runtime:
|
||||
|
||||
```text
|
||||
leave old stack now
|
||||
enter new stack after old stack is fully saved
|
||||
```
|
||||
|
||||
The bug-prone part is that `Fibers.trampoline()` has its own guard,
|
||||
`Fibers.trampolineRunning`. If a rewind from inside the trampoline itself unwinds again before
|
||||
the trampoline function returns, the guard can stay stuck. Then future fiber swaps set
|
||||
`Fibers.nextFiber`, but the trampoline refuses to run.
|
||||
|
||||
### What is parking?
|
||||
|
||||
Parking means a context is suspended and waiting outside Wasm.
|
||||
|
||||
Examples:
|
||||
|
||||
- A modal dialog is parked while waiting for the user to press OK or Cancel.
|
||||
- A clipboard read is parked while waiting for `navigator.clipboard.readText()`.
|
||||
- A KiCad tool fiber is parked after yielding to another fiber.
|
||||
|
||||
Parked does not mean active. Many contexts can be parked at once as long as each has its own
|
||||
saved stack buffer.
|
||||
|
||||
### What is queueing?
|
||||
|
||||
Queueing means "this parked context is ready to resume, but do not resume it immediately if the
|
||||
runtime is in the middle of another unwind/rewind."
|
||||
|
||||
The queue is not a mutex that blocks all async work. A naive mutex would deadlock modal UI,
|
||||
because a modal needs events to keep flowing while it is waiting.
|
||||
|
||||
The queue should serialize only active transitions:
|
||||
|
||||
```text
|
||||
Allowed:
|
||||
sleep A parked
|
||||
fiber B parked
|
||||
nested loop C parked
|
||||
clipboard D parked
|
||||
|
||||
Not allowed:
|
||||
two calls to asyncify_start_rewind at the same instant
|
||||
a Promise wakeup directly calling doRewind while the fiber trampoline is mid-switch
|
||||
```
|
||||
|
||||
## The problem this design solves
|
||||
|
||||
Today, several roads write the same global slot:
|
||||
|
||||
```text
|
||||
handleSleep:
|
||||
Asyncify.currData = Asyncify.allocateData()
|
||||
|
||||
fiber swap:
|
||||
Asyncify.currData = oldFiber + 20
|
||||
|
||||
fiber finishContextSwitch:
|
||||
Asyncify.currData = newFiber + 20
|
||||
|
||||
current handleSleep shim:
|
||||
restores the sleep buffer before wakeUp
|
||||
```
|
||||
|
||||
The current `handlesleep.js` shim is useful, but it is still local. It protects one sleep from
|
||||
fiber swaps by remembering the sleep's buffer. It does not make all suspension producers obey one
|
||||
state machine.
|
||||
|
||||
Design A says: make the local patch into a real arbiter.
|
||||
|
||||
## Core design
|
||||
|
||||
Introduce a JS object, conceptually:
|
||||
|
||||
```js
|
||||
AsyncifyArbiter = {
|
||||
active: null,
|
||||
readyQueue: [],
|
||||
contexts: new Map(),
|
||||
transitionRunning: false,
|
||||
trampolineRunning: false,
|
||||
|
||||
registerSleep(ctx) {},
|
||||
registerFiber(ctx) {},
|
||||
park(ctx) {},
|
||||
markReady(ctx, value) {},
|
||||
drain() {},
|
||||
beginUnwind(ctx) {},
|
||||
beginRewind(ctx) {},
|
||||
finishRewind(ctx) {},
|
||||
withCurrData(ctx, fn) {}
|
||||
}
|
||||
```
|
||||
|
||||
Each context has durable state:
|
||||
|
||||
```js
|
||||
{
|
||||
id,
|
||||
kind: "sleep" | "fiber" | "nested-loop" | "main-loop",
|
||||
dataPtr,
|
||||
status: "running" | "unwinding" | "parked" | "ready" | "rewinding" | "done",
|
||||
result,
|
||||
cancel
|
||||
}
|
||||
```
|
||||
|
||||
`Asyncify.currData` becomes a derived value:
|
||||
|
||||
```text
|
||||
When rewinding context X:
|
||||
Asyncify.currData = X.dataPtr
|
||||
|
||||
When unwinding context X:
|
||||
Asyncify.currData = X.dataPtr
|
||||
|
||||
When no transition is active:
|
||||
Asyncify.currData may be null
|
||||
```
|
||||
|
||||
No context is allowed to rely on `Asyncify.currData` as its long-term storage.
|
||||
|
||||
## Hooks
|
||||
|
||||
### Hook 1: `Asyncify.handleSleep`
|
||||
|
||||
This is the road used by `EM_ASYNC_JS` functions:
|
||||
|
||||
- `startModal` in `wxwidgets/src/wasm/dialog.cpp`
|
||||
- clipboard read/write/clear/has-text in `wxwidgets/src/wasm/clipbrd.cpp`
|
||||
- font enumeration in `wxwidgets/src/wasm/fontenum.cpp`
|
||||
- the nested event loop added by wx commit `c27fe8bf`, if adopted
|
||||
|
||||
The current shim wraps `handleSleep` and captures the allocated buffer. The arbiter would keep
|
||||
that, but add explicit lifecycle state:
|
||||
|
||||
```text
|
||||
handleSleep begins
|
||||
-> create sleep context
|
||||
-> intercept allocateData
|
||||
-> associate allocated dataPtr with the context
|
||||
-> start unwind
|
||||
-> mark context parked
|
||||
|
||||
Promise resolves
|
||||
-> store result on context
|
||||
-> mark context ready
|
||||
-> queue drain
|
||||
|
||||
drain runs when safe
|
||||
-> load context.dataPtr into Asyncify.currData
|
||||
-> start rewind
|
||||
-> doRewind(context.dataPtr)
|
||||
```
|
||||
|
||||
Important difference from the current implementation: the Promise resolution path should not
|
||||
directly call `wakeUp` if another transition is already active. It should enqueue the context and
|
||||
let `drain()` decide when to resume.
|
||||
|
||||
### Hook 2: `_emscripten_fiber_swap`
|
||||
|
||||
The fiber path already has per-fiber buffers. The arbiter should not allocate new buffers for
|
||||
fibers. It should track them.
|
||||
|
||||
Current idea:
|
||||
|
||||
```text
|
||||
oldFiber data = oldFiber + 20
|
||||
newFiber data = newFiber + 20
|
||||
```
|
||||
|
||||
On swap from old to new:
|
||||
|
||||
```text
|
||||
register old fiber context if unknown
|
||||
register new fiber context if unknown
|
||||
begin unwind of old
|
||||
set Fibers.nextFiber = new
|
||||
```
|
||||
|
||||
When the unwind reaches bottom, the arbiter owns the trampoline step:
|
||||
|
||||
```text
|
||||
finish old unwind
|
||||
queue new fiber as ready
|
||||
drain
|
||||
-> begin rewind of new
|
||||
```
|
||||
|
||||
The original Emscripten functions can still do much of the work. The key is to wrap their
|
||||
critical sections so the arbiter knows which data pointer belongs to which context and can reset
|
||||
guards reliably.
|
||||
|
||||
### Hook 3: `Fibers.trampoline`
|
||||
|
||||
The arbiter must own the trampoline guard.
|
||||
|
||||
Minimum invariant:
|
||||
|
||||
```js
|
||||
try {
|
||||
Fibers.trampolineRunning = true;
|
||||
// process next fiber
|
||||
} finally {
|
||||
Fibers.trampolineRunning = false;
|
||||
}
|
||||
```
|
||||
|
||||
But the arbiter design goes further. It treats the trampoline as part of the scheduler:
|
||||
|
||||
```text
|
||||
maybeStopUnwind()
|
||||
-> old stack is fully saved
|
||||
-> notify arbiter that a transition completed
|
||||
-> arbiter queues the next fiber
|
||||
-> arbiter drains when safe
|
||||
```
|
||||
|
||||
The reason this matters: if re-entered Wasm unwinds again while the trampoline is running, the
|
||||
arbiter has to know that it left the trampoline in an incomplete state. A `try/finally` fixes the
|
||||
guard symptom. A scheduler gives us a place to reason about the whole switch.
|
||||
|
||||
### Hook 4: main loop diagnostic, not necessarily main loop ownership
|
||||
|
||||
This is the part that caused the earlier "do not start by de-parking" comment.
|
||||
|
||||
The top-level `emscripten_set_main_loop(ProcessEvents, 0, 1)` throws `"unwind"` to stop C++
|
||||
from returning into wx cleanup. That throw is not Asyncify. It is a special Emscripten lifetime
|
||||
trick.
|
||||
|
||||
Design A can leave that alone if the throw happens while no Asyncify context is in flight.
|
||||
|
||||
So the first diagnostic is:
|
||||
|
||||
```text
|
||||
At the moment top-level DoRun installs the main loop:
|
||||
Asyncify.state == Normal?
|
||||
Asyncify.currData == null?
|
||||
Fibers.trampolineRunning == false?
|
||||
```
|
||||
|
||||
If yes, the throw is only a lifetime mechanism. It is not corrupting Asyncify state. Then the
|
||||
arbiter can fix overlapping sleeps/fibers without touching wx app lifetime.
|
||||
|
||||
If no, the throw is abandoning a live Asyncify context. Then Design A is incomplete unless it
|
||||
also takes ownership of the main-loop park or we de-park main.
|
||||
|
||||
## Why not de-park first?
|
||||
|
||||
De-parking means changing the top-level main loop from:
|
||||
|
||||
```cpp
|
||||
emscripten_set_main_loop(ProcessEvents, 0, 1);
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```cpp
|
||||
emscripten_set_main_loop(ProcessEvents, 0, 0);
|
||||
```
|
||||
|
||||
The `0` version does not throw. It returns normally. That sounds cleaner for Asyncify.
|
||||
|
||||
But it has a cost: after `DoRun()` returns, wx thinks the app is done. `wxEntryReal()` continues
|
||||
into cleanup:
|
||||
|
||||
```text
|
||||
OnRun returns
|
||||
-> CallOnExit destructor runs wxTheApp->OnExit()
|
||||
-> wxEntryCleanupReal deletes windows and wxTheApp
|
||||
-> Emscripten rAF main loop is still alive
|
||||
-> next tick touches deleted app/window state
|
||||
```
|
||||
|
||||
So de-parking is not just a one-line Asyncify fix. It is an application lifetime redesign.
|
||||
|
||||
That is why Design A starts with the arbiter and the diagnostic. If the top-level throw is not
|
||||
orphaning a live buffer, de-parking is avoidable. If the diagnostic proves the throw is
|
||||
orphaning a live buffer, then de-parking becomes part of the solution, not a speculative first
|
||||
move.
|
||||
|
||||
## Queueing model
|
||||
|
||||
The queue should be cooperative and explicit:
|
||||
|
||||
```js
|
||||
function markReady(ctx, result) {
|
||||
ctx.result = result;
|
||||
ctx.status = "ready";
|
||||
readyQueue.push(ctx);
|
||||
scheduleDrain();
|
||||
}
|
||||
|
||||
function scheduleDrain() {
|
||||
if (drainScheduled) return;
|
||||
drainScheduled = true;
|
||||
setTimeout(drain, 0);
|
||||
}
|
||||
|
||||
function drain() {
|
||||
drainScheduled = false;
|
||||
|
||||
if (transitionRunning) return;
|
||||
if (Asyncify.state !== Asyncify.State.Normal) return;
|
||||
if (Fibers.trampolineRunning) return;
|
||||
if (!readyQueue.length) return;
|
||||
|
||||
const ctx = readyQueue.shift();
|
||||
transitionRunning = true;
|
||||
|
||||
try {
|
||||
beginRewind(ctx);
|
||||
} finally {
|
||||
// This may not run immediately if beginRewind re-enters Wasm and unwinds.
|
||||
// Therefore the real implementation must pair this with explicit callbacks
|
||||
// from stop_rewind/maybeStopUnwind, not rely only on JS finally.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The subtle point: queueing must understand that `doRewind()` can re-enter Wasm and trigger
|
||||
another unwind before returning. A plain JS queue is not enough unless the arbiter also receives
|
||||
transition completion signals.
|
||||
|
||||
## Flow: modal dialog plus tool fiber
|
||||
|
||||
Current bad flow:
|
||||
|
||||
```text
|
||||
ShowModal starts handleSleep
|
||||
-> currData = modalBuffer
|
||||
-> modal parked
|
||||
|
||||
modal's JS pump calls ProcessEvents
|
||||
-> tool fiber swaps
|
||||
-> currData = fiberBuffer
|
||||
|
||||
modal Promise resolves
|
||||
-> handleSleep rewinds currData
|
||||
-> currData is wrong
|
||||
-> crash or wrong rewind
|
||||
```
|
||||
|
||||
Arbiter flow:
|
||||
|
||||
```text
|
||||
ShowModal starts handleSleep
|
||||
-> modal context dataPtr = modalBuffer
|
||||
-> modal context parked
|
||||
|
||||
modal's JS pump calls ProcessEvents
|
||||
-> fiber context old/new dataPtr tracked
|
||||
-> fiber transitions complete
|
||||
-> currData may change transiently
|
||||
|
||||
modal Promise resolves
|
||||
-> modal context marked ready
|
||||
-> drain waits until no active transition
|
||||
-> currData = modalContext.dataPtr
|
||||
-> modal rewinds using modalBuffer
|
||||
```
|
||||
|
||||
`currData` can be overwritten during the wait. It no longer matters because the durable pointer
|
||||
lives in the context record.
|
||||
|
||||
## Flow: long clipboard read plus fiber swap
|
||||
|
||||
The clipboard `IsSupported()` path is unpleasant because it can park for two seconds on a
|
||||
permission-gated `readText()`.
|
||||
|
||||
With the arbiter:
|
||||
|
||||
```text
|
||||
clipboard sleep parks with dataPtr C
|
||||
fiber swap parks/resumes with dataPtr F
|
||||
clipboard Promise resolves later
|
||||
arbiter reloads C before clipboard rewind
|
||||
```
|
||||
|
||||
That makes the path correct, but it does not make it good UX. We may still want a synchronous or
|
||||
cached `IsSupported()` for performance and browser permission reasons. That is a separate
|
||||
quality fix.
|
||||
|
||||
## Flow: quasi-modal nested event loop
|
||||
|
||||
The linked wx commit `c27fe8bf` adds a nested event loop implemented with `EM_ASYNC_JS`.
|
||||
|
||||
Conceptually, that creates another sleep context:
|
||||
|
||||
```text
|
||||
DIALOG_SHIM::ShowQuasiModal()
|
||||
-> wxGUIEventLoop event_loop
|
||||
-> event_loop.Run()
|
||||
-> nested DoRun
|
||||
-> wxWasmRunNestedLoop()
|
||||
-> Asyncify sleep until EndQuasiModal exits it
|
||||
```
|
||||
|
||||
Design A can support this if `wxWasmRunNestedLoop()` goes through the same `handleSleep`
|
||||
arbiter.
|
||||
|
||||
The caution: if the nested pump catches an async `ProcessEvents` rejection and stops pumping
|
||||
without resolving its Promise, the nested `DoRun()` remains parked forever. The arbiter can
|
||||
make the rewind safe, but the nested loop implementation still needs a policy:
|
||||
|
||||
```text
|
||||
on pump error:
|
||||
resolve with an error/exit code
|
||||
or reject in a controlled way
|
||||
or mark context cancelled and resume C++ through cleanup
|
||||
```
|
||||
|
||||
It should not silently stop with no resolution.
|
||||
|
||||
## Invariants
|
||||
|
||||
The arbiter should assert these aggressively in diagnostics builds:
|
||||
|
||||
1. Only the arbiter writes `Asyncify.currData` during managed transitions.
|
||||
2. Every parked context has a durable `dataPtr`.
|
||||
3. A context may be parked without being active.
|
||||
4. At most one context is `unwinding` or `rewinding`.
|
||||
5. Promise resolution never directly calls `doRewind()` if another transition is active.
|
||||
6. `Fibers.trampolineRunning` is reset even if re-entered Wasm unwinds.
|
||||
7. A context's buffer is not freed until that context reaches `done` or `cancelled`.
|
||||
8. `Asyncify.currData` may be null while contexts are parked. The context records are the truth.
|
||||
|
||||
## What this design does not solve by itself
|
||||
|
||||
### It does not remove the top-level `throw "unwind"`
|
||||
|
||||
If the top-level main-loop throw is clean, that is fine. If it abandons a live Asyncify context,
|
||||
the arbiter cannot recover perfectly after the fact because the throw bypasses Asyncify
|
||||
bookkeeping.
|
||||
|
||||
That is why the diagnostic matters.
|
||||
|
||||
### It does not make arbitrary nested C stacks possible
|
||||
|
||||
Raw nested `handleSleep` on the same C stack is still not a thing Asyncify can magically support.
|
||||
If function `f()` is parked and function `g()` inside the same live stack tries to park
|
||||
independently, there is only one actual stack. To have independent suspension, `f` and `g` must
|
||||
be on separate stacks/fibers or be parked as one combined context.
|
||||
|
||||
The arbiter prevents buffer loss. It does not violate stack physics.
|
||||
|
||||
### It does not remove browser permission latency
|
||||
|
||||
A correct two-second clipboard read is still a two-second clipboard read. Correctness and UX are
|
||||
separate.
|
||||
|
||||
## Implementation sketch
|
||||
|
||||
Stage 1: observation only.
|
||||
|
||||
- Add diagnostics around `handleSleep`, `_emscripten_fiber_swap`, `Fibers.trampoline`,
|
||||
`maybeStopUnwind`, and the first rAF tick.
|
||||
- Log context ids, data pointers, states, queue length, and trampoline guard.
|
||||
- Confirm whether the top-level main-loop throw happens with clean Asyncify state.
|
||||
|
||||
Stage 2: make sleeps context-owned.
|
||||
|
||||
- Promote `handlesleep.js` from "restore captured pointer" to "register sleep context".
|
||||
- Store sleep data pointer in context.
|
||||
- Queue Promise wakeups through `drain()` instead of direct immediate rewind when unsafe.
|
||||
|
||||
Stage 3: make fibers context-owned.
|
||||
|
||||
- Wrap `_emscripten_fiber_swap`.
|
||||
- Register fiber buffers by pointer.
|
||||
- Move trampoline guard reset into arbiter invariants.
|
||||
- Record old/new fiber context state around swaps.
|
||||
|
||||
Stage 4: unify drain.
|
||||
|
||||
- One drain path handles ready sleep contexts and ready fiber contexts.
|
||||
- Drain only starts a rewind when `Asyncify.state` is Normal and no transition is active.
|
||||
|
||||
Stage 5: decide on de-parking.
|
||||
|
||||
- If diagnostics prove the top-level throw is clean, leave it alone.
|
||||
- If not, combine the arbiter with a separate lifetime change. See Design B for the cleaner
|
||||
version of that world.
|
||||
|
||||
## Tests this design needs
|
||||
|
||||
Minimum named tests:
|
||||
|
||||
- `long_parked_sleep_clobbered_by_swap`
|
||||
- `modal_pump_runs_fiber_then_modal_resolves`
|
||||
- `fiber_swap_after_top_level_main_loop_park`
|
||||
- `nested_quasi_modal_loop_exit_resumes_DoRun`
|
||||
- `nested_quasi_modal_pump_error_does_not_hang`
|
||||
- `clipboard_has_text_timeout_does_not_crash`
|
||||
- `out_of_order_sleep_resolution`
|
||||
- `fiber_trampoline_unwinds_inside_trampoline_then_next_swap_lives`
|
||||
|
||||
Each test should assert:
|
||||
|
||||
- no `index out of bounds`
|
||||
- no `indirect call to null`
|
||||
- no uncaught `"unwind"` rejection
|
||||
- completion within a timeout
|
||||
- correct returned value or dialog code
|
||||
|
||||
Liveness is as important as crash freedom.
|
||||
|
||||
## Why this is elegant enough
|
||||
|
||||
This design is elegant because it changes the ownership model without forcing KiCad or wxWidgets
|
||||
to become async-first.
|
||||
|
||||
The main rule becomes:
|
||||
|
||||
```text
|
||||
Every suspension has a context.
|
||||
Every context owns its buffer.
|
||||
The arbiter owns the one active transition slot.
|
||||
```
|
||||
|
||||
That is the same shape as Emscripten fibers, TinyGo's scheduler, and other coroutine runtimes.
|
||||
It respects Asyncify's real constraint: not one buffer total, but one active transition at a
|
||||
time.
|
||||
|
||||
## Tradeoffs
|
||||
|
||||
Pros:
|
||||
|
||||
- Lowest disruption to KiCad and wxWidgets C++ APIs.
|
||||
- Keeps `ShowModal()` and `ShowQuasiModal()` synchronous from KiCad's perspective.
|
||||
- Can be built incrementally from the current `handlesleep.js` shim.
|
||||
- Avoids app-lifetime risk unless diagnostics prove de-parking is required.
|
||||
|
||||
Cons:
|
||||
|
||||
- JS shim complexity increases.
|
||||
- Still depends on Emscripten-generated runtime shapes (`handleSleep`, `Fibers.trampoline`,
|
||||
`_emscripten_fiber_swap`).
|
||||
- A partial arbiter can be worse than no arbiter if some path still writes `currData` behind its
|
||||
back.
|
||||
- It makes unsafe overlaps safe, but it does not automatically improve slow clipboard/font UX.
|
||||
|
||||
## Decision point
|
||||
|
||||
Choose Design A if we want the practical universal fix first.
|
||||
|
||||
Before implementing it, answer:
|
||||
|
||||
```text
|
||||
At top-level main-loop installation, is Asyncify clean?
|
||||
```
|
||||
|
||||
If yes, build the arbiter around sleeps and fibers. If no, either extend Design A to own the
|
||||
main-loop lifetime too, or move toward Design B.
|
||||
609
docs/features/async/06-design-b-fiber-first-runtime.md
Normal file
609
docs/features/async/06-design-b-fiber-first-runtime.md
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
# 06 - Design B: fiber-first async runtime
|
||||
|
||||
> Goal: make the architecture conceptually cleaner by reducing the number of suspension
|
||||
> primitives. Instead of having tool coroutines use fibers while modal/clipboard/font/nested loops
|
||||
> use `EM_ASYNC_JS` sleeps, put every blocking-looking operation onto a fiber-like runtime and let
|
||||
> one scheduler decide which stack runs next.
|
||||
|
||||
## Status
|
||||
|
||||
This is a design note, not an implementation. It is more invasive than Design A, but it is also
|
||||
cleaner.
|
||||
|
||||
Design A says:
|
||||
|
||||
```text
|
||||
Keep both suspension systems.
|
||||
Put an arbiter in front of them.
|
||||
```
|
||||
|
||||
Design B says:
|
||||
|
||||
```text
|
||||
Stop having two suspension systems.
|
||||
Represent every suspendable C++ activity as a scheduler context with its own stack/buffer.
|
||||
```
|
||||
|
||||
In other words: modals, clipboard waits, font waits, quasi-modal loops, and tool coroutines all
|
||||
become the same kind of thing from the runtime's point of view.
|
||||
|
||||
## Concepts
|
||||
|
||||
### Coroutine
|
||||
|
||||
A coroutine is code that can yield and later resume.
|
||||
|
||||
KiCad tools already have this shape:
|
||||
|
||||
```text
|
||||
tool starts
|
||||
-> waits for user event
|
||||
-> yields
|
||||
-> resumes when event arrives
|
||||
-> waits again
|
||||
```
|
||||
|
||||
The useful property is that the tool can be written in direct style. It does not have to become a
|
||||
chain of callbacks.
|
||||
|
||||
### Stackless coroutine vs. stackful coroutine
|
||||
|
||||
A stackless coroutine can only suspend at explicit points in that function. JavaScript
|
||||
`async`/`await` is stackless from the JS perspective: every caller must also understand Promise
|
||||
control flow.
|
||||
|
||||
A stackful coroutine can suspend deep inside normal calls:
|
||||
|
||||
```text
|
||||
tool()
|
||||
-> helper()
|
||||
-> dialog.ShowModal()
|
||||
-> suspend here
|
||||
```
|
||||
|
||||
When it resumes, it continues from inside `ShowModal()` and returns normally to `helper()` and
|
||||
then to `tool()`.
|
||||
|
||||
KiCad wants stackful behavior because a large C++ application assumes blocking APIs like
|
||||
`ShowModal()` and synchronous predicates.
|
||||
|
||||
### Fiber
|
||||
|
||||
A fiber is a stackful coroutine with its own stack.
|
||||
|
||||
Native platforms switch fibers by saving CPU registers and the stack pointer. In WebAssembly we
|
||||
cannot directly manipulate the real engine stack, so Emscripten implements fibers using
|
||||
Asyncify.
|
||||
|
||||
The important thing: a fiber has an identity and storage.
|
||||
|
||||
```text
|
||||
fiber A:
|
||||
C stack memory
|
||||
asyncify_data buffer
|
||||
status
|
||||
|
||||
fiber B:
|
||||
C stack memory
|
||||
asyncify_data buffer
|
||||
status
|
||||
```
|
||||
|
||||
This is exactly the model we want for every independently parked operation.
|
||||
|
||||
### Asyncify buffer
|
||||
|
||||
An Asyncify buffer is where the saved Wasm stack goes when a context parks.
|
||||
|
||||
Think of it like a suitcase for a suspended stack:
|
||||
|
||||
```text
|
||||
before suspend:
|
||||
live call stack is inside Wasm engine
|
||||
|
||||
during suspend:
|
||||
saved stack is copied into context.dataPtr
|
||||
|
||||
after resume:
|
||||
stack is rebuilt from context.dataPtr
|
||||
```
|
||||
|
||||
Many suitcases may exist. Only one suitcase is being packed or unpacked at any instant.
|
||||
|
||||
### Trampoline
|
||||
|
||||
A trampoline is code that transfers control from one context to another after the old context has
|
||||
fully yielded.
|
||||
|
||||
For fibers:
|
||||
|
||||
```text
|
||||
old fiber asks to switch to new fiber
|
||||
old fiber unwinds
|
||||
JS boundary is reached
|
||||
trampoline rewinds new fiber
|
||||
```
|
||||
|
||||
It is called a trampoline because control "bounces" through it. The old stack cannot directly
|
||||
jump into the new stack while it is still being unwound. The trampoline is the neutral place
|
||||
where the runtime can say, "old is saved, now start new."
|
||||
|
||||
### Scheduler
|
||||
|
||||
A scheduler decides which context runs next.
|
||||
|
||||
In a cooperative scheduler, contexts yield voluntarily. There is no preemption. That matches
|
||||
browser main-thread WebAssembly well: only one thing runs at a time, and control returns to the
|
||||
browser between slices.
|
||||
|
||||
Design B wants a scheduler like:
|
||||
|
||||
```text
|
||||
ready queue:
|
||||
tool fiber
|
||||
modal continuation
|
||||
nested loop continuation
|
||||
clipboard continuation
|
||||
|
||||
running:
|
||||
exactly one context
|
||||
```
|
||||
|
||||
## Current architecture
|
||||
|
||||
Today KiCad-WASM has two broad suspension families.
|
||||
|
||||
Family 1: fibers.
|
||||
|
||||
```text
|
||||
KiCad tool coroutine
|
||||
-> libcontext
|
||||
-> emscripten_fiber_swap
|
||||
-> fiber-owned asyncify buffer
|
||||
```
|
||||
|
||||
Family 2: `EM_ASYNC_JS` sleeps.
|
||||
|
||||
```text
|
||||
wxDialog::ShowModal
|
||||
clipboard read/write/hasText/clear
|
||||
font enumeration
|
||||
nested event loop if c27 is adopted
|
||||
-> Asyncify.handleSleep
|
||||
-> malloc-owned asyncify buffer
|
||||
```
|
||||
|
||||
Both families eventually manipulate the same `Asyncify.currData` and `Asyncify.state`.
|
||||
|
||||
Design B removes that split.
|
||||
|
||||
## Core design
|
||||
|
||||
Make all blocking-looking APIs suspend the current scheduler context instead of directly using
|
||||
`EM_ASYNC_JS` as an independent stack owner.
|
||||
|
||||
Conceptual API:
|
||||
|
||||
```cpp
|
||||
int wxWasmAwaitModal();
|
||||
char* wxWasmAwaitClipboardRead();
|
||||
int wxWasmAwaitClipboardHasText();
|
||||
int wxWasmAwaitNestedLoopExit();
|
||||
```
|
||||
|
||||
Internally:
|
||||
|
||||
```text
|
||||
current fiber/context calls await operation
|
||||
-> runtime records what JS Promise/event will wake it
|
||||
-> current context yields to scheduler/main context
|
||||
-> JS continues pumping browser events
|
||||
-> Promise/event resolves
|
||||
-> scheduler marks context ready
|
||||
-> scheduler later resumes that same context
|
||||
```
|
||||
|
||||
From C++'s perspective, this still looks blocking:
|
||||
|
||||
```cpp
|
||||
int result = wxWasmAwaitModal();
|
||||
```
|
||||
|
||||
From the runtime's perspective, there is no separate `handleSleep` stack competing with fibers.
|
||||
There are only scheduler contexts.
|
||||
|
||||
## How `ShowModal()` would work
|
||||
|
||||
Current model:
|
||||
|
||||
```text
|
||||
wxDialog::ShowModal()
|
||||
-> startModal() EM_ASYNC_JS
|
||||
-> handleSleep parks the whole current C stack
|
||||
-> JS setTimeout loop calls ProcessEvents
|
||||
-> EndModal resolves Promise
|
||||
-> handleSleep rewinds the saved C stack
|
||||
```
|
||||
|
||||
Fiber-first model:
|
||||
|
||||
```text
|
||||
wxDialog::ShowModal()
|
||||
-> show dialog
|
||||
-> register modal wait on current context
|
||||
-> yield current context to scheduler
|
||||
-> browser/main loop keeps pumping events
|
||||
-> EndModal stores return code and marks context ready
|
||||
-> scheduler resumes context
|
||||
-> ShowModal returns int
|
||||
```
|
||||
|
||||
There is still a suspension. The difference is ownership.
|
||||
|
||||
In the current model, `startModal()` creates a separate `handleSleep` suspension that can overlap
|
||||
with tool fibers.
|
||||
|
||||
In Design B, the modal wait is a reason for the current fiber/context to yield. It does not
|
||||
create a second independent suspension mechanism.
|
||||
|
||||
## How clipboard would work
|
||||
|
||||
Current model:
|
||||
|
||||
```text
|
||||
wxClipboard::GetData()
|
||||
-> js_readTextFromClipboard() EM_ASYNC_JS
|
||||
-> handleSleep
|
||||
-> Promise waits
|
||||
-> rewind C++ stack
|
||||
```
|
||||
|
||||
Fiber-first model:
|
||||
|
||||
```text
|
||||
wxClipboard::GetData()
|
||||
-> start JS clipboard Promise
|
||||
-> current context yields
|
||||
-> Promise resolves with text or error
|
||||
-> scheduler resumes context
|
||||
-> GetData continues synchronously with stored result
|
||||
```
|
||||
|
||||
This still allows the public wx API to look synchronous. But the wait is represented as "this
|
||||
fiber is blocked on clipboard" instead of "handleSleep owns another Asyncify buffer."
|
||||
|
||||
## How quasi-modal nested loops would work
|
||||
|
||||
The linked wx commit `c27fe8bf` implements nested `wxGUIEventLoop::DoRun()` by adding another
|
||||
`EM_ASYNC_JS` pump. That is reasonable as an incremental fix, but in Design B the nested loop is
|
||||
just another scheduler wait.
|
||||
|
||||
Current c27-shaped model:
|
||||
|
||||
```text
|
||||
ShowQuasiModal
|
||||
-> wxGUIEventLoop::Run()
|
||||
-> nested DoRun
|
||||
-> wxWasmRunNestedLoop() EM_ASYNC_JS
|
||||
-> setTimeout pump calls ProcessEvents
|
||||
-> EndQuasiModal resolves Promise
|
||||
```
|
||||
|
||||
Fiber-first model:
|
||||
|
||||
```text
|
||||
ShowQuasiModal
|
||||
-> wxGUIEventLoop::Run()
|
||||
-> nested DoRun registers "wait until this loop exits"
|
||||
-> current context yields to scheduler
|
||||
-> top-level browser loop continues pumping ProcessEvents
|
||||
-> EndQuasiModal calls loop->Exit()
|
||||
-> scheduler marks nested-loop context ready
|
||||
-> nested DoRun returns
|
||||
```
|
||||
|
||||
No second `emscripten_set_main_loop`. No nested `handleSleep` pump. No independent Promise
|
||||
rewind directly from the nested loop.
|
||||
|
||||
## The main loop in Design B
|
||||
|
||||
This is where de-parking becomes easier to understand.
|
||||
|
||||
The top-level Emscripten main loop currently uses:
|
||||
|
||||
```cpp
|
||||
emscripten_set_main_loop(ProcessEvents, 0, 1);
|
||||
```
|
||||
|
||||
The `1` means "simulate an infinite loop." Emscripten implements that by throwing `"unwind"` out
|
||||
of the startup stack. This prevents `main()` and `wxEntryReal()` from returning into wx cleanup.
|
||||
|
||||
In Design B, the cleaner version is:
|
||||
|
||||
```text
|
||||
main loop is also a scheduler participant
|
||||
app lifetime is owned by the scheduler/browser loop
|
||||
wx cleanup runs only on real exit/unload
|
||||
```
|
||||
|
||||
That probably means eventually replacing the top-level throw with explicit lifetime ownership:
|
||||
|
||||
```text
|
||||
startup initializes wx app
|
||||
scheduler starts rAF/setTimeout event pump
|
||||
startup returns without destroying wx app
|
||||
real cleanup is deferred to page unload or explicit app exit
|
||||
```
|
||||
|
||||
But this is the risky part. wx's normal contract says:
|
||||
|
||||
```text
|
||||
OnRun returns -> app exits -> cleanup happens
|
||||
```
|
||||
|
||||
Browser apps want:
|
||||
|
||||
```text
|
||||
OnRun starts event pump -> app stays alive -> cleanup happens later
|
||||
```
|
||||
|
||||
The existing `simulate_infinite_loop=1` throw is Emscripten's shortcut for that mismatch.
|
||||
Design B would replace the shortcut with explicit lifetime rules.
|
||||
|
||||
## Why this design may need de-parking
|
||||
|
||||
Design B tries to make every suspension normal and scheduler-owned. A plain JS throw that
|
||||
abandons the startup stack is not normal and not scheduler-owned.
|
||||
|
||||
So the cleanest Design B includes de-parking:
|
||||
|
||||
```text
|
||||
no special top-level throw
|
||||
no hidden abandoned C++ stack
|
||||
main loop is represented as a scheduler/lifetime state
|
||||
```
|
||||
|
||||
But de-parking must be paired with suppressing normal wx cleanup during steady-state operation.
|
||||
Otherwise `wxTheApp` and top-level windows can be deleted while the browser rAF loop is still
|
||||
calling `ProcessEvents()`.
|
||||
|
||||
That is why "de-park main" is not a trivial first patch. It belongs naturally in Design B, but it
|
||||
requires an app-lifetime plan.
|
||||
|
||||
## Can we have separate Asyncify state for each stack?
|
||||
|
||||
Design B's answer is nuanced:
|
||||
|
||||
```text
|
||||
Separate durable state per stack: yes.
|
||||
Separate active Asyncify.state per stack: no, not needed.
|
||||
```
|
||||
|
||||
Each scheduler context gets:
|
||||
|
||||
```text
|
||||
own C stack/fiber stack
|
||||
own asyncify_data buffer
|
||||
own status/result/wait reason
|
||||
```
|
||||
|
||||
But the Wasm instance still has one active transition at a time:
|
||||
|
||||
```text
|
||||
Normal -> Unwinding -> Normal
|
||||
Normal -> Rewinding -> Normal
|
||||
```
|
||||
|
||||
That is fine. A single-threaded cooperative scheduler only needs one active transition. The
|
||||
important thing is that parked contexts retain their own buffers while they are inactive.
|
||||
|
||||
## Why not just queue `handleSleep` calls?
|
||||
|
||||
Because a modal dialog must pump events while it is waiting.
|
||||
|
||||
A bad queue would say:
|
||||
|
||||
```text
|
||||
modal sleep is active
|
||||
do not allow any other suspension until modal resolves
|
||||
```
|
||||
|
||||
That freezes the UI if the modal's own event pump needs to run tool callbacks or nested waits.
|
||||
|
||||
A good scheduler says:
|
||||
|
||||
```text
|
||||
modal context is parked
|
||||
other contexts may run
|
||||
when modal resolves, resume it later when the transition slot is free
|
||||
```
|
||||
|
||||
Design B makes that cleaner by representing all waits as scheduler waits, not arbitrary nested
|
||||
`handleSleep` calls.
|
||||
|
||||
## Implementation approaches
|
||||
|
||||
There are two possible ways to implement Design B.
|
||||
|
||||
### B1: Fiberize wx waits on top of current libcontext
|
||||
|
||||
Keep the existing Emscripten fiber/libcontext machinery. Add a small runtime API:
|
||||
|
||||
```cpp
|
||||
using WAKE_TOKEN = int;
|
||||
|
||||
WAKE_TOKEN wasm_begin_async_wait(...);
|
||||
int wasm_yield_until(WAKE_TOKEN token);
|
||||
void wasm_resolve_wait(WAKE_TOKEN token, int result);
|
||||
```
|
||||
|
||||
Then implement modal/clipboard/font/nested-loop waits through that API.
|
||||
|
||||
Rough flow:
|
||||
|
||||
```text
|
||||
C++ starts async JS operation
|
||||
JS operation gets token
|
||||
C++ yields current fiber/context
|
||||
JS resolves token later
|
||||
scheduler resumes waiting context
|
||||
```
|
||||
|
||||
This approach keeps most of KiCad's coroutine system. The risk is making sure every caller is on
|
||||
a scheduler-owned context when it tries to wait. If code on the raw main stack calls a wait, the
|
||||
runtime must either fiberize the main stack first or reject with a clear diagnostic.
|
||||
|
||||
### B2: Make the whole app run inside a managed root fiber
|
||||
|
||||
Instead of only tool coroutines being fibers, start KiCad inside a root managed fiber. Then even
|
||||
"main stack" waits are scheduler-owned.
|
||||
|
||||
Conceptual startup:
|
||||
|
||||
```text
|
||||
JS starts runtime
|
||||
runtime creates root app fiber
|
||||
root app fiber calls main/wxEntry
|
||||
root app fiber yields when OnRun starts browser loop
|
||||
browser loop/scheduler owns future resumes
|
||||
```
|
||||
|
||||
This is cleaner but more invasive. It makes the top-level main loop, modal waits, tool
|
||||
coroutines, and nested loops all part of one runtime from the beginning.
|
||||
|
||||
If we were designing from scratch, this is probably the architecture. In an existing port, it is
|
||||
a bigger migration.
|
||||
|
||||
## How this compares to Design A
|
||||
|
||||
| Question | Design A: JS arbiter | Design B: fiber-first runtime |
|
||||
|---|---|---|
|
||||
| Keeps current `EM_ASYNC_JS` waits? | Yes | Mostly no |
|
||||
| Requires de-parking main? | Only if diagnostic proves needed | Probably yes for the clean version |
|
||||
| Touches wx app lifetime? | Maybe | Likely |
|
||||
| JS shim complexity | Medium/high | Medium |
|
||||
| C++ runtime changes | Low/medium | High |
|
||||
| Conceptual cleanliness | Good | Best |
|
||||
| Migration risk | Lower | Higher |
|
||||
| End state | Two suspension families under one arbiter | One scheduler-owned suspension family |
|
||||
|
||||
## Failure modes
|
||||
|
||||
### Code waits outside a managed context
|
||||
|
||||
If a blocking-looking API is called on a stack the scheduler does not own, it cannot safely yield.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- initialize a root app fiber early, or
|
||||
- detect unmanaged waits and abort with a diagnostic in development builds.
|
||||
|
||||
### Lifetime cleanup runs too early
|
||||
|
||||
If de-parking lets `wxEntryReal()` continue into cleanup, the app can be destroyed while the
|
||||
browser loop still runs.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- make browser loop own app lifetime,
|
||||
- defer `OnExit()`/`wxEntryCleanupReal()` to page unload or explicit app exit,
|
||||
- ensure `emscripten_cancel_main_loop()` and cleanup are ordered.
|
||||
|
||||
### Reentrancy bugs become visible
|
||||
|
||||
When waits become scheduler-managed, the browser can continue running other events while a
|
||||
context is parked. That is correct, but C++ code may have assumed certain globals cannot change
|
||||
while a "blocking" call is waiting.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- tests for out-of-order resolution,
|
||||
- clear "modal blocks this parent/window" rules,
|
||||
- keep wx's modal/quasi-modal disabling semantics intact.
|
||||
|
||||
### Starvation
|
||||
|
||||
If the scheduler always resumes newly ready contexts first, an older parked context could wait
|
||||
too long.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- FIFO ready queue by default,
|
||||
- priority only for paint/input if proven necessary,
|
||||
- diagnostics for context age.
|
||||
|
||||
## Test matrix
|
||||
|
||||
Design B should pass everything Design A needs, plus root-context tests:
|
||||
|
||||
- app startup inside managed root fiber
|
||||
- top-level main loop starts without orphaning Asyncify state
|
||||
- `ShowModal()` from root context
|
||||
- `ShowModal()` from tool coroutine
|
||||
- `ShowQuasiModal()` from tool coroutine
|
||||
- clipboard read from root context
|
||||
- clipboard read from tool coroutine
|
||||
- font enumeration during startup
|
||||
- nested modal inside quasi-modal
|
||||
- exit/unload cleanup after parked contexts exist
|
||||
|
||||
Each test should assert:
|
||||
|
||||
- no crash
|
||||
- no hang
|
||||
- correct return value
|
||||
- app remains interactive after the wait
|
||||
- cleanup does not run during steady-state main-loop pumping
|
||||
|
||||
## Why this is elegant
|
||||
|
||||
Design B is elegant because it makes the runtime model match the actual problem:
|
||||
|
||||
```text
|
||||
KiCad is a synchronous C++ GUI app.
|
||||
The browser is asynchronous.
|
||||
Therefore the port needs a stackful cooperative scheduler.
|
||||
```
|
||||
|
||||
Once we accept that, modal dialogs, clipboard operations, font enumeration, nested event loops,
|
||||
and tool coroutines are not different species. They are all contexts that sometimes wait.
|
||||
|
||||
The universal rule becomes:
|
||||
|
||||
```text
|
||||
No API directly owns Asyncify.
|
||||
APIs ask the scheduler to park or wake contexts.
|
||||
The scheduler alone performs Asyncify transitions.
|
||||
```
|
||||
|
||||
That is the clean architecture.
|
||||
|
||||
## Why this may be too much as the first fix
|
||||
|
||||
It asks us to change more than the bug requires:
|
||||
|
||||
- modal implementation,
|
||||
- clipboard implementation,
|
||||
- font enumeration,
|
||||
- nested event loops,
|
||||
- possibly app startup and shutdown,
|
||||
- root stack ownership.
|
||||
|
||||
That is a lot of surface area for a port that already has working pieces.
|
||||
|
||||
So the pragmatic path is often:
|
||||
|
||||
```text
|
||||
1. Build Design A arbiter.
|
||||
2. Use tests to locate remaining architectural pain.
|
||||
3. Migrate high-risk waits toward Design B over time.
|
||||
4. De-park/root-fiber the app only when the evidence says it is necessary or worth the cleanup.
|
||||
```
|
||||
|
||||
## Decision point
|
||||
|
||||
Choose Design B if we want the clean long-term runtime architecture and are willing to touch
|
||||
wx/KiCad lifetime boundaries.
|
||||
|
||||
Choose Design A first if we want to stabilize the current port and learn exactly which
|
||||
suspension overlaps still fail.
|
||||
49
docs/features/async/README.md
Normal file
49
docs/features/async/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Asyncify `currData` contention in KiCad-WASM — research dossier
|
||||
|
||||
> **Status:** research / understanding only. No implementation has been chosen.
|
||||
> Authored 2026-06. All line numbers are against the artifacts current at that time
|
||||
> (`tests/apps/kicad/pcbnew.js`, `wxwidgets/src/wasm/*.cpp`,
|
||||
> `kicad/thirdparty/libcontext/libcontext.cpp`, `wxwidgets/src/common/init.cpp`).
|
||||
|
||||
## Why this exists
|
||||
|
||||
While bringing up `tests/kicad/load-pcb.spec.ts` (load a real `.kicad_pcb` through
|
||||
File→Open), three distinct errors surfaced. One is fixed; two remain and turned out to be
|
||||
**the same underlying disease**: Emscripten Asyncify has a single global suspension register
|
||||
(`Asyncify.currData` + `Asyncify.state`), but KiCad-WASM has **three independent subsystems**
|
||||
that all drive it (tool coroutines, modal/clipboard sleeps, and the parked main loop). When any
|
||||
two overlap, one reads a buffer that no longer belongs to it → **crash** (`index out of bounds`)
|
||||
or **hang** (a swap unwinds but is never rewound).
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **The single slot is not a WebAssembly/Binaryen law — it's a choice in Emscripten's JS
|
||||
runtime.** The Binaryen Asyncify pass is multi-buffer by design; `asyncify_start_unwind`/
|
||||
`asyncify_start_rewind` take the buffer pointer as an argument.
|
||||
- **"Give each context its own buffer" is the standard, supported solution** — it's literally
|
||||
what Emscripten *fibers* are, and what QEMU/TinyGo/Pyodide do. We already do it for tool
|
||||
coroutines (each `wasm_fcontext` owns a buffer). The bug is that the **fiber path and the
|
||||
`handleSleep` path both blindly overwrite the one global `currData` register.**
|
||||
- **We are not switching to JSPI.** The fix stays within Asyncify, in the wasm/shim layer.
|
||||
- **The achievable universal fix** is a single cooperative scheduler that owns `currData` (and
|
||||
the fiber trampoline) and treats every suspendable thing — coroutines, modals, clipboard,
|
||||
fonts, and the main loop itself — as a registered context with its own buffer.
|
||||
|
||||
## Document index
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| [`01-background-and-findings.md`](01-background-and-findings.md) | The originating session, the e2e test + logs, and the three concrete bugs (rtree=fixed; clipboard crash; tool-open hang). |
|
||||
| [`02-asyncify-internals.md`](02-asyncify-internals.md) | The machine, in detail: what "sleeps", the single `currData`/`state`, the three producers, `handleSleep`/`fiber_swap`/trampoline, the #9153 shim, and full control-flow walkthroughs (startup→park, the hang, the crash, **de-parking** down to the JS line). |
|
||||
| [`03-solutions-and-prior-art.md`](03-solutions-and-prior-art.md) | Is there a real solution? Per-context buffers, who has done it, why we're not switching to JSPI, and the unified-authority recipe with failure modes. Sources/URLs included. |
|
||||
| [`04-decisions-tests-open-questions.md`](04-decisions-tests-open-questions.md) | How the fix options relate (what's subsumed vs. genuinely separate), the one diagnostic that decides scope, the combinatorial test matrix, and open questions. |
|
||||
| [`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. |
|
||||
|
||||
## The single decisive next step
|
||||
|
||||
Before designing anything, **measure whether `Asyncify.currData` is clean (null) at the moment
|
||||
`wxGUIEventLoop::DoRun()` throws `"unwind"`** (and at the first rAF tick, and at the first
|
||||
post-startup `emscripten_fiber_swap`). That one fact determines whether the universal fix must
|
||||
also reshape the main loop ("de-parking") or whether a per-context `currData` authority alone
|
||||
suffices. Details in [`04-decisions-tests-open-questions.md`](04-decisions-tests-open-questions.md).
|
||||
401
docs/features/async/async-research.md
Normal file
401
docs/features/async/async-research.md
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
# 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`: call `startAsync(wakeUp)`. If `wakeUp` is **not** called
|
||||
synchronously, a real suspend begins (`:10219`):
|
||||
`state=Unwinding; currData = allocateData(); MainLoop.pause(); start_unwind()`.
|
||||
→ the C stack unwinds; locals spill into `currData`'s buffer.
|
||||
- Promise resolves → `wakeUp(result)` (`:10169`):
|
||||
`state=Rewinding; start_rewind(currData); MainLoop.resume(); doRewind(currData)`.
|
||||
`doRewind` reads **field #8 of the buffer** to learn *which exported function to re-enter*
|
||||
(`getDataRewindFuncName`, `:10143`). **If `currData` is 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** touch `MainLoop`.
|
||||
|
||||
### fiber swap — the coroutine road (`pcbnew.js:11557`)
|
||||
```js
|
||||
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":
|
||||
```js
|
||||
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:**
|
||||
1. `finishContextSwitch` re-enters wasm (`doRewind`/`dynCall_vi`). If that re-entered code
|
||||
itself unwinds before returning, the `do/while` is abandoned with
|
||||
`Fibers.trampolineRunning === true` (the `:11533` reset never runs). **Every future
|
||||
`Fibers.trampoline()` then fails the guard and returns immediately** → pending `nextFiber`
|
||||
never processed → **hang.** (This is the precise facet the pasted `try/finally`
|
||||
"self-heal" addresses — it forces the guard back to `false`.)
|
||||
2. fiber buffers come from `emscripten_fiber_init`, **not** `Asyncify.allocateData`, so the
|
||||
`handlesleep.js` shim (§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()` (and `wxEntryReal`, `OnRun`, `DoRun`) is **gone**.
|
||||
- **`CallOnExit::~CallOnExit()` (→ `OnExit()`) and `wxEntryCleanupReal()` NEVER RUN** — the
|
||||
app, frames, and tools stay alive. *This is the entire purpose of `simulate_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`)
|
||||
pumps `ProcessPendingEvents` + `Paint` + every-third `ProcessIdle`, 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"`:
|
||||
|
||||
1. **Dangling `currData` from the abnormal teardown.** The park abandons the C stack via a JS
|
||||
throw **without** running `stop_unwind/stop_rewind` or resetting the Asyncify globals. If
|
||||
the startup burst left an in-flight or half-settled fiber context at the moment `DoRun`
|
||||
threw, `currData` stays non-null into idle. The next `emscripten_fiber_swap` enters the
|
||||
`state==Normal` branch and **overwrites** `currData` with `old+20`, orphaning the dangling
|
||||
buffer; when control later needs the orphaned context, its rewind can never be issued → hang.
|
||||
|
||||
2. **Stuck trampoline guard.** If any swap inside `Fibers.trampoline`'s `do/while` unwound and
|
||||
never returned (§3, fragility #1), `trampolineRunning` is stuck `true`, so the first
|
||||
post-startup swap's `nextFiber` is scheduled but the trampoline early-returns and never
|
||||
rewinds it → hang.
|
||||
|
||||
> **Diagnostic that would disambiguate (cheap, no rebuild):** log `Asyncify.currData`,
|
||||
> `Asyncify.state`, and `Fibers.trampolineRunning` (a) at the last line of `DoRun` *before* the
|
||||
> throw, (b) on the first rAF tick, (c) at the entry of the first post-startup
|
||||
> `emscripten_fiber_swap`. Compare `currData` against `g_main_context`'s buffer and against any
|
||||
> live coroutine's `fiber+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:**
|
||||
1. `emscripten_set_main_loop(..., 0)` so `DoRun` returns with clean Asyncify state, **and**
|
||||
2. **suppress the destructive post-`MainLoop` teardown** so the live app isn't freed. Options
|
||||
for (2), in ascending invasiveness:
|
||||
- **(2a)** In the wasm `wxApp`/event-loop path, make the `OnRun`→`DoRun` return *not* fall
|
||||
into `~CallOnExit`/`wxEntryCleanupReal` (e.g. a wasm-specific `OnRun` that returns through
|
||||
a path which skips cleanup), and instead run cleanup from **`UnloadCallback`**
|
||||
(`app.cpp:620`, registered `:694`) on `beforeunload`. This keeps teardown for real page
|
||||
exit and removes it from the steady state.
|
||||
- **(2b)** Keep `wxEntryReal` as-is but guard `wxEntryCleanupReal` / `OnExit` to 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.
|
||||
|
||||
**What de-parking does and does NOT fix:**
|
||||
- **Fixes:** the dangling-`currData`-at-idle mechanism (§5#1) and the
|
||||
null-`IsModal` close 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
|
||||
`ProcessEvents` drives a tool coroutine) still contend for one `currData`. 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 /
|
||||
`-fexceptions` invoke wrappers.
|
||||
- **Resume target:** continuation / **virtual call** (`invoke_vi→dynCall_vi`, the `vcall_repro`
|
||||
smoking gun).
|
||||
- **Assert per cell:** (1) no `index out of bounds` / `indirect call to null` / `unwind`
|
||||
rejection (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) and
|
||||
`fiber_swap_after_main_park` (the §5 hang — requires the harness to actually install a
|
||||
`simulate_infinite_loop=1` main 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/finally` in `inject-dyncall-shims.sh`): forces
|
||||
`Fibers.trampolineRunning=false` even 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`** (generalize `handlesleep.js` into 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
|
||||
1. Run the §5 diagnostic — is the idle `currData` the orphaned-from-park buffer (#1), or is the
|
||||
trampoline guard stuck (#2), or both? This decides whether Stage 1 alone meaningfully helps.
|
||||
2. For Stage 2, which suppression shape (2a UnloadCallback-driven cleanup vs 2b guarded
|
||||
`wxEntryCleanupReal`) is least invasive given our `wxApp`/`init.cpp` fork delta? (Check
|
||||
`scripts/kicad-diff-stats.sh` and the current wx fork divergence first.)
|
||||
3. 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.)
|
||||
76
docs/features/wasm-exceptions/01-background-two-eh-models.md
Normal file
76
docs/features/wasm-exceptions/01-background-two-eh-models.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# 01 — The two exception-handling models, and where ours bleeds into Asyncify
|
||||
|
||||
## What we build with today
|
||||
|
||||
Every layer is on **`-fexceptions`** = Emscripten's JavaScript-based EH:
|
||||
|
||||
- `scripts/build-wxuniversal-wasm.sh:141-142` (wxWidgets CFLAGS/CXXFLAGS)
|
||||
- `scripts/kicad/build-kicad-target.sh:203/207/211/214` (KiCad, with the comment at `:196`:
|
||||
"-fexceptions is required because wxWidgets is built with exceptions enabled")
|
||||
- `tests/apps/Makefile.wasm` (standalone test apps)
|
||||
|
||||
No `SUPPORT_LONGJMP` is set anywhere → default `emscripten` flavor (JS-based setjmp/longjmp
|
||||
through the same machinery).
|
||||
|
||||
## Model 1 — JS-based EH (`-fexceptions`), what actually happens
|
||||
|
||||
- A C++ `throw` compiles to a call out to JS (`__cxa_throw`), which throws a **JavaScript
|
||||
exception** carrying an i32 pointer (the C++ exception object in linear memory).
|
||||
- Every call that may need cleanup-during-unwind (anything inside a `try`, or in a function
|
||||
with destructors on the unwind path) is routed through an **`invoke_<sig>` wrapper** — a
|
||||
JS function that does `stackSave()`, calls back into the wasm table inside a JS
|
||||
`try/catch`, and on catch runs `stackRestore(sp)` + `_setThrew(1)` so the wasm side can
|
||||
run destructors and re-dispatch. The generated `pcbnew.js` contains **1,049 `invoke_`
|
||||
references**.
|
||||
- Consequence: a "C++ call stack" is really a **wasm→JS→wasm→JS sandwich**, and C++
|
||||
exceptions physically travel through JS frames.
|
||||
|
||||
## Model 2 — native wasm-EH (`-fwasm-exceptions`)
|
||||
|
||||
- `try`/`catch`/`throw` are **wasm instructions** (the exception-handling proposal;
|
||||
standardized, supported in all modern browsers — note the legacy encoding vs the newer
|
||||
`exnref`/`try_table` encoding, controlled by Emscripten's `WASM_LEGACY_EXCEPTIONS`,
|
||||
default legacy as of this writing).
|
||||
- No `invoke_*` wrappers exist at all. Throws never enter JS. The non-throwing path of a
|
||||
`try` is ~zero-cost in modern engines. `SUPPORT_LONGJMP=wasm` rides the same instructions.
|
||||
|
||||
## The three concrete couplings into our Asyncify machine
|
||||
|
||||
**1. JS-EH is why Asyncify instruments almost everything.**
|
||||
`scripts/common/apply-asyncify.sh:33`:
|
||||
|
||||
```
|
||||
ASYNCIFY_IMPORTS="env.invoke_*,env.__asyncjs__*,env.emscripten_fiber_swap"
|
||||
```
|
||||
|
||||
`env.invoke_*` is listed because suspensions genuinely flow through invokes (a modal opened
|
||||
inside a parser's `try`, a tool yielding inside a try-scope). But every invoke is an import
|
||||
call, so marking them suspension-capable breaks the static call graph at ~1,049 places and
|
||||
the "can this suspend?" analysis answers *yes* for most of KiCad → near-total
|
||||
instrumentation. This is the root of: the 338 MB pre-O2 binary, the V8 per-function-locals
|
||||
silent-stall family (see memory/`chrome-asyncify-rewind-crash`), the `ASYNCIFY_REMOVE`
|
||||
list, and the mandatory post-asyncify `wasm-opt -O2` pass. Under wasm-EH the imports list
|
||||
shrinks to `env.__asyncjs__*,env.emscripten_fiber_swap` and instrumentation collapses to
|
||||
the genuine suspend chains.
|
||||
|
||||
**2. JS-EH is why our wasm stacks have JS frames in the middle.**
|
||||
Every asyncify unwind must early-return through, and every rewind must re-establish, the
|
||||
invoke sandwich layers. The async dossier's test matrix calls out "`-fexceptions` invoke
|
||||
wrappers" as a failure axis and `vcall_repro` (`invoke_vi`→`dynCall_vi`) as the smoking
|
||||
gun. Phases 1–2 of `scripts/common/inject-dyncall-shims.sh` (per-signature dynCall
|
||||
bindings, empty-callback fixes) exist substantially to keep this machinery alive after
|
||||
asyncify+O2.
|
||||
|
||||
**3. JS-EH is why C++ throws can interact with the asyncify JS runtime at all.**
|
||||
Under JS-EH a C++ exception *is* a JS exception: it can tear through
|
||||
`Fibers.trampoline()`'s `do/while` exactly like the `throw "unwind"` park does (the §3c
|
||||
trampoline self-heal in `inject-dyncall-shims.sh` guards that), and the invoke wrapper's
|
||||
catch performs `stackRestore(sp)` — a stack-pointer write from a JS frame — while fibers
|
||||
juggle the same stack pointer. Under wasm-EH this entire interaction class disappears.
|
||||
|
||||
## What wasm-EH does NOT change
|
||||
|
||||
The Asyncify *correctness* problems (single global `Asyncify.currData`, the crash/hang
|
||||
family in `docs/features/async/`) are identical under either EH mode. Modals, clipboard, fonts,
|
||||
and fiber swaps still suspend through the same register. wasm-EH is a de-bloating /
|
||||
de-fragilizing move, not a concurrency fix. The arbiter is needed either way.
|
||||
93
docs/features/wasm-exceptions/02-measurements.md
Normal file
93
docs/features/wasm-exceptions/02-measurements.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# 02 — Measurements: our controlled experiment + published benchmarks
|
||||
|
||||
## §1. Our experiment (pcbnew, 2026-06-10)
|
||||
|
||||
**Question:** how much of the asyncify size tax is invoke-driven (i.e., would vanish under
|
||||
wasm-EH)?
|
||||
|
||||
**Method:** take the saved pre-asyncify artifact `output/pcbnew.finalized.wasm` (111 MB,
|
||||
post-link, post-finalize, pre-asyncify) and run the production post-link pipeline
|
||||
(`apply-asyncify.sh`: `wasm-opt --asyncify` + `wasm-opt -O2`, Binaryen v121) three ways,
|
||||
into `output/measure/` (originals untouched):
|
||||
|
||||
1. **O2-only** (no asyncify) — the floor; *not a shippable config* (the app needs asyncify).
|
||||
2. **Asyncify, `env.invoke_*` dropped** from the imports list — the wasm-EH proxy.
|
||||
3. **Asyncify, full imports** — the shipped configuration (control).
|
||||
|
||||
Control sanity check: variant 3 reproduced the production `pcbnew.wasm` size exactly
|
||||
(187 MB), so the pipeline replication is faithful.
|
||||
|
||||
**Results:**
|
||||
|
||||
| Variant | Raw | gzip -9 |
|
||||
|---|---|---|
|
||||
| O2-only floor | 77.6 MB | 19.8 MB |
|
||||
| Asyncify w/o invokes (wasm-EH proxy) | 122.1 MB | 36.0 MB |
|
||||
| Asyncify full (shipped) | 187.3 MB | 64.5 MB |
|
||||
|
||||
**Decomposition:**
|
||||
|
||||
- Total asyncify tax today: **+109.7 MB raw = +141% over floor** (+44.7 MB gzip). That is
|
||||
~2× the worst-case figures in the published literature — KiCad is an extreme asyncify
|
||||
case, and the invoke graph is why.
|
||||
- **Invoke-driven share of the tax: 59% raw (65.2 MB), 64% gzip (28.5 MB).** This is the
|
||||
part wasm-EH eliminates.
|
||||
- The remaining no-invoke tax (+44.5 MB, +57% over floor) is the legitimate cost of the
|
||||
real suspend chains (fibers + modal/clipboard sleeps) — in line with the published ~50% —
|
||||
and is kept under any design.
|
||||
|
||||
**Headline:** wasm-EH ≈ **download 64.5 → 36.0 MB (−44%), module 187 → 122 MB (−35%)**,
|
||||
before counting runtime. The real wasm-EH build differs from the proxy in both directions:
|
||||
it also deletes the invoke wrappers + JS-EH glue outright (smaller still) while adding
|
||||
wasm-EH's own try/catch instructions (~+4% per published prototype data).
|
||||
|
||||
**Caveats:** (a) the no-invoke proxy is **semantically unsound under `-fexceptions`** —
|
||||
with invokes present in JS but uninstrumented in wasm, the first suspension crossing a try
|
||||
frame corrupts state; measurement artifact only, never ship. (b) The finalized input is a
|
||||
May 26 snapshot while today's apps are newer — irrelevant for the *relative* comparison
|
||||
(same input, same tool, only the imports list varies). (c) Runtime gain is not directly
|
||||
measurable from these artifacts; the size-tracks-speed heuristic (Zakai) plus the published
|
||||
reports below are the basis for the runtime estimate.
|
||||
|
||||
## §2. Published benchmarks and field reports (researched 2026-06-10)
|
||||
|
||||
No clean head-to-head JS-EH vs wasm-EH benchmark of a large C++ app exists; triangulate:
|
||||
|
||||
**Asyncify instrumentation tax (the thing our invoke graph inflates):**
|
||||
|
||||
| Source | Workload | Numbers |
|
||||
|---|---|---|
|
||||
| Alon Zakai, "Pause and Resume… Asyncify" (2019, canonical) | multiple benchmarks | size ~**+50%** typical, ≤2× worst; speed tracks size; **22%** Fannkuch; **5×** pathological when one huge function dominates (SQLite 150→300 KB) |
|
||||
| wa-sqlite (production, measured) | SQLite wasm | default asyncify **+70.6%** → **+35.3%** after narrowing instrumentation to the ~6 actually-async entry points (auto-detection had instrumented 700+ functions) |
|
||||
| Wasmer PHP/WordPress (production) | PHP setjmp/longjmp via asyncify | **1.5× size, 2× slower**; replacing with wasm-EH SjLj: **2×** faster cold start (120→60 ms), separately **4×** (100→25 ms) |
|
||||
| WordPress Playground | auto-detected asyncify list | ~70,000 functions instrumented, startup +4.5 s, fixed by manual list |
|
||||
|
||||
**JS-EH (`invoke_*`) tax:**
|
||||
|
||||
| Source | Workload | Numbers |
|
||||
|---|---|---|
|
||||
| OpenCascade.js (CAD kernel — closest cousin to KiCad's code character) | STEP file load | global JS-EH **~10 min → ~2 min (~5×)** with narrowed EH; "performance degradation and file size impact is currently massive" |
|
||||
| emscripten discussion #17526 | JS-EH → wasm-EH | "not just 2× faster, but an order of magnitude" (anecdote) |
|
||||
| Emscripten docs (Box2D) | `-fno-rtti -fno-exceptions` | −15% size (RTTI+EH combined; only weak isolation) |
|
||||
|
||||
**wasm-EH's own cost (what we'd pay instead):**
|
||||
|
||||
| Source | Numbers |
|
||||
|---|---|
|
||||
| Heejin Ahn, TPAC slides (prototype) | **+4%** whole-module instructions, **+11%** in exception-using functions |
|
||||
| V8 / Emscripten docs | non-throwing path **~zero cost** by design |
|
||||
|
||||
**Most transferable to us:** wa-sqlite's 70.6→35.3 (the narrowing effect — ours measured
|
||||
59–64%) and OpenCascade's ~5× (exception-dense C++ file loading — our `LoadBoard` path).
|
||||
Triangulated runtime expectation for KiCad: **1.5–3× on load/parse/geometry paths**, more
|
||||
on exception-dense I/O paths.
|
||||
|
||||
## §3. Reproducing
|
||||
|
||||
```sh
|
||||
# control (uses the project script unmodified):
|
||||
./scripts/common/apply-asyncify.sh output/pcbnew.finalized.wasm output/measure/pcbnew.asyncify-full.wasm
|
||||
# proxy: same script with ASYNCIFY_IMPORTS="env.__asyncjs__*,env.emscripten_fiber_swap"
|
||||
# floor: $(scripts/common/get-wasm-opt.sh | tail -1) -O2 in.wasm -o out.wasm
|
||||
# then: ls -l + gzip -9c | wc -c
|
||||
```
|
||||
81
docs/features/wasm-exceptions/03-toolchain-status.md
Normal file
81
docs/features/wasm-exceptions/03-toolchain-status.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# 03 — Toolchain compatibility status (verified 2026-06-10/11)
|
||||
|
||||
## The compatibility matrix
|
||||
|
||||
| Combination | Status |
|
||||
|---|---|
|
||||
| `ASYNCIFY=1` + `-fexceptions` (ours today) | Supported; the expensive-but-working baseline |
|
||||
| `ASYNCIFY=1` + `-fwasm-exceptions` | emcc emits a **warning, not an error** ("Parts of the program that mix ASYNCIFY and exceptions will not compile"); Binaryen support is **partial** — everything except unwinding from inside a catch arm (see 05) |
|
||||
| `SUPPORT_LONGJMP=emscripten` + `-fwasm-exceptions` | **Hard error** in emcc; wasm-EH pairs with `SUPPORT_LONGJMP=wasm` (automatic default) |
|
||||
| JSPI (`-sJSPI`) + `-fwasm-exceptions` | Compatible (JSPI doesn't transform the wasm) — **but fibers do not exist under JSPI** (`Asyncify.setDataHeader is not a function`, emscripten #18180), and KiCad tools are fibers → JSPI stays closed for us |
|
||||
| Mixing `-fexceptions` and `-fwasm-exceptions` objects at link | Not supported; produces inconsistent internal state (emscripten #20165, #18500) — flag flip must be uniform across wx + kicad + test apps |
|
||||
|
||||
## Binaryen history (the part that moved since our earlier research)
|
||||
|
||||
- **Partial asyncify+wasm-EH support merged upstream 2025-11-19**, commit `ad13362b`
|
||||
"Add partial support for -fwasm-exceptions in Asyncify (#5343) (#5475)" — these are the
|
||||
caiiiycuk PRs from 2022/2023 (the author previously maintained the
|
||||
`caiiiycuk/binaryen-fwasm-exceptions` fork). Shipped in **binaryen v125**
|
||||
(released the same day). Upstream is at **v130** (2026-06-01).
|
||||
- The merge is **+114 lines** in `src/passes/Asyncify.cpp` plus 1,139 lines of lit tests
|
||||
(`asyncify_pass-arg=asyncify-eh*.wast`): Try-body traversal + an asserts-mode tripwire.
|
||||
Details and the remaining hole in [`05-asyncify-fork-design.md`](05-asyncify-fork-design.md).
|
||||
- Notable gaps in current main: the documented `asyncify-ignore-unwind-from-catch` pass-arg
|
||||
is **not consumed anywhere in the code** (dead docs); **`TryTable` (standardized
|
||||
exnref EH encoding) is entirely unsupported** by the pass; tail calls remain fatal.
|
||||
- Tracking issue for full support: **WebAssembly/binaryen #4470** (open).
|
||||
|
||||
## Our local toolchain
|
||||
|
||||
- emsdk-bundled `wasm-opt`: **v121** (`version_121-72-g7353da707`) — verified **612 commits
|
||||
behind** the EH merge. We do not have even the partial support locally.
|
||||
- This only matters for the **post-link step**: `scripts/common/apply-asyncify.sh` resolves
|
||||
its binary via `scripts/common/get-wasm-opt.sh`, so a newer/patched wasm-opt can be
|
||||
slotted in for asyncify alone, without touching the emsdk compiler side.
|
||||
(`apply-finalize.sh` keeps using the emsdk `wasm-emscripten-finalize`; cross-version
|
||||
binary compatibility at the .wasm level is fine.)
|
||||
- The compiler side (LLVM emitting wasm-EH instructions) has been mature for years; the
|
||||
emcc warning about ASYNCIFY=1 is expected and survivable once the Binaryen side is fixed.
|
||||
|
||||
## setjmp/longjmp inventory (often raised as a blocker — it is not)
|
||||
|
||||
- KiCad: **zero** direct `setjmp`/libpng usage outside `thirdparty/` (and `thirdparty/`
|
||||
does not bundle libpng). All image I/O goes through `wxImage`.
|
||||
- wxWidgets bundles `png`, `jpeg`, `tiff`, `zlib`. Both libpng's `pngerror.c` and wx's own
|
||||
PNG handler use the classic pattern (`src/common/imagpng.cpp:319/:527` `setjmp`, `:204`
|
||||
`longjmp`); libjpeg's error manager likewise.
|
||||
- Today this rides JS-based SjLj (default `SUPPORT_LONGJMP=emscripten`, invoke machinery).
|
||||
Under wasm-EH it becomes `SUPPORT_LONGJMP=wasm` automatically, **no source changes**.
|
||||
Constraints don't bite: setjmp is not called from C++ catch clauses there, and the
|
||||
PNG/JPEG decode paths never suspend (pure computation → never asyncify-instrumented →
|
||||
the unwind-from-catch limitation cannot arise in them).
|
||||
|
||||
## The parked end-to-end experiment (2026-06-11) — what it taught us
|
||||
|
||||
A parallel session actually built pcbnew with wasm-EH end-to-end
|
||||
(`docs/wasm-exceptions-experiment.md`; full plumbing patch in its appendix, gated behind
|
||||
`KICAD_WASM_EH=1`). Findings that supersede/extend the matrix above:
|
||||
|
||||
- Working flag set: `-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=0`,
|
||||
uniformly on **every compile and link** (deps incl. boost/cairo/harfbuzz, wx, kicad).
|
||||
- Result: links clean, **raw pcbnew 92 MB** (vs 338 MB post-asyncify pre-O2 today), zero
|
||||
`invoke_*`/`dynCall` — corroborates this dossier's measurements from the compiler side.
|
||||
- **Blocker #1 (before the catch issue ever arises): emscripten 4.0.2's LLVM emits an
|
||||
invalid `br_table`** (label arity mismatch) in OpenCASCADE
|
||||
(`ShapeUpgrade_SplitSurface::Build`) under wasm-EH — module malformed at the source; no
|
||||
Binaryen can fix it. Resume = emsdk bump (5.0.7+), then full clean rebuild (~2.5–3 h).
|
||||
- **Encoding wrinkle:** 4.0.2's *legacy* encoding output failed Binaryen parsing
|
||||
(`popping from empty stack`), forcing `WASM_LEGACY_EXCEPTIONS=0` (exnref). But the
|
||||
asyncify pass has **zero `TryTable` support** (05 §2) — so after the emsdk bump, the
|
||||
encoding decision forks the asyncify work: legacy → v125 partial support + the
|
||||
catch-arm-hoisting pre-pass; exnref → TryTable support + exnref spilling (05 §new-EH).
|
||||
- Ops gotchas recorded there: deps scripts skip-if-stamped (flag changes don't trigger
|
||||
rebuilds — `undefined symbol: emscripten_longjmp` from stale `libcairo.a`); CI already
|
||||
self-builds Binaryen v130 for the post-link chain; build-time payoff is also large (the
|
||||
~52 min `-O2` pass shrinks with the module).
|
||||
|
||||
## Browser support notes
|
||||
|
||||
wasm-EH (legacy encoding) is supported across modern Chrome/Firefox/Safari. One watch item
|
||||
from research: a Safari 26.0 regression crashing `-fwasm-exceptions` apps at startup was
|
||||
reported September 2025 (emscripten #25365) — re-check status at migration time.
|
||||
75
docs/features/wasm-exceptions/04-kicad-audit.md
Normal file
75
docs/features/wasm-exceptions/04-kicad-audit.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# 04 — The KiCad catch-block audit
|
||||
|
||||
## Why an audit
|
||||
|
||||
Binaryen's asyncify cannot (yet) handle a suspension that begins while execution is inside
|
||||
a wasm catch handler (see 05). Any C++ `catch` whose handler (directly or transitively)
|
||||
opens a modal dialog, touches the async clipboard, etc., is therefore illegal under
|
||||
`-fwasm-exceptions` + ASYNCIFY=1 today. KiCad's standard error pattern is exactly that:
|
||||
|
||||
```cpp
|
||||
catch( const IO_ERROR& ioe )
|
||||
{
|
||||
DisplayErrorMessage( this, ioe.What() ); // → ShowModal → Asyncify suspension
|
||||
}
|
||||
```
|
||||
|
||||
## Method
|
||||
|
||||
[`catch_audit.py`](catch_audit.py): walks `kicad/**/*.cpp` (excluding `thirdparty/`, `qa/`),
|
||||
extracts every catch block with real brace matching (string/comment aware), and classifies
|
||||
each handler body:
|
||||
|
||||
- **direct_suspend** — contains a known suspending call (`DisplayError[Message]`,
|
||||
`DisplayInfoMessage`, `wxMessageBox`, `ShowModal`, `ShowQuasiModal`, `KIDIALOG`,
|
||||
`IsOK(`, clipboard ops, `wxFileDialog`, …).
|
||||
- **infobar** — only `ShowInfoBar*` (non-modal, non-suspending).
|
||||
- **trivial** — only rethrow / capture / format / logging. Note `wxLogError` is *safe*:
|
||||
its GUI display is deferred to the idle-time log flush, outside any catch.
|
||||
`PGM_BASE::HandleException` (`common/pgm_base.cpp:805`) was manually verified — it only
|
||||
`wxLogError`s → benign.
|
||||
- **needs_review** — calls functions not classifiable as benign; requires a transitive look.
|
||||
|
||||
Full output incl. all site locations: [`audit-results.txt`](audit-results.txt).
|
||||
Re-run: `python3 catch_audit.py` (path to the kicad tree is hardcoded at the top).
|
||||
|
||||
## Results (2026-06-10, kicad @ wasm-port head)
|
||||
|
||||
| Category | Count |
|
||||
|---|---|
|
||||
| **direct_suspend** | **85** |
|
||||
| needs_review | 93 (tail looks mostly benign — accessors/file ops; expect ~10–20 to become refactors on inspection) |
|
||||
| trivial/safe | 458 |
|
||||
| infobar-only | 0 |
|
||||
| **total** | **636** |
|
||||
|
||||
Per app (direct/review): eeschema 37/17, pcbnew 32/29, common 9/35, cvpcb 3/1, rest
|
||||
scattered. Concentrated exactly on the file-load error paths the e2e tests exercise:
|
||||
`pcbnew/files.cpp`, `eeschema/files-io.cpp`, `footprint_libraries_utils.cpp`,
|
||||
`symbol_library_manager.cpp`, the design-block utils.
|
||||
|
||||
wxWidgets adds essentially nothing (zero catch-with-dialog sites in its own code).
|
||||
|
||||
## The hand-refactor option (superseded by the fork, kept for the record)
|
||||
|
||||
Mechanical hoist per site:
|
||||
|
||||
```cpp
|
||||
wxString err;
|
||||
try { ... }
|
||||
catch( const IO_ERROR& ioe ) { err = ioe.What(); } // capture only
|
||||
if( !err.IsEmpty() ) DisplayErrorMessage( this, err ); // suspend OUTSIDE the catch
|
||||
```
|
||||
|
||||
Effort: 85 hoists (~15–30 min each; error-UX paths with near-zero test coverage) ≈ 3–5
|
||||
dev-days; 93 reviews ≈ 2–3 dev-days; destructor-during-unwind audit ≈ half day (cleanup
|
||||
pads = `catch_all`; dialogs from destructors expected zero); **plus** permanent
|
||||
upstream-sync policing (every KiCad merge adds new `catch { DisplayError }` sites —
|
||||
`catch_audit.py` as a CI gate automates detection), **plus** the fork-divergence cost
|
||||
against the "stay close to upstream" policy. Total ~2–3 weeks one-time + maintenance tax,
|
||||
with hard-crash failure modes for any missed transitive site (asyncify asserts mode traps
|
||||
deterministically — good in CI, fatal in production).
|
||||
|
||||
**Verdict:** with the catch-arm-hoisting fork (05) this entire refactor becomes
|
||||
unnecessary — all 85 direct sites are C++ catches, which the fork makes legal. Only
|
||||
suspend-inside-`catch_all`-cleanup remains forbidden, which KiCad does not do.
|
||||
170
docs/features/wasm-exceptions/05-asyncify-fork-design.md
Normal file
170
docs/features/wasm-exceptions/05-asyncify-fork-design.md
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
# 05 — Asyncify.cpp internals and the catch-arm-hoisting fork design
|
||||
|
||||
> Based on a direct read of `src/passes/Asyncify.cpp` @ binaryen main (2,030 lines,
|
||||
> 2026-06-11) and the merged partial-support commit `ad13362b` (+114 lines + 1,139 test
|
||||
> lines, shipped in v125).
|
||||
|
||||
## §1. How the pass works (the 60-second model)
|
||||
|
||||
From the header comment (lines 56–97): three states (`__asyncify_state` =
|
||||
Normal/Unwinding/Rewinding).
|
||||
|
||||
- **Unwind:** every instrumented call is followed by
|
||||
`if (unwinding) { note call index; save locals; return }` — the stack collapses via
|
||||
ordinary early returns, recording a path of call indices into the asyncify buffer.
|
||||
- **Rewind:** re-enter the function from the top and *fall forward*: every non-call
|
||||
statement is wrapped `if (state == Normal)` (skipped while rewinding); every call in
|
||||
`if (normal OR call-index-matches)` — control "skips along" to the suspended call and
|
||||
re-enters callees the same way. Locals are restored from / spilled to linear memory
|
||||
(`AsyncifyLocals`).
|
||||
- Pipeline (per instrumented function): `flatten` → `dce` → optimizers → `AsyncifyFlow`
|
||||
(call-index + skip logic) → [asserts walkers] → `AsyncifyLocals` (spill/restore).
|
||||
|
||||
## §2. What the v125 "partial support" actually is
|
||||
|
||||
- `AsyncifyFlow` traverses a `Try`'s **body** like any control flow (suspending inside a
|
||||
try body works), but **`catchBodies` are skipped wholesale** — line ~1172:
|
||||
*"catchBodies are ignored because we assume that pause/resume will not happen inside
|
||||
them."* Handler code gets no call indices, no unwind checks, no rewind steering.
|
||||
- Asserts mode adds `AsyncifyUnwindWalker` (lines ~1358–1420): every call lexically inside
|
||||
a catch arm (found via the expression stack: inside a `Try` but not in its `body`) is
|
||||
wrapped with `if (state != Normal) unreachable` — a **tripwire, not support**. Without
|
||||
asserts, a suspend inside a catch silently corrupts (callee early-returns with
|
||||
state=Unwinding; the uninstrumented handler barrels on with a garbage value).
|
||||
- Dead doc: the header documents `--pass-arg=asyncify-ignore-unwind-from-catch`, but the
|
||||
flag is **never consumed** in current main.
|
||||
- **`TryTable` (standardized exnref EH) is not handled at all** — it would hit
|
||||
`WASM_UNREACHABLE("unexpected expression type")` in the flow walker.
|
||||
|
||||
## §3. Why catch arms are structurally hard (not just unimplemented)
|
||||
|
||||
Rewind steers with plain `if`s and forward motion. A wasm **catch arm cannot be entered by
|
||||
falling into it** — only the engine's exception dispatch transfers control there. So
|
||||
"rewind back into the middle of a catch handler" is impossible in this scheme: you would
|
||||
have to re-throw, which would re-run (or mis-skip) the try body in rewind state. Separately,
|
||||
the in-flight caught-exception state (what `rethrow` consumes) is not something asyncify
|
||||
can save to linear memory.
|
||||
|
||||
## §4. The fork: catch-arm hoisting (outlining)
|
||||
|
||||
Stop needing to re-enter catch arms — make the handler ordinary code. A pre-transform on
|
||||
structured IR (before `flatten`), applied only to instrumented functions whose catch arms
|
||||
contain potentially-state-changing calls (the `ModuleAnalyzer` already knows):
|
||||
|
||||
```wat
|
||||
(try (do BODY)
|
||||
(catch $__cpp_exception HANDLER)) ;; payload: i32 exception ptr
|
||||
;; becomes:
|
||||
(block $done
|
||||
(try (do BODY)
|
||||
(catch $__cpp_exception ;; arm now contains ONLY:
|
||||
(local.set $exn (pop i32)) ;; capture payload
|
||||
(local.set $inCatch (i32.const 1)))) ;; set flag — nothing suspendable
|
||||
(if (i32.eqz (local.get $inCatch)) (br $done))
|
||||
HANDLER') ;; hoisted: plain straight-line code
|
||||
```
|
||||
|
||||
After this, the existing machinery does everything for free: `HANDLER'` gets call indices,
|
||||
unwind checks, and rewind steering like any other code; `$exn` is an ordinary i32 local, so
|
||||
`AsyncifyLocals` spills/restores it across suspension automatically. The upstream invariant
|
||||
("no pause/resume inside catchBodies") becomes true **by construction**. The `br $done`
|
||||
guard is state-aware, so during rewind control falls into `HANDLER'` and the call-index
|
||||
peek steers to the suspended call.
|
||||
|
||||
### The rethrow problem and its translation
|
||||
|
||||
LLVM's C++ lowering places a `rethrow` in catch arms on the personality **no-match** path
|
||||
(user-level `throw;` compiles to a `__cxa_rethrow()` *call* — no instruction issue).
|
||||
`rethrow` is only valid lexically inside a catch, so hoisted code must translate:
|
||||
|
||||
```
|
||||
rethrow $T → throw $__cpp_exception (local.get $exn)
|
||||
```
|
||||
|
||||
Sound for the C++ tag because the i32 payload *is* the exception identity; all libc++abi
|
||||
state is driven by the `__cxa_*` calls, preserved verbatim (`__cxa_begin_catch` is only
|
||||
called on the matched path, so the no-match translation does not disturb handler counts).
|
||||
Known observable delta: an exception escaping to JS after such a re-throw is a fresh
|
||||
`WebAssembly.Exception` (object identity / stack trace) — acceptable, document it.
|
||||
|
||||
### Hard limits (kept deliberately)
|
||||
|
||||
- Only tags whose payload fully identifies the exception are hoistable
|
||||
(`__cpp_exception`'s i32 qualifies). **`catch_all` arms — LLVM cleanup pads
|
||||
(destructors-during-unwind) and foreign-exception paths — cannot be hoisted** (no payload
|
||||
to re-throw); they keep the asserts tripwire. This is the right boundary for KiCad: all
|
||||
85 audited sites are C++ catches; suspending destructors-during-unwind is the case the
|
||||
audit expects zero of.
|
||||
- Make hoisting tag-configurable: `--pass-arg=asyncify-hoist-catch-tags@__cpp_exception`.
|
||||
|
||||
### Fiddly-but-tractable engineering list
|
||||
|
||||
- Tries with concrete result types: route arm values through a temp local.
|
||||
- Nested tries inside hoisted handlers: recurse; resolve each `rethrow`'s target Try and
|
||||
translate only those referring to hoisted-from tries (payload local per hoisted try).
|
||||
- `delegate` arms: untouched by arm hoisting (they live on the body side), but must be
|
||||
regression-tested.
|
||||
- Label scoping: hoist to immediately-after-the-try (same enclosing blocks) so every outer
|
||||
branch target stays in scope.
|
||||
- Pipeline placement: run pre-`flatten`, per-function, only where the analyzer says a catch
|
||||
arm can change state — keeps the transform off the 99% of tries that never suspend.
|
||||
- Tests: extend the v125 lit tests (`asyncify_pass-arg=asyncify-eh*.wast`); binaryen's
|
||||
fuzzer has asyncify support — use it.
|
||||
|
||||
### The new-EH (TryTable) variant, for later
|
||||
|
||||
Under `try_table`/exnref the handlers are *already* plain blocks outside the try — the
|
||||
hoisted shape is the native shape. The hard part there is exnref liveness across
|
||||
suspension (reference types cannot be stored to linear memory): park the exnref in an
|
||||
auxiliary exnref table slot, save the slot index in the asyncify buffer, `throw_ref` from
|
||||
the slot on the cleanup path. Worth doing when Emscripten flips `WASM_LEGACY_EXCEPTIONS`
|
||||
off by default; until then the legacy hoisting fork is the pragmatic target.
|
||||
|
||||
**Reality check from the parked experiment (03 §experiment):** emscripten 4.0.2's
|
||||
*legacy*-encoding output failed Binaryen parsing outright, so the end-to-end experiment
|
||||
had to force `WASM_LEGACY_EXCEPTIONS=0` (exnref). Which variant of this design applies is
|
||||
therefore decided **after the emsdk bump** that fixes the LLVM `br_table` bug: if newer
|
||||
LLVM emits parseable legacy encoding → the §4 hoisting pre-pass; if exnref remains the
|
||||
only viable encoding → this §TryTable variant (larger: TryTable traversal in
|
||||
`AsyncifyFlow` + exnref spill machinery) becomes the required work. Budget accordingly
|
||||
before committing to either.
|
||||
|
||||
## §5. Effort, deployment, upstreaming
|
||||
|
||||
**Key deployment insight (2026-06-11): this is a PRE-pass, not a fork of Asyncify.cpp.**
|
||||
The hoisting is a standalone, semantics-preserving wasm→wasm rewrite on standard EH
|
||||
constructs. Run it before stock `--asyncify` and the stock pass's assumption ("no
|
||||
pause/resume inside catchBodies") holds by construction — zero changes to the asyncify
|
||||
pass itself. It can even share one invocation (`wasm-opt --hoist-cpp-catches --asyncify
|
||||
--pass-arg=… -O2` — passes run in listed order), avoiding an extra 111 MB roundtrip.
|
||||
|
||||
Three deployment grades:
|
||||
|
||||
1. **Zero-fork:** standalone native tool in our repo linking *stock* libbinaryen (C/C++
|
||||
API has full EH expression support). Costs one extra parse/write roundtrip per app.
|
||||
(`binaryen.js` is not viable — wasm32 4 GB ceiling vs our module sizes.)
|
||||
2. **Additive build (recommended):** one new file `src/passes/HoistCppCatches.cpp` + two
|
||||
registration lines; no existing binaryen code modified → rebases trivially.
|
||||
`get-wasm-opt.sh` already has the path: `BINARYEN_BUILD_FROM_SOURCE=1` clones a tag and
|
||||
builds wasm-opt (cached in `build-wasm/tools/`) — pointing the clone at our branch is a
|
||||
one-line change.
|
||||
3. **Upstream (end state):** because nothing in `Asyncify.cpp` changes, this is a pure
|
||||
addition — file the design on binaryen #4470 first; if accepted, grade 2 collapses
|
||||
into stock wasm-opt.
|
||||
|
||||
Notes:
|
||||
- **Size/effort:** ~400–800 lines + lit tests. **1–2 weeks** for someone comfortable in
|
||||
Binaryen IR (Builder, branch-utils), plus fuzzing time.
|
||||
- **Binaryen ≥ v125 is required for the `--asyncify` step regardless** (partial EH
|
||||
support); emsdk bundles v121. Already-solved infra: `BINARYEN_VERSION=130` makes
|
||||
`get-wasm-opt.sh` download the official release — v130 output was previously validated
|
||||
by the full e2e suite (adopted for the v121 -O2 slowness fix). Heed the script's
|
||||
version-skew warning: re-validate e2e after any bump.
|
||||
- **Mechanics:** preserve the names section (`-g`) if hoisting runs as a separate
|
||||
invocation (ASYNCIFY_REMOVE matches by name); verify EH feature flags ride the binary's
|
||||
`target_features`; start with hoist-all-cpp-catches (no call-graph analysis needed,
|
||||
`-O2` cleans up), keep selective hoisting (suspendable-closure only) as a refinement.
|
||||
- **Payoff coupling:** with this pass, the wasm-EH migration needs **no KiCad refactor**
|
||||
(04), and the measured −44% download / −35% module (02) becomes reachable with: binaryen
|
||||
bump + this pass + uniform flag flip + dropping `env.invoke_*` from
|
||||
`apply-asyncify.sh:33`.
|
||||
97
docs/features/wasm-exceptions/README.md
Normal file
97
docs/features/wasm-exceptions/README.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# `-fexceptions` vs `-fwasm-exceptions` in KiCad-WASM — research dossier
|
||||
|
||||
> **Status:** research / decision record. A parallel session attempted the migration
|
||||
> end-to-end and **parked it** on an emscripten-4.0.2 LLVM codegen bug — see
|
||||
> [`docs/wasm-exceptions-experiment.md`](../../wasm-exceptions-experiment.md) (full
|
||||
> build-plumbing patch preserved in its appendix; flags
|
||||
> `-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=0`, raw linked
|
||||
> pcbnew 92 MB and zero `invoke_*`/`dynCall`). This dossier is the research companion:
|
||||
> mechanism, measurements, audit, and the asyncify catch-block design.
|
||||
> Authored 2026-06-11/12. Companion to [`docs/features/async/`](../async/) (the Asyncify
|
||||
> `currData` contention dossier) — this dossier covers the *exception-handling* axis of
|
||||
> the same machine.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The whole build is on **`-fexceptions`** (Emscripten's JavaScript-based exception
|
||||
handling). That choice is not cosmetic — it is the single largest driver of our Asyncify
|
||||
cost: it forces `env.invoke_*` into `ASYNCIFY_IMPORTS`, which makes nearly the whole call
|
||||
graph "suspension-capable" and therefore instrumented. We **measured** the consequence on
|
||||
our own binary: 59% of the raw asyncify tax (64% of the gzipped tax) on pcbnew exists only
|
||||
because of the invoke machinery. Migrating to native **`-fwasm-exceptions`** would cut the
|
||||
shipped pcbnew download from **64.5 MB to ~36 MB (−44%)** and the module from
|
||||
**187 MB to ~122 MB (−35%)**, plus an unmeasured-but-real runtime win on every try-region
|
||||
hot path.
|
||||
|
||||
The migration is currently blocked by one upstream limitation — Binaryen's Asyncify pass
|
||||
cannot handle a suspension *inside a catch handler* — and KiCad triggers exactly that
|
||||
pattern (modal error dialogs from catch blocks) in **85 audited places**. This dossier
|
||||
records the mechanism, the measurements, the toolchain status, the audit, and a concrete
|
||||
**fork design (catch-arm hoisting)** that would remove the blocker without refactoring
|
||||
KiCad at all.
|
||||
|
||||
## TL;DR / decision
|
||||
|
||||
- **Stay on `-fexceptions` for now.** Asyncify stays under any design (fibers + EM_ASYNC_JS
|
||||
have no wasm-EH replacement); this is purely about how much it must instrument.
|
||||
- The prize is measured, not estimated: **−44% download, −35% module size** (see 02).
|
||||
- Binaryen merged *partial* asyncify+wasm-EH support in **v125** (2025-11-19). Our emsdk
|
||||
bundles **v121** — we don't even have the partial support locally.
|
||||
- The remaining hole — unwind-from-catch — is fixable with a **bounded new Binaryen pass**
|
||||
(catch-arm hoisting, ~400–800 lines, 1–2 weeks, genuinely upstreamable; see 05). It is a
|
||||
*pre-pass* before stock `--asyncify` — `Asyncify.cpp` itself needs zero changes, so
|
||||
"fork" overstates it (one added file; `get-wasm-opt.sh` already has the
|
||||
build-from-source deployment path). It **obsoletes the 85-site KiCad refactor** entirely.
|
||||
- **Blocker ordering (learned from the parked experiment):** the catch-block limitation
|
||||
is blocker #2. Blocker #1 is an **LLVM codegen bug in emscripten 4.0.2** (invalid
|
||||
`br_table` arity in OpenCASCADE code under wasm-EH) — needs an emsdk bump first. And the
|
||||
experiment had to force the **exnref encoding** (`WASM_LEGACY_EXCEPTIONS=0`, because
|
||||
4.0.2's legacy encoding output doesn't even parse in Binaryen), which collides with the
|
||||
fact that Binaryen's asyncify has **zero `TryTable` support**: the encoding choice after
|
||||
the emsdk bump decides which variant of the catch fix applies (see 03 §experiment, 05).
|
||||
- Trigger to act: when we are ready to invest ~2 weeks of toolchain work, or if upstream
|
||||
lands full support on binaryen #4470 first. Until then the Asyncify arbiter work
|
||||
(docs/features/async/) is the priority — it fixes shipping bugs and is needed either way.
|
||||
|
||||
## Document index
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| [`01-background-two-eh-models.md`](01-background-two-eh-models.md) | How JS-EH (`invoke_*`) and wasm-EH actually work, and the three concrete couplings into our Asyncify machine. |
|
||||
| [`02-measurements.md`](02-measurements.md) | Our controlled size experiment on pcbnew (methodology + numbers) and the published third-party benchmarks. |
|
||||
| [`03-toolchain-status.md`](03-toolchain-status.md) | Compatibility matrix: emcc checks, binaryen history (what merged in v125, what didn't), JSPI/fibers, setjmp/longjmp, mixing modes. |
|
||||
| [`04-kicad-audit.md`](04-kicad-audit.md) | The brace-matching catch-block audit: 85 direct / 93 review / 458 trivial of 636; libpng/libjpeg setjmp story; refactor effort if done by hand. |
|
||||
| [`05-asyncify-fork-design.md`](05-asyncify-fork-design.md) | Asyncify.cpp internals, why catch arms are structurally hard, and the catch-arm-hoisting fork design with limits and effort. |
|
||||
| [`catch_audit.py`](catch_audit.py) | The audit tool (re-runnable; suitable as a CI gate on the kicad submodule). |
|
||||
| [`audit-results.txt`](audit-results.txt) | Full audit output incl. all 85 direct-suspend sites. |
|
||||
|
||||
## Relationship to docs/features/async/
|
||||
|
||||
Independent axes of the same machine. The async dossier is about *correctness* (one global
|
||||
`Asyncify.currData` shared by overlapping suspensions → crash/hang); this dossier is about
|
||||
*cost* (how much code Asyncify instruments). Fixing one does not fix the other. Sequencing:
|
||||
async arbiter first (shipping bugs), wasm-EH migration second (size/speed), and the
|
||||
migration plan below assumes the arbiter exists.
|
||||
|
||||
## Migration plan (when triggered)
|
||||
|
||||
0. Resume the parked experiment (`docs/wasm-exceptions-experiment.md`): bump emsdk past
|
||||
the 4.0.2 LLVM `br_table` bug, `git apply` its appendix patch (`KICAD_WASM_EH=1`
|
||||
gated), full clean deps rebuild (stamp-skip gotcha: stale sjlj objects in cairo etc.).
|
||||
Then decide the EH encoding: if newer LLVM emits parseable **legacy** encoding, the
|
||||
catch-arm-hoisting pre-pass (05) applies; if **exnref** stays forced, asyncify needs
|
||||
`TryTable` support + exnref spilling instead (05 §new-EH variant).
|
||||
1. Newer Binaryen for the post-link step only: `scripts/common/get-wasm-opt.sh` already
|
||||
abstracts the binary — point it at a ≥ v125 build carrying the hoisting patch (05).
|
||||
2. Validate partial support first: rebuild one app `-fwasm-exceptions` + asyncify-asserts,
|
||||
run the e2e suites; the asserts tripwire makes any missed unwind-from-catch a
|
||||
deterministic trap.
|
||||
3. Uniform flag flip: `-fexceptions` → `-fwasm-exceptions` in
|
||||
`scripts/build-wxuniversal-wasm.sh:141-142`, `scripts/kicad/build-kicad-target.sh`
|
||||
(lines ~203/207/211/214), `tests/apps/Makefile.wasm` (all occurrences) — plus
|
||||
`-sSUPPORT_LONGJMP=wasm` (default with wasm-EH; the `emscripten` flavor is a hard error).
|
||||
4. Drop `env.invoke_*` from `ASYNCIFY_IMPORTS` in `scripts/common/apply-asyncify.sh:33`.
|
||||
5. Expect to delete/shrink shim machinery that exists only for the invoke world
|
||||
(`inject-dyncall-shims.sh` phases 1–2) — verify, don't assume.
|
||||
6. Keep `catch_audit.py` as a CI gate only if shipping *without* the fork (i.e., the
|
||||
hand-refactor path); with the fork it is informational.
|
||||
135
docs/features/wasm-exceptions/audit-results.txt
Normal file
135
docs/features/wasm-exceptions/audit-results.txt
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
{
|
||||
"total": 636,
|
||||
"trivial": 458,
|
||||
"needs_review": 93,
|
||||
"direct_suspend": 85
|
||||
}
|
||||
|
||||
== per top-level dir (direct/review/trivial/infobar):
|
||||
eeschema direct= 37 review= 17 trivial=127 infobar= 0
|
||||
pcbnew direct= 32 review= 29 trivial=128 infobar= 0
|
||||
common direct= 9 review= 35 trivial=126 infobar= 0
|
||||
cvpcb direct= 3 review= 1 trivial= 0 infobar= 0
|
||||
pcb_calculator direct= 2 review= 0 trivial= 2 infobar= 0
|
||||
kicad direct= 1 review= 1 trivial= 21 infobar= 0
|
||||
pagelayout_editor direct= 1 review= 1 trivial= 3 infobar= 0
|
||||
scripting direct= 0 review= 0 trivial= 1 infobar= 0
|
||||
plugins direct= 0 review= 1 trivial= 4 infobar= 0
|
||||
3d-viewer direct= 0 review= 2 trivial= 6 infobar= 0
|
||||
utils direct= 0 review= 4 trivial= 27 infobar= 0
|
||||
libs direct= 0 review= 2 trivial= 7 infobar= 0
|
||||
gerbview direct= 0 review= 0 trivial= 6 infobar= 0
|
||||
|
||||
== top 25 unknown callees inside needs_review catches (freq):
|
||||
4 clearOutlines
|
||||
4 message
|
||||
4 wxASSERT_MSG
|
||||
4 handleException
|
||||
4 tl::unexpected
|
||||
3 wxLogFatalError
|
||||
3 GetFullFilename
|
||||
3 std::string
|
||||
3 ReportMsg
|
||||
3 line_at
|
||||
3 positions
|
||||
3 LIBRARY_PARSE_ERROR
|
||||
3 move_push
|
||||
3 wxRemoveFile
|
||||
3 IsTooRecent
|
||||
3 Contents
|
||||
3 GetFieldValue
|
||||
3 ToOrigString
|
||||
3 SetValue
|
||||
2 GetFilename
|
||||
2 ClearShapes
|
||||
2 GetFPIDAsString
|
||||
2 wxString::FromUTF8
|
||||
2 GetFormatName
|
||||
2 nan
|
||||
|
||||
== direct-suspend sites (85):
|
||||
cvpcb/display_footprints_frame.cpp:310
|
||||
cvpcb/cvpcb_mainframe.cpp:977
|
||||
cvpcb/readwrite_dlgs.cpp:163
|
||||
pcb_calculator/datafile_read_write.cpp:69
|
||||
pcb_calculator/calculator_panels/panel_r_calculator.cpp:130
|
||||
kicad/kicad_manager_frame.cpp:822
|
||||
common/draw_panel_gal.cpp:325
|
||||
common/draw_panel_gal.cpp:561
|
||||
common/drawing_sheet/ds_data_model_io.cpp:100
|
||||
common/drawing_sheet/ds_data_model_io.cpp:131
|
||||
common/widgets/design_block_pane.cpp:121
|
||||
common/widgets/design_block_pane.cpp:212
|
||||
common/widgets/design_block_pane.cpp:315
|
||||
common/widgets/design_block_pane.cpp:356
|
||||
common/widgets/design_block_pane.cpp:418
|
||||
pcbnew/load_select_footprint.cpp:378
|
||||
pcbnew/pcb_base_frame.cpp:1111
|
||||
pcbnew/pcb_base_frame.cpp:1239
|
||||
pcbnew/pcb_edit_frame.cpp:2209
|
||||
pcbnew/files.cpp:670
|
||||
pcbnew/files.cpp:678
|
||||
pcbnew/files.cpp:689
|
||||
pcbnew/files.cpp:1024
|
||||
pcbnew/files.cpp:1108
|
||||
pcbnew/footprint_libraries_utils.cpp:198
|
||||
pcbnew/footprint_libraries_utils.cpp:288
|
||||
pcbnew/footprint_libraries_utils.cpp:401
|
||||
pcbnew/footprint_libraries_utils.cpp:545
|
||||
pcbnew/footprint_libraries_utils.cpp:613
|
||||
pcbnew/footprint_libraries_utils.cpp:673
|
||||
pcbnew/footprint_libraries_utils.cpp:817
|
||||
pcbnew/pcb_design_block_utils.cpp:107
|
||||
pcbnew/pcb_design_block_utils.cpp:168
|
||||
pcbnew/pcb_design_block_utils.cpp:198
|
||||
pcbnew/pcb_design_block_utils.cpp:226
|
||||
pcbnew/pcb_design_block_utils.cpp:332
|
||||
pcbnew/pcb_design_block_utils.cpp:429
|
||||
pcbnew/pcb_draw_panel_gal.cpp:781
|
||||
pcbnew/tools/board_editor_control.cpp:751
|
||||
pcbnew/tools/footprint_editor_control.cpp:529
|
||||
pcbnew/tools/pcb_control.cpp:1990
|
||||
pcbnew/netlist_reader/netlist.cpp:74
|
||||
pcbnew/exporters/export_idf.cpp:662
|
||||
pcbnew/exporters/export_idf.cpp:670
|
||||
pcbnew/dialogs/dialog_board_setup.cpp:385
|
||||
pcbnew/specctra_import_export/specctra_import.cpp:71
|
||||
pcbnew/widgets/pcb_design_block_preview_widget.cpp:191
|
||||
eeschema/project_rescue.cpp:671
|
||||
eeschema/project_rescue.cpp:819
|
||||
eeschema/sch_draw_panel.cpp:200
|
||||
eeschema/project_sch.cpp:108
|
||||
eeschema/project_sch.cpp:122
|
||||
eeschema/sch_design_block_utils.cpp:139
|
||||
eeschema/sch_design_block_utils.cpp:179
|
||||
eeschema/sch_design_block_utils.cpp:222
|
||||
eeschema/sch_design_block_utils.cpp:364
|
||||
eeschema/sch_design_block_utils.cpp:446
|
||||
eeschema/sch_design_block_utils.cpp:526
|
||||
eeschema/sch_edit_frame.cpp:1528
|
||||
eeschema/sch_base_frame.cpp:96
|
||||
eeschema/sheet.cpp:231
|
||||
eeschema/files-io.cpp:356
|
||||
eeschema/files-io.cpp:365
|
||||
eeschema/files-io.cpp:374
|
||||
eeschema/files-io.cpp:911
|
||||
eeschema/files-io.cpp:1410
|
||||
eeschema/files-io.cpp:1423
|
||||
eeschema/symbol_library_manager.cpp:376
|
||||
eeschema/symbol_library_manager.cpp:553
|
||||
eeschema/tools/sch_editor_control.cpp:569
|
||||
eeschema/tools/sch_editor_control.cpp:1703
|
||||
eeschema/dialogs/dialog_edit_symbols_libid.cpp:742
|
||||
eeschema/dialogs/dialog_sheet_properties.cpp:597
|
||||
eeschema/dialogs/dialog_sim_model.cpp:1526
|
||||
eeschema/dialogs/dialog_bom.cpp:361
|
||||
eeschema/sim/spice_simulator.cpp:44
|
||||
eeschema/sim/spice_value.cpp:415
|
||||
eeschema/sim/simulator_frame_ui.cpp:1672
|
||||
eeschema/symbol_editor/symbol_editor.cpp:170
|
||||
eeschema/symbol_editor/symbol_editor.cpp:236
|
||||
eeschema/symbol_editor/symbol_editor.cpp:1147
|
||||
eeschema/symbol_editor/symbol_editor.cpp:1187
|
||||
eeschema/symbol_editor/symbol_editor_import_export.cpp:104
|
||||
eeschema/symbol_editor/symbol_editor_import_export.cpp:110
|
||||
pagelayout_editor/tools/pl_edit_tool.cpp:531
|
||||
109
docs/features/wasm-exceptions/catch_audit.py
Normal file
109
docs/features/wasm-exceptions/catch_audit.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Audit KiCad catch blocks for wasm-EH safety (suspension inside catch handlers)."""
|
||||
import os, re, sys, json
|
||||
from collections import defaultdict
|
||||
|
||||
ROOT = "/Users/V/IdeaProjects/kicad-wasm/kicad"
|
||||
SKIP_DIRS = {"thirdparty", ".git", "qa", "build"}
|
||||
|
||||
# Calls that suspend (Asyncify) directly or are dialog wrappers in KiCad/wx
|
||||
DIRECT_SUSPEND = [
|
||||
"DisplayErrorMessage", "DisplayError", "DisplayInfoMessage", "DisplayHtmlInfoMessage",
|
||||
"wxMessageBox", "ShowModal", "ShowQuasiModal", "KIDIALOG", "OKOrCancelDialog",
|
||||
"wxMessageDialog", "IsOK(", "DisplayLoadError", "ShowAboutDialog",
|
||||
"wxGetSingleChoice", "wxTextEntryDialog", "wxFileDialog", "wxDirDialog",
|
||||
"GetDataFromClipboard", "SaveToClipboard", "wxClipboard", "EnumerateFacenames",
|
||||
]
|
||||
INFOBAR = ["ShowInfoBarError", "ShowInfoBarMsg", "ShowInfoBarWarning"]
|
||||
# Benign callees: logging (wxLog* is deferred to idle-time flush -> not inside catch),
|
||||
# string formatting, rethrow, reporters writing text
|
||||
BENIGN = {
|
||||
"wxLogError", "wxLogWarning", "wxLogMessage", "wxLogTrace", "wxLogDebug", "wxLogVerbose",
|
||||
"Format", "Printf", "printf", "fprintf", "snprintf", "What", "Problem", "Where",
|
||||
"GetErrorMessage", "wxString", "FROM_UTF8", "TO_UTF8", "UTF8", "c_str", "mb_str",
|
||||
"GetChars", "IsEmpty", "empty", "clear", "size", "length", "Report", "ReportTail",
|
||||
"ReportHead", "Add", "push_back", "emplace_back", "insert", "append", "Append",
|
||||
"SetError", "assert", "wxASSERT", "wxFAIL", "wxCHECK", "abort", "exit",
|
||||
"GetMessages", "GetErrors", "reset", "get", "release", "find", "count", "at",
|
||||
"begin", "end", "str", "Mid", "Left", "Right", "Trim", "Lower", "Upper",
|
||||
"StartsWith", "EndsWith", "Contains", "Replace", "Remove", "make_unique",
|
||||
"make_shared", "static_cast", "dynamic_cast", "const_cast", "reinterpret_cast",
|
||||
"Clear", "Close", "swap", "resize", "erase", "Set", "SetBitmap", "Destroy",
|
||||
"wxT", "_", "_HKI", "traceSchPlugin", "TRACE", "what", "THROW_IO_ERROR", "wxS", "wxFAIL_MSG", "IDF_ERROR", "LIBRARY_ERROR", "FUTURE_FORMAT_ERROR", "PARSE_ERROR", "KI_PARAM_ERROR", "fmt::format", "format", "GetFullPath", "HandleException", "Pgm", "current_exception", "rethrow_exception", "Nickname", "GetName", "GetLibNickname", "GetLibItemName", "wx_str", "GetMessageString", "UnescapeString", "exceptions", "CLOSE_STREAM", "AddError", "GetRequiredVersion", "Disconnect", "From_UTF8", "typeid", "LogException", "Instance", "GetFullName", "GetUniStringLibId", "ShowText", "SetStatusText", "GetItemDescription", "GetClass", "GetFriendlyName", "IsValid", "GetPath", "GetFileName", "GetExtension", "Length", "GetData", "data", "front", "back", "pop_back", "emplace", "GetSettingsManager",
|
||||
}
|
||||
CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_:]*)\s*\(")
|
||||
CATCH_RE = re.compile(r"\bcatch\s*\(")
|
||||
|
||||
def find_block(text, start):
|
||||
"""Return (block_text, end_idx) for brace-block starting at first '{' at/after start."""
|
||||
i = text.find("{", start)
|
||||
if i < 0: return None, start
|
||||
depth, j, n = 0, i, len(text)
|
||||
in_str = in_chr = in_lc = in_bc = False
|
||||
while j < n:
|
||||
c = text[j]; p = text[j-1] if j else ""
|
||||
if in_lc:
|
||||
if c == "\n": in_lc = False
|
||||
elif in_bc:
|
||||
if p == "*" and c == "/": in_bc = False
|
||||
elif in_str:
|
||||
if c == '"' and p != "\\": in_str = False
|
||||
elif in_chr:
|
||||
if c == "'" and p != "\\": in_chr = False
|
||||
elif c == "/" and j+1 < n and text[j+1] == "/": in_lc = True
|
||||
elif c == "/" and j+1 < n and text[j+1] == "*": in_bc = True
|
||||
elif c == '"': in_str = True
|
||||
elif c == "'": in_chr = True
|
||||
elif c == "{": depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0: return text[i:j+1], j+1
|
||||
j += 1
|
||||
return None, start
|
||||
|
||||
stats = defaultdict(int)
|
||||
direct_sites, review_sites = [], []
|
||||
review_callees = defaultdict(int)
|
||||
per_dir = defaultdict(lambda: defaultdict(int))
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(ROOT):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
for fn in filenames:
|
||||
if not fn.endswith(".cpp"): continue
|
||||
path = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(path, ROOT)
|
||||
top = rel.split(os.sep)[0]
|
||||
try: text = open(path, encoding="utf-8", errors="replace").read()
|
||||
except OSError: continue
|
||||
for m in CATCH_RE.finditer(text):
|
||||
block, _ = find_block(text, m.end())
|
||||
if block is None: continue
|
||||
stats["total"] += 1
|
||||
line = text[:m.start()].count("\n") + 1
|
||||
loc = f"{rel}:{line}"
|
||||
if any(s in block for s in DIRECT_SUSPEND):
|
||||
stats["direct_suspend"] += 1; per_dir[top]["direct"] += 1
|
||||
direct_sites.append(loc)
|
||||
continue
|
||||
if any(s in block for s in INFOBAR):
|
||||
stats["infobar"] += 1; per_dir[top]["infobar"] += 1
|
||||
continue
|
||||
calls = set(CALL_RE.findall(block)) - {"catch", "if", "for", "while", "switch", "return", "sizeof", "throw"}
|
||||
unknown = {c for c in calls if c.split("::")[-1] not in BENIGN and c not in BENIGN}
|
||||
if not unknown:
|
||||
stats["trivial"] += 1; per_dir[top]["trivial"] += 1
|
||||
else:
|
||||
stats["needs_review"] += 1; per_dir[top]["review"] += 1
|
||||
review_sites.append((loc, sorted(unknown)[:6]))
|
||||
for c in unknown: review_callees[c] += 1
|
||||
|
||||
print(json.dumps(stats, indent=1))
|
||||
print("\n== per top-level dir (direct/review/trivial/infobar):")
|
||||
for d in sorted(per_dir, key=lambda d: -per_dir[d]["direct"]):
|
||||
p = per_dir[d]
|
||||
print(f" {d:24s} direct={p['direct']:3d} review={p['review']:3d} trivial={p['trivial']:3d} infobar={p['infobar']:2d}")
|
||||
print("\n== top 25 unknown callees inside needs_review catches (freq):")
|
||||
for c, n in sorted(review_callees.items(), key=lambda kv: -kv[1])[:25]:
|
||||
print(f" {n:3d} {c}")
|
||||
print(f"\n== direct-suspend sites ({len(direct_sites)}):")
|
||||
for s in direct_sites: print(" " + s)
|
||||
Loading…
Reference in a new issue