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.)
|
||||
Loading…
Reference in a new issue