test(asyncify): red-green race harness + ablation flags, unwind-catch shim, spec tightening, decisions docs

The asyncify single-slot work, executed red-green (full ledger:
docs/features/asyncify-arbiter/redgreen.md; decisions record:
docs/features/async/07-decisions-and-outcome.md):

- tests/apps/standalone/asyncify-races/ + tests/asyncify/ + dedicated
  playwright config: 8 scenarios reproducing the KiCad asyncify failure
  family with the kicad-faithful startup topology (pre-park fiber swap →
  park throw through the live trampoline). Built in 3 variants; the
  SHIM_DISABLE_TRAMPOLINE_HEAL / SHIM_DISABLE_HANDLESLEEP ablation builds
  keep the historical hang and index-out-of-bounds crash reproducible
  forever (mutation-style pins for the existing shims).
- scripts/common/shims/handlesleep.js: catch the "unwind" park sentinel
  in the wakeUp path — when main's last pre-park suspension was a sleep,
  the main-loop park throw escaped through that sleep's promise reaction
  as an uncaught rejection (the calculator/gerbview console errors).
- scripts/common/inject-dyncall-shims.sh: SHIM_DISABLE_* ablation knobs.
- Spec tightening (the acceptance bar): 'uncaught exception: unwind'
  tolerance DELETED from pcbnew/eeschema specs; load-pcb gained a hard
  clean-console gate over 5 asyncify corruption signatures.
- wxwidgets pointer bump: modal LIFO resolvers, pump resolve-on-error,
  sync clipboard IsSupported (014f67e6c1).

Final state: asyncify suite 7/7, wx e2e 291/292 (1 skip), KiCad e2e 40
passed / 2 skipped with ZERO corruption signatures in any log across all
six apps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-06-12 16:59:07 +02:00
commit 14ca16cbd3
16 changed files with 1593 additions and 13 deletions

1
.gitignore vendored
View file

@ -73,3 +73,4 @@ output/
/bench/
/scripts/bench/vm/
/tests/.test-port-asyncify

View file

@ -0,0 +1,155 @@
# 07 — Decisions and outcome (2026-06-12)
> The dossier (0106) ended with designs and open questions. This file records what was
> actually decided, built, and deliberately NOT built — and the trigger conditions for
> revisiting each road not taken. Working artifacts: `docs/features/asyncify-arbiter/`
> (baseline.md, redgreen.md), harness in `tests/apps/standalone/asyncify-races/` +
> `tests/asyncify/`, runnable via `npm run test:asyncify:firefox`.
## D1 — Root cause: the dossier's two hang mechanisms were ONE mechanism
The §5 (doc 02) question "orphaned currData vs stuck trampoline guard" was settled by code
trace, then pinned by a deterministic test. At the `emscripten_set_main_loop(...,1)`
`throw "unwind"` park, Asyncify state is clean — but **any OnInit-era fiber swap means
main() is, from then on, executing inside `Fibers.trampoline()`'s `do/while`** (resumed via
`finishContextSwitch → doRewind`). The throw tears through that live frame; the
`trampolineRunning = false` reset is skipped; the guard wedges forever; the first post-idle
swap strands. "Orphaned currData" is structurally impossible (the throw only executes from
straight-line Normal-state code). Corollary: when main's *last* pre-park suspension was a
**sleep**, the same throw instead escapes through that sleep's wakeUp promise reaction —
that was the long-standing `uncaught exception: unwind` rejection family.
Consequence: the trampoline self-heal (`inject-dyncall-shims.sh` §3c, commit `18a9de0`)
is the **structural cure** for the hang, not a band-aid (doc 04 had it demoted), and
**de-parking (02 §7) is not needed for correctness.**
## D2 — Red-green doctrine governed everything
Rule applied throughout: *no fix lands before a test has been observed failing for the
exact disease, and the failing run is recorded* (`asyncify-arbiter/redgreen.md`). Two red
flavors: **natural red** (bug unfixed today) and **ablation red** (fix exists; rebuild the
harness with `SHIM_DISABLE_TRAMPOLINE_HEAL=1` / `SHIM_DISABLE_HANDLESLEEP=1` to prove the
test detects the disease the fix prevents — mutation-testing style). The ablation flags are
permanent injector features; the diseases stay reproducible on demand.
## D3 — Decision: the full Design-A arbiter was NOT built
Doc 05's arbiter (central context registry + deferred-wakeup queue + single-owner
transitions) was the plan. It was dropped because **no scenario could be made red that it
would fix**: at production asyncify semantics (`-sASSERTIONS=0`), the pre-existing
per-sleep capture in `scripts/common/shims/handlesleep.js` (commit `a4ad694`) already
implements Design A's core invariant — *`Asyncify.currData` is a transient register; every
parked context owns its buffer pointer elsewhere* (fibers: in the `wasm_fcontext` struct;
sleeps: in per-sleep closures, restored immediately before that sleep's own rewind). The
scenarios predicted to need the arbiter (`wakeup_during_transition`,
`out_of_order_sleep_resolution`, `sleep_inside_fiber_inside_modal`) are green under the
existing shim and are kept as regression pins.
Why the residual race is structurally hard to hit: promise resolvers only queue
microtasks; microtasks run only at JS-stack-empty; every transition (unwind chain,
trampoline loop, rewind chain) completes synchronously within one task. The only mechanism
that splits a transition across tasks is an `await` inside the chain — the async-ccall
pumps — which are exactly what was hardened (D5).
**Trigger to revisit:** any reappearance of a wakeup-during-transition signature in
`tests/logs/` (e.g. handleSleep entered at `state=2`, `invalid state` aborts), or any new
red the existing shims can't turn green. The arbiter design in doc 05 remains the blueprint;
the deferred-wakeup queue alone is a ~50-line `handlesleep.js` extension.
## D4 — Decision: keep the park throw (+ heal + sentinel catch); defer the asyncify-park
The alternative discussed ("Option C": park main in a never-resolving `EM_ASYNC_JS` sleep
instead of the throw — clean unwind, no exceptional control flow, explicit teardown owner,
natural step one of Design B) was deliberately **deferred**. With the heal (§3c) covering
the fiber face and the `handlesleep.js` sentinel catch covering the sleep face, both damage
classes of the throw are individually fixed and pinned; the asyncify-park would have landed
green-on-green. Costs it would incur now: wx lifecycle change + rebuild, `MainLoop.pause()`
interplay (handleSleep auto-pauses the loop), a permanently-parked buffer, divergence from
stock emscripten idiom.
**Trigger to revisit:** recurring `"unwind"`-leak variants that the sentinel catch doesn't
cover, or starting Design B (fiber-first runtime) in earnest.
## D5 — wx-layer fixes (the bugs that were wx's, not the shim's)
1. **`dialog.cpp` — modal resolver is a LIFO stack.** `Module._endModal` was a single slot
that deleted itself after use; with 3+ nested modals the middle `EndModal` resolved
nothing and its `ShowModal` parked forever. Found by the new `modal_in_modal_in_modal`
scenario — a previously unknown product bug (KiCad nests dialogs routinely). Now:
stable `Module._endModal` dispatcher popping `Module._wxModalResolvers` (LIFO — matches
wx modal discipline and c27's `_wxNestedLoopExit` convention). If programmatic
out-of-order EndModal ever matters, key the dispatcher per-dialog (small change, needs
its own red test first).
2. **Pumps must never stop without resolving.** Both `startModal` (modal pump) and
`wxWasmRunNestedLoop` (c27 quasi-modal pump) caught ProcessEvents rejections and
silently stopped, leaving the suspended C++ stack parked forever. Now both resolve on
error, loudly (`console.error`): the modal cancels with `wxID_CANCEL` (passed in from
C++), the nested loop exits. Self-cancel paths splice their own resolver out of the
stack (they may not be topmost).
3. **`clipbrd.cpp``IsSupported` is synchronous again.** It called the 2 s
permission-gated `js_clipboardHasText` EM_ASYNC_JS from a synchronous-by-contract
predicate on the idle path — creating the long-parked sleeps behind the
`index out of bounds` family. Now answers optimistically from the sync
`js_isClipboardAPIAvailable` probe; the real read stays in `GetData()` (user-gesture
gated).
## D6 — Harness design decisions (tests/apps/standalone/asyncify-races/)
- **KiCad-faithful topology is the point:** ≥1 fiber swap in `OnInit` so main is
trampoline-resumed at park time — the precondition `coroutine-nested` lacked, which is
why it never reproduced the hang. `#mode=sleep-park` flips the last pre-park suspension
to a sleep for the rejection face.
- **`-sASSERTIONS=0` (LDFLAGS_RACES):** emscripten's debug assert ("cannot start an async
operation when one is already in flight") forbids the very multi-parked-sleep states the
production shims exist to handle; the harness must match production semantics.
- **Params travel in the URL hash:** `npx serve`'s cleanUrls redirect for `*.html` drops
query strings.
- **EM_JS bodies use C parameter NAMES** (`aToken`), not `$0` — that is EM_ASM syntax.
- **To make a pump fail, throw from a PENDING EVENT** (`CallAfter` → ProcessPendingEvents
inside the pump's awaited ccall). wx timers fire via
`emscripten_async_call`/`callUserCallback` and bypass pumps entirely.
- **Quiescence probe** (state==Normal, currData==null, nextFiber==0) is checked
synchronously between scenarios; `trampolineRunning` is deliberately excluded from the
sync check (code resumed via a fiber legitimately runs inside the trampoline do/while) —
a genuinely stuck guard is caught by the per-scenario JS watchdogs instead.
- Wedge-prone scenarios run as `#only=` singles on separate page loads; the chained battery
holds only scenarios that can't kill the chain.
## D7 — Attribution ledger (who fixed what, when)
Pre-existing (earlier sessions): per-sleep buffer capture/restore in `handlesleep.js`
(`a4ad694`); trampoline self-heal §3c (`18a9de0`); per-fiber buffers (stock emscripten +
libcontext). This session's code: the 13-line `"unwind"` sentinel catch in
`handlesleep.js`; the three wx fixes (D5); `SHIM_DISABLE_*` ablation flags; the harness +
specs + config; spec tightening (D8). This session's *proof*: before it, the shim and heal
were unverified folklore — nothing failed if you deleted them. Now their diseases reproduce
on demand and their absence fails tests.
## D8 — Acceptance bar moved into the specs
`'uncaught exception: unwind'` tolerance filters DELETED from `pcbnew.spec.ts` /
`eeschema.spec.ts`; `load-pcb.spec.ts` gained a hard clean-console gate over five
signatures (`index out of bounds`, `indirect call to null`, `uncaught exception: unwind`,
`invalid state`, `is not a function`) covering before AND after board render.
## Outcome (final verification)
- Harness: 7/7 green (battery of 4 + three singles); both ablation builds still reproduce
their diseases. wx e2e 291 passed / 1 skipped / 0 failed; coroutine 13/13.
- KiCad e2e after rebuilding **all six apps**: 40 passed / 2 skipped / 0 failed / 0 flaky,
and a sweep of every `tests/logs/kicad/` log finds **zero** corruption signatures and
**zero** `.errors.log` files (baseline had load-pcb `index out of bounds` and
calculator/gerbview `uncaught exception: unwind`).
- Upstream (researched): Fibers/Asyncify JS runtime unchanged since 2020; the single-slot
limitation family is WONTFIX (#9153, #12270, #13302, #16291, #18412). The §3c
try/finally heal is a good candidate for an upstream PR.
## Roads not taken, with triggers
| Option | Status | Revisit when |
|---|---|---|
| Full Design-A arbiter (registry + wakeup queue) | not built | any wakeup-during-transition signature in logs, or a red the shims can't fix |
| Park-via-unresolved-sleep (no throw) | deferred | recurring unwind-leak variants, or Design B work starts |
| De-parking (02 §7, lifecycle surgery) | rejected | only as part of Design B |
| Design B (fiber-first runtime) | long-term option | architectural appetite, not correctness need |
| Per-dialog-keyed modal resolvers | not needed | a real out-of-order EndModal use case (write the red first) |

View file

@ -39,6 +39,7 @@ or **hang** (a swap unwinds but is never rewound).
| [`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. |
| [`07-decisions-and-outcome.md`](07-decisions-and-outcome.md) | **What was decided and shipped (2026-06-12):** root cause, red-green ledger, the arbiter NOT built and why, roads not taken with revisit triggers. |
## The single decisive next step
@ -47,3 +48,32 @@ Before designing anything, **measure whether `Asyncify.currData` is clean (null)
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).
---
## RESOLUTION (2026-06-12) — see [`07-decisions-and-outcome.md`](07-decisions-and-outcome.md) and `docs/features/asyncify-arbiter/`
The decisive measurement was answered **by code trace and then pinned by a deterministic
test** (`tests/asyncify/asyncify-races.spec.ts` + `tests/apps/standalone/asyncify-races/`):
- At the park throw, Asyncify state IS clean (`currData==null`, `state==Normal`) — **but the
JS stack is necessarily still inside `Fibers.trampoline()`'s `do/while`** (any OnInit-era
fiber swap means main is trampoline-resumed from then on). The throw skips the
`trampolineRunning = false` reset → the guard wedges → the first post-idle swap hangs.
That IS the §5 hang; "orphaned currData" (mechanism #1) is structurally impossible.
The self-heal (`inject-dyncall-shims.sh` §3c, commit `18a9de0`) is therefore the
*structural cure*, not a band-aid — **no de-parking needed**.
- When main's last pre-park suspension is a *sleep*, the same throw instead escapes through
the sleep's wakeUp promise reaction → the long-mystifying `uncaught exception: unwind`
rejections. Fixed in `scripts/common/shims/handlesleep.js` (catches the sentinel like
`callMain` does).
- The full Design-A arbiter was NOT needed: at production semantics (`-sASSERTIONS=0`),
out-of-order and overlapping-sleep scenarios are already handled by the per-sleep buffer
capture in `handlesleep.js`. The remaining bugs were wx-layer: single-slot
`Module._endModal` broke 3-deep nested modals (now a LIFO resolver stack), and the
modal/nested-loop pumps stalled silently on ProcessEvents rejection (now resolve-on-error).
Clipboard `IsSupported` no longer runs the 2 s async probe.
- De-parking (02 §7) and Design B remain documented options, unneeded for correctness today.
- Upstream status (researched): the Fibers/Asyncify code is unchanged since 2020; the
single-slot family is WONTFIX (#9153, #12270, #13302, #16291, #18412). The trampoline
try/finally would be a good upstream PR.

View file

@ -0,0 +1,59 @@
# Baseline — pre-fix state (2026-06-12)
Artifacts under test for the KiCad baseline run:
- `output/pcbnew.js` + `output/eeschema.js` from `docker/build.sh pcbnew,eeschema`
started 14:20 (container rsyncs source at start → consistently PRE-fix wx:
async clipboard IsSupported, single-slot `Module._endModal`, silently-stalling
modal/nested pumps). Shims as of the same moment: §3c trampoline heal INCLUDED,
handlesleep.js WITHOUT the wakeUp "unwind" catch.
- This is the first build of these apps since `18a9de0` (trampoline heal) —
the previously deployed artifacts predated it, which is why the load-pcb UI
froze post-load in the user's earlier runs.
## Standalone harness reds recorded pre-fix
See `redgreen.md` — 3 reds (sleep-park unwind rejection; modal_in_modal_in_modal
stall; nested pump-error stall) + 2 ablation pins reproducing the historical
hang and clobber crash.
## KiCad e2e baseline run (npm run test:kicad, firefox)
- Full playwright summary: **40 passed, 2 skipped, 0 failed, 0 flaky** (1.5 m).
Green-at-baseline because this is the first deployed build containing the
§3c trampoline heal (`18a9de0`) — the freeze-after-load the user saw came
from pre-heal artifacts.
- Failures classified pre-existing/unrelated: none (2 skips are by design).
- Asyncify errors present in logs DESPITE passing (the "before" evidence the
post-fix rebuild must eliminate, currently tolerated by spec filters):
- `logs/kicad/load-pcb/...pic-programmer....errors.log`:
`RuntimeError: index out of bounds` — the clipboard 2 s `IsSupported`
sleep clobber (wx fix: sync IsSupported).
- `logs/kicad/calculator/...loads-calculator-frame.errors.log` and
`...switch-to-color-code-panel.errors.log`: `uncaught exception: unwind`
the park throw escaping through a sleep's wakeUp (shim fix: handlesleep.js
unwind catch).
## wxWidgets e2e regression (against the FIXED wx — the regression gate for
## the dialog.cpp/evtloop.cpp/clipbrd.cpp changes)
- Coroutine suite (firefox): 13 passed, 0 failed (49.8 s)
- Asyncify races suite (firefox): 7 passed, 0 failed (18.8 s)
- Full wx e2e (bundled chromium): **291 passed, 1 skipped, 0 failed, 0 flaky** (1.6 m)
No regressions from the modal LIFO resolver stack, pump resolve-on-error
changes, or the sync clipboard IsSupported.
## FINAL verification (post-fix rebuild of ALL 6 apps, tightened specs)
Specs tightened first: `'uncaught exception: unwind'` tolerance DELETED from
pcbnew.spec.ts/eeschema.spec.ts; load-pcb.spec.ts gained a hard clean-console
gate over 5 asyncify corruption signatures (before AND after board render).
- `npm run test:kicad` (firefox): **40 passed, 2 skipped, 0 failed, 0 flaky** (1.5 m)
- Signature sweep over every `tests/logs/kicad/` log
(index out of bounds / indirect call to null / uncaught exception: unwind /
invalid state / is not a function / Aborted(): **zero matches**
- `.errors.log` files produced by the run: **zero** (baseline had 5+, including
load-pcb's `RuntimeError: index out of bounds` and calculator/gerbview's
`uncaught exception: unwind` — all gone)
- Screenshot baselines: unchanged (no rendering impact).

View file

@ -0,0 +1,65 @@
# Asyncify red-green ledger
Harness: `tests/apps/standalone/asyncify-races/` (3 build variants: full shims /
`SHIM_DISABLE_TRAMPOLINE_HEAL=1` / `SHIM_DISABLE_HANDLESLEEP=1`), specs in
`tests/asyncify/asyncify-races.spec.ts`, run via `npm run test:asyncify:firefox`.
Built with `-sASSERTIONS=0` to match production asyncify semantics (the debug
assert "We cannot start an async operation when one is already flight" forbids
the multi-parked-sleep states the production shims are designed to handle).
The harness reproduces KiCad's real startup topology: a fiber swap during OnInit
means main() is resumed via Fibers.trampoline() when the
emscripten_set_main_loop(...,1) `throw "unwind"` park fires — the precondition
for the trampoline-guard wedge (this is what coroutine-nested never modeled).
## Board after the initial red run (2026-06-12, all pre-fix)
| Spec | State | Recorded failure mode |
|---|---|---|
| battery: post_park_fiber_swap | GREEN | §3c trampoline heal works (pinned by ablation below) |
| battery: sleep_inside_fiber_inside_modal | GREEN | 3 concurrent buffers (modal+fiber+sleep) survive under handlesleep.js |
| battery: out_of_order_sleep_resolution | GREEN | FIFO resolution of 2 parked sleeps survives (shim associates per-sleep buffers) |
| battery: long_parked_sleep_clobbered_by_swap | GREEN | 1.2s parked sleep + 2 fiber-swap cycles survives (pinned by ablation below) |
| wakeup_during_transition | GREEN | modal teardown from fresh stack over 2 parked sleeps survives current shims |
| **modal_in_modal_in_modal** | **RED** | watchdog timeout, all-quiet state: wx `dialog.cpp` keeps the modal resolver in a single slot (`Module._endModal = fn`, `delete` after use) — the middle EndModal(102) resolves nothing, its ShowModal parks forever. NEW product bug found by the harness (KiCad nests dialogs). Fix: Stage-3 LIFO resolver stack. |
| **nested_quasi_modal_pump_error** | **RED** | watchdog timeout with `currData=1390336` left parked — c27fe8bf's `wxWasmRunNestedLoop` pump catches the ProcessEvents rejection and stops WITHOUT resolving; nested DoRun leaks forever. Fix: Stage-3 resolve-on-error. |
| **sleep-park: unwind_through_promise** | **RED** | `uncaught exception: unwind` at `handleSleep/< ... promise callback*handleAsync` — the park throw escapes through the last pre-park sleep's wakeUp promise reaction. Fix: Stage-2 shim catches the `"unwind"` sentinel in the wakeUp path (the same class pcbnew.spec.ts/eeschema.spec.ts currently FILTER OUT with `'uncaught exception: unwind'`). |
| ablation noheal: post_park swap hangs | GREEN (reproduces) | watchdog: `state=0 currData!=0 trampolineRunning=true nextFiber=0`, suite never completes — the exact traced mechanism: the park throw tears through the live trampoline do/while, `trampolineRunning=false` reset skipped, guard wedged forever. Pins §3c (`18a9de0`). |
| ablation nosleepfix: parked sleep clobbered | GREEN (reproduces) | `RuntimeError: index out of bounds` (the KiCad clipboard crash signature) — fiber swap clobbers `Asyncify.currData` while a sleep is parked; wakeUp rewinds garbage. Pins `handlesleep.js`. |
## Notes
- out_of_order_sleep_resolution and wakeup_during_transition could not be made
red under the current shims at production semantics — the existing
handlesleep.js per-sleep buffer capture handles them. They stay as regression
pins. The KiCad-side "ENTER at state=2" diagnostic remains the only evidence
for a residual wakeup race; the Stage-4 KiCad e2e run (clean-console
assertions) is the judge of whether more shim work (deferred wakeups) is needed.
- Earlier harness iterations hit two environment gotchas worth remembering:
`EM_JS` bodies take C parameter NAMES (not `$0` — that's EM_ASM), and
`npx serve`'s cleanUrls redirect DROPS query strings — harness params travel
in the URL hash.
## Green transitions (2026-06-12, same day — suite 7/7 green in 21.7s)
- [x] sleep-park unwind_through_promise → GREEN via `scripts/common/shims/handlesleep.js`:
the wakeUp wrapper catches the `"unwind"` sentinel (the main-loop park
escaping through a sleep's promise reaction) and swallows it exactly like
callMain does on the direct path. Shim-only; no rebuild of wx needed.
- [x] modal_in_modal_in_modal → GREEN via `wxwidgets/src/wasm/dialog.cpp`:
`Module._endModal` is now a stable LIFO dispatcher over
`Module._wxModalResolvers` (was: single slot + delete). Also: the modal
pump now CANCELS the modal (resolves `wxID_CANCEL`) on a ProcessEvents
rejection instead of silently stopping with the stack parked.
- [x] nested_quasi_modal_pump_error → GREEN via `wxwidgets/src/wasm/evtloop.cpp`:
`wxWasmRunNestedLoop`'s pump resolves (exits the nested loop, loudly) on a
ProcessEvents rejection instead of stopping with the nested DoRun parked.
- [also] `wxwidgets/src/wasm/clipbrd.cpp` `IsSupported`: no longer calls the 2 s
`js_clipboardHasText` EM_ASYNC_JS — answers from the sync capability
probe. Its red lives at the KiCad level (CLIP-DIAG unwind/OOB lines in
tests/logs/kicad/load-pcb); verified by the Stage-4 clean-console runs.
Harness learning recorded for posterity: to make the *pump* fail you must throw
from a PENDING EVENT (ProcessEvents -> ProcessPendingEvents); wx timers on wasm
fire via emscripten_async_call/callUserCallback and bypass the pump's awaited
ccall entirely.

View file

@ -122,7 +122,11 @@ echo "Total: Fixed $TOTAL_FIXED empty callback(s)"
# --- 3. Nested-Asyncify handleSleep fix ---------------------------------------
# Injected after Emscripten's fiber glue (the _emscripten_fiber_swap.isAsync marker).
if grep -q '__nestedHandleSleepInstalled' "$JS_FILE"; then
# SHIM_DISABLE_HANDLESLEEP=1 skips it: used by the asyncify-races red-green harness
# to keep the historical "sleep buffer clobbered by fiber swap" crash reproducible.
if [ "${SHIM_DISABLE_HANDLESLEEP:-0}" = "1" ]; then
echo "handleSleep fix DISABLED (SHIM_DISABLE_HANDLESLEEP=1) - ablation build"
elif grep -q '__nestedHandleSleepInstalled' "$JS_FILE"; then
echo "handleSleep fix already present - skipping"
else
HS_MARKER=$(grep -n '^_emscripten_fiber_swap\.isAsync = true;$' "$JS_FILE" | head -1 | cut -d: -f1)
@ -163,7 +167,12 @@ fi
# so every later fiber swap silently fails to switch — the schematic load and all
# post-idle tool actions hang. Wrap the loop in try/finally so the flag is always
# reset (self-healing).
if grep -qF '} finally { Fibers.trampolineRunning = false; }' "$JS_FILE"; then
# SHIM_DISABLE_TRAMPOLINE_HEAL=1 skips it: used by the asyncify-races red-green
# harness to keep the historical "park throw wedges the trampoline guard" hang
# reproducible.
if [ "${SHIM_DISABLE_TRAMPOLINE_HEAL:-0}" = "1" ]; then
echo "fiber trampoline self-heal DISABLED (SHIM_DISABLE_TRAMPOLINE_HEAL=1) - ablation build"
elif grep -qF '} finally { Fibers.trampolineRunning = false; }' "$JS_FILE"; then
echo "fiber trampoline self-heal already present - skipping"
elif grep -qF 'Fibers.trampolineRunning = true;' "$JS_FILE"; then
perl -0pi -e 's/(Fibers\.trampolineRunning = true;)(\s*)(do \{.*?\} while \(Fibers\.nextFiber\);)(\s*)(Fibers\.trampolineRunning = false;)/$1$2try {$3} finally { $5 }/s' "$JS_FILE"

View file

@ -52,7 +52,20 @@ if (typeof Asyncify !== "undefined") {
Asyncify.currData = sleepCtx.capturedData;
}
cleanup();
try {
return wakeUp(result);
} catch (e) {
// emscripten_set_main_loop(...,1) parks main() by throwing the
// "unwind" sentinel. When main's LAST pre-park suspension was a
// sleep, main is resumed from THIS wakeUp, so the sentinel
// propagates here instead of into callMain's catch — surfacing as
// an uncaught "unwind" promise rejection. Swallow it exactly like
// callMain/handleException do on the direct path.
if (e === "unwind") {
return;
}
throw e;
}
});
});
} catch (e) {

View file

@ -97,6 +97,12 @@ COROUTINE_BASE_LDFLAGS = -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
-sASYNCIFY_IMPORTS=['startModal','js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts','emscripten_fiber_swap']
LDFLAGS_COROUTINE = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) $(WX_LDFLAGS_NOGL)
# The asyncify-races harness must match PRODUCTION asyncify semantics: the KiCad
# apps ship with assertions off and rely on the shim layer, while emscripten's
# debug assert ("We cannot start an async operation when one is already flight")
# forbids the very multi-parked-sleep states the harness exists to exercise.
LDFLAGS_RACES = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -sASSERTIONS=0 $(WX_LDFLAGS_NOGL)
JS = $(TOOLS_ROOT)/wx.js
HTML = $(TOOLS_ROOT)/template.html
@ -157,6 +163,9 @@ all: minimal_test.html \
$(S)/retinascale/retinascale_test.html \
$(S)/coroutine/coroutine_test.html \
$(S)/coroutine-nested/nested_test.html \
$(S)/asyncify-races/races_test.html \
$(S)/asyncify-races/races_test_noheal.html \
$(S)/asyncify-races/races_test_nosleepfix.html \
$(S)/coroutine-pthread/coroutine_test_wxpt.html \
$(S)/coroutine-pthread/embind_repro.html \
$(S)/coroutine-pthread/gl_repro.html \
@ -487,6 +496,25 @@ $(S)/coroutine-nested/nested_test.html: $(S)/coroutine-nested/nested_test.o $(S)
$(CXX) $^ $(LDFLAGS_COROUTINE) --pre-js $(JS) --shell-file $(HTML) -o $@
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
# Asyncify race-condition red-green harness (tests/asyncify/). Three variants of
# the same app: full shims, trampoline-heal ablated, handleSleep-fix ablated.
# The ablated builds keep the historical bugs reproducible (red) so the shim
# fixes stay pinned by tests. Same .o, three link+inject passes.
$(S)/asyncify-races/races_test.o: $(S)/asyncify-races/races_test.cpp $(S)/coroutine/kicad_coroutine_harness.h
$(CXX) -c $(CXXFLAGS) -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
$(S)/asyncify-races/races_test.html: $(S)/asyncify-races/races_test.o $(S)/coroutine/libcontext.o $(WX_CORE_LIB)
$(CXX) $^ $(LDFLAGS_RACES) --pre-js $(JS) --shell-file $(HTML) -o $@
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
$(S)/asyncify-races/races_test_noheal.html: $(S)/asyncify-races/races_test.o $(S)/coroutine/libcontext.o $(WX_CORE_LIB)
$(CXX) $^ $(LDFLAGS_RACES) --pre-js $(JS) --shell-file $(HTML) -o $@
SHIM_DISABLE_TRAMPOLINE_HEAL=1 ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
$(S)/asyncify-races/races_test_nosleepfix.html: $(S)/asyncify-races/races_test.o $(S)/coroutine/libcontext.o $(WX_CORE_LIB)
$(CXX) $^ $(LDFLAGS_RACES) --pre-js $(JS) --shell-file $(HTML) -o $@
SHIM_DISABLE_HANDLESLEEP=1 ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
# Convenience targets
menu: $(S)/menu/menu_test.html
clipboard: $(S)/clipboard/clipboard_test.html
@ -541,6 +569,7 @@ $(S)/retinascale/retinascale_test.html: $(S)/retinascale/retinascale_test.o $(WX
retinascale: $(S)/retinascale/retinascale_test.html
coroutine: $(S)/coroutine/coroutine_test.html
coroutine-nested: $(S)/coroutine-nested/nested_test.html
asyncify-races: $(S)/asyncify-races/races_test.html $(S)/asyncify-races/races_test_noheal.html $(S)/asyncify-races/races_test_nosleepfix.html
# Only delete generated app files (*_test*, *_repro*) — a bare $(S)/*/*.js would
# also delete checked-in sources like coroutine-pthread/worker_dom_stub.js.
@ -550,7 +579,7 @@ clean:
rm -f $(S)/*/*_test*.html $(S)/*/*_test*.js $(S)/*/*_test*.wasm
rm -f $(S)/*/*_repro*.html $(S)/*/*_repro*.js $(S)/*/*_repro*.wasm
.PHONY: all clean menu clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize threadpool logerror retinascale coroutine coroutine-nested
.PHONY: all clean menu clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize threadpool logerror retinascale coroutine coroutine-nested asyncify-races
# === Coroutine pthread variant — reproduces the KiCad Asyncify-fiber x pthreads crash ===
# Same modal-free harness as `coroutine`, but compiled/linked with pthreads to match

View file

@ -0,0 +1,863 @@
// races_test.cpp - Asyncify race-condition red-green harness.
//
// Reproduces the KiCad-WASM Asyncify failure modes deterministically so the shim
// fixes stay pinned by tests (see features/async/ research dossier):
//
// - The app performs a fiber swap during OnInit BEFORE the main loop parks.
// This is the load-bearing topology detail: it means main() is resumed via
// Fibers.trampoline() when wxGUIEventLoop::DoRun() executes the
// emscripten_set_main_loop(...,1) `throw "unwind"` park, so the throw tears
// through the live trampoline do/while. Without the trampoline self-heal
// shim that wedges Fibers.trampolineRunning=true forever and the FIRST
// post-park fiber swap hangs (the KiCad schematic/PCB tool hang).
// coroutine-nested/nested_test.cpp does NOT do a pre-park swap, which is
// why it never reproduced that hang.
//
// - EM_ASYNC_JS sleeps (modal dialogs, token waits) overlapping fiber swaps
// reproduce the single-slot Asyncify.currData clobber family (the KiCad
// clipboard "index out of bounds" crash).
//
// URL parameters:
// ?only=<scenario> run a single scenario instead of the default battery
// (used for scenarios that intentionally wedge/crash)
// ?mode=sleep-park make the LAST pre-park suspension a sleep instead of a
// fiber swap: the park throw then escapes through the
// sleep's wakeUp promise reaction as an unhandled
// "unwind" rejection (scenario unwind_through_promise)
//
// Output protocol (polled by tests/asyncify/asyncify-races.spec.ts):
// [ASYNCIFY_RACES] CASE <name>
// [ASYNCIFY_RACES] PASS <name> / FAIL <name> :: <detail>
// [ASYNCIFY_RACES] WATCHDOG <name> state=.. currData=.. trampolineRunning=..
// [ASYNCIFY_RACES] SUMMARY total=N passed=N failed=N
#include "wx/wx.h"
#include "wx/dialog.h"
#include "wx/evtloop.h"
#include "wx/timer.h"
#include "kicad_coroutine_harness.h"
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#include <emscripten/em_js.h>
#endif
#include <functional>
#include <memory>
#include <sstream>
#include <string>
#include <vector>
using coroutine_test::TestCoroutine;
namespace
{
constexpr int ID_SCENARIO_TIMER = wxID_HIGHEST + 700;
constexpr int ID_POLL_TIMER = wxID_HIGHEST + 701;
struct CaseContext
{
bool passed = true;
std::vector<std::string> failures;
void Expect( bool aCondition, const std::string& aMessage )
{
if( !aCondition )
{
passed = false;
failures.push_back( aMessage );
}
}
};
std::string JoinFailures( const std::vector<std::string>& aFailures )
{
std::ostringstream oss;
for( std::size_t i = 0; i < aFailures.size(); ++i )
{
if( i > 0 )
oss << " | ";
oss << aFailures[i];
}
return oss.str();
}
void LogLine( const std::string& aLine )
{
#ifdef __EMSCRIPTEN__
EM_ASM( { console.log( UTF8ToString( $0 ) ); }, aLine.c_str() );
#else
std::printf( "%s\n", aLine.c_str() );
#endif
}
#ifdef __EMSCRIPTEN__
// --- JS helpers ----------------------------------------------------------------
// Park the calling stack until JS resolves the token (races_resolve_token_after).
EM_ASYNC_JS( int, races_await_token, ( int aToken ), {
Module.__racesWaits = Module.__racesWaits || {};
return await new Promise( ( resolve ) => { Module.__racesWaits[aToken] = resolve; } );
} );
// Resolve a parked token after a JS-side delay (independent of the C++ world,
// so it fires even while every C++ stack is parked).
EM_JS( void, races_resolve_token_after, ( int aToken, int aValue, int aDelayMs ), {
setTimeout( function() {
var w = Module.__racesWaits && Module.__racesWaits[aToken];
if( w ) { delete Module.__racesWaits[aToken]; w( aValue ); }
else { console.log( '[ASYNCIFY_RACES] WARN resolve-token ' + aToken + ' had no waiter' ); }
}, aDelayMs );
} );
// Plain parked sleep.
EM_ASYNC_JS( int, races_sleep_ms, ( int aMs ), {
await new Promise( ( r ) => setTimeout( r, aMs ) );
return 1;
} );
// Schedule an async ccall into an exported C function on a FRESH JS/wasm stack.
// This is how the harness drives suspensions while every C++ stack is parked
// (mirrors KiCad's EndModal/clipboard work arriving on fresh event stacks).
EM_JS( void, races_schedule_ccall, ( const char* aFunc, int aDelayMs ), {
var fn = UTF8ToString( aFunc );
setTimeout( function() {
try {
var p = Module.ccall( fn, null, [], [], { async: true } );
if( p && p.catch )
p.catch( function( e ) { console.error( '[ASYNCIFY_RACES] ccall ' + fn + ' rejected: ' + e ); } );
} catch( e ) {
console.error( '[ASYNCIFY_RACES] ccall ' + fn + ' threw: ' + e );
}
}, aDelayMs );
} );
// Watchdog: if the scenario hasn't marked itself done in aMs, dump the Asyncify
// state and emit a FAIL line. JS-side, so it fires even when C++ is wedged.
EM_JS( void, races_arm_watchdog, ( const char* aName, int aMs ), {
var name = UTF8ToString( aName );
Module.__racesDone = Module.__racesDone || {};
setTimeout( function() {
if( !Module.__racesDone[name] ) {
var st = ( typeof Asyncify !== 'undefined' ) ? Asyncify.state : 'n/a';
var cd = ( typeof Asyncify !== 'undefined' ) ? ( Asyncify.currData || 0 ) : 'n/a';
var tr = ( typeof Fibers !== 'undefined' ) ? Fibers.trampolineRunning : 'n/a';
var nf = ( typeof Fibers !== 'undefined' ) ? Fibers.nextFiber : 'n/a';
console.log( '[ASYNCIFY_RACES] WATCHDOG ' + name + ' state=' + st + ' currData=' + cd
+ ' trampolineRunning=' + tr + ' nextFiber=' + nf );
console.log( '[ASYNCIFY_RACES] FAIL ' + name + ' :: watchdog timeout (suspension never completed)' );
}
}, aMs );
} );
EM_JS( void, races_mark_done, ( const char* aName ), {
Module.__racesDone = Module.__racesDone || {};
Module.__racesDone[UTF8ToString( aName )] = true;
} );
// Quiescence invariant sampled from C++ between scenarios. NOTE: this runs on a
// stack that may itself have been resumed via Fibers.trampoline(), in which case
// Fibers.trampolineRunning is legitimately true — so the guard is deliberately
// NOT part of this check (a genuinely stuck guard wedges the next fiber swap and
// is caught by the scenario watchdogs instead).
EM_JS( int, races_quiescent, (), {
try {
var stOk = ( typeof Asyncify === 'undefined' ) || Asyncify.state === 0;
var cdOk = ( typeof Asyncify === 'undefined' ) || !Asyncify.currData;
var nfOk = ( typeof Fibers === 'undefined' ) || !Fibers.nextFiber;
return ( stOk && cdOk && nfOk ) ? 1 : 0;
} catch( e ) {
return 0;
}
} );
EM_JS( void, races_log_state, ( const char* aTag ), {
try {
var tag = UTF8ToString( aTag );
var st = ( typeof Asyncify !== 'undefined' ) ? Asyncify.state : 'n/a';
var cd = ( typeof Asyncify !== 'undefined' ) ? ( Asyncify.currData || 0 ) : 'n/a';
var tr = ( typeof Fibers !== 'undefined' ) ? Fibers.trampolineRunning : 'n/a';
var nf = ( typeof Fibers !== 'undefined' ) ? Fibers.nextFiber : 'n/a';
console.log( '[ASYNCIFY_RACES] STATE ' + tag + ' state=' + st + ' currData=' + cd
+ ' trampolineRunning=' + tr + ' nextFiber=' + nf );
} catch( e ) {}
} );
// Throw a raw JS error out of the current wasm frame. Used inside the nested
// quasi-modal pump to force the pump's `await ccall('ProcessEvents')` to reject
// (the c27fe8bf silent-stall path).
EM_JS( void, races_throw_js_error, (), {
throw new Error( 'races forced pump error' );
} );
#endif // __EMSCRIPTEN__
} // namespace
// ---------------------------------------------------------------------------------
// Exported helpers driven from JS on fresh stacks (fire-and-forget async ccalls).
// Globals because ccall'd plain C functions have no frame pointer.
// ---------------------------------------------------------------------------------
static int g_token2Value = 0; // out_of_order: second parker's result
static bool g_token2Done = false;
static std::vector<std::string>* g_oooSeq = nullptr;
static int g_wdtBValue = 0; // wakeup_during_transition: B-side result
static bool g_wdtBDone = false;
static wxDialog* g_activeModal = nullptr;
extern "C" {
// A complete fiber swap cycle on a fresh stack (Call + Resume to completion).
// Mirrors KiCad's EndModal-driven tool teardown swaps that clobber a parked sleep.
EMSCRIPTEN_KEEPALIVE void races_swap_once()
{
TestCoroutine co( []( TestCoroutine& self ) { self.Yield( 7 ); } );
co.Call( 1 );
co.Resume( 2 );
LogLine( "[ASYNCIFY_RACES] SWAP-ONCE done" );
}
// Park a second, independent stack on token 2 (out_of_order scenario).
EMSCRIPTEN_KEEPALIVE void races_park_token2()
{
#ifdef __EMSCRIPTEN__
LogLine( "[ASYNCIFY_RACES] OOO second parker parking" );
g_token2Value = races_await_token( 2 );
g_token2Done = true;
if( g_oooSeq )
g_oooSeq->push_back( "t2" );
LogLine( "[ASYNCIFY_RACES] OOO second parker resumed" );
#endif
}
// Park a stack on token 11 (wakeup_during_transition B side).
EMSCRIPTEN_KEEPALIVE void races_wdt_park_b()
{
#ifdef __EMSCRIPTEN__
LogLine( "[ASYNCIFY_RACES] WDT B parking" );
g_wdtBValue = races_await_token( 11 );
g_wdtBDone = true;
LogLine( "[ASYNCIFY_RACES] WDT B resumed" );
#endif
}
// End the active modal from a fresh stack (mirrors KiCad's EndModal arriving
// while a clipboard sleep is parked).
EMSCRIPTEN_KEEPALIVE void races_end_active_modal()
{
if( g_activeModal )
{
LogLine( "[ASYNCIFY_RACES] ending active modal from fresh stack" );
g_activeModal->EndModal( wxID_OK );
}
}
} // extern "C"
// ---------------------------------------------------------------------------------
// The scenario-driver frame
// ---------------------------------------------------------------------------------
class RacesDialog : public wxDialog
{
public:
RacesDialog( wxWindow* aParent, const wxString& aTag ) :
wxDialog( aParent, wxID_ANY, aTag, wxDefaultPosition, wxSize( 260, 120 ) )
{
}
};
class RacesFrame : public wxFrame
{
public:
RacesFrame( const std::string& aOnly, bool aSleepParkMode ) :
wxFrame( nullptr, wxID_ANY, "Asyncify Races Test", wxDefaultPosition,
wxSize( 900, 600 ) ),
m_only( aOnly ),
m_sleepParkMode( aSleepParkMode ),
m_scenarioTimer( this, ID_SCENARIO_TIMER ),
m_pollTimer( this, ID_POLL_TIMER )
{
wxPanel* panel = new wxPanel( this );
wxBoxSizer* sizer = new wxBoxSizer( wxVERTICAL );
m_summary = new wxStaticText( panel, wxID_ANY, "Running asyncify race scenarios..." );
sizer->Add( m_summary, 0, wxEXPAND | wxALL, 8 );
panel->SetSizer( sizer );
CreateStatusBar();
Bind( wxEVT_TIMER, &RacesFrame::OnScenarioTimer, this, ID_SCENARIO_TIMER );
Bind( wxEVT_TIMER, &RacesFrame::OnPollTimer, this, ID_POLL_TIMER );
// Scenarios run AFTER the main loop parks (CallAfter fires on the first
// rAF ticks) - the same place KiCad tool interactions live.
CallAfter( [this]() { RunNext(); } );
}
private:
// ----- bookkeeping -----
bool ShouldRun( const std::string& aName ) const
{
if( m_sleepParkMode )
return aName == "unwind_through_promise";
if( !m_only.empty() )
return m_only == aName;
// Default battery: everything that is safe to chain in one page load.
// modal_in_modal_in_modal, wakeup_during_transition and
// nested_quasi_modal_pump_error are ?only= singles - they intentionally
// wedge/crash while their bugs are unfixed and would kill the chain.
return aName == "post_park_fiber_swap"
|| aName == "sleep_inside_fiber_inside_modal"
|| aName == "out_of_order_sleep_resolution"
|| aName == "long_parked_sleep_clobbered_by_swap";
}
void Finalize( const std::string& aName, CaseContext&& aCtx )
{
#ifdef __EMSCRIPTEN__
races_mark_done( aName.c_str() );
#endif
if( aCtx.passed )
LogLine( "[ASYNCIFY_RACES] PASS " + aName );
else
LogLine( "[ASYNCIFY_RACES] FAIL " + aName + " :: " + JoinFailures( aCtx.failures ) );
m_total += 1;
m_passed += aCtx.passed ? 1 : 0;
CallAfter( [this]() { RunNext(); } );
}
void CheckQuiescent( CaseContext& aCtx, const std::string& aWhere )
{
#ifdef __EMSCRIPTEN__
aCtx.Expect( races_quiescent() == 1,
"asyncify machine not quiescent " + aWhere
+ " (state/currData/trampolineRunning/nextFiber - see STATE log)" );
if( races_quiescent() != 1 )
races_log_state( ( "non-quiescent-" + aWhere ).c_str() );
#endif
}
void RunNext()
{
static const std::vector<std::pair<std::string, void ( RacesFrame::* )()>> ALL = {
{ "post_park_fiber_swap", &RacesFrame::Scenario_PostParkFiberSwap },
{ "modal_in_modal_in_modal", &RacesFrame::Scenario_TripleModal },
{ "sleep_inside_fiber_inside_modal", &RacesFrame::Scenario_SleepInsideFiberInsideModal },
{ "out_of_order_sleep_resolution", &RacesFrame::Scenario_OutOfOrder },
{ "long_parked_sleep_clobbered_by_swap", &RacesFrame::Scenario_LongParkedSleep },
{ "wakeup_during_transition", &RacesFrame::Scenario_WakeupDuringTransition },
{ "nested_quasi_modal_pump_error", &RacesFrame::Scenario_NestedPumpError },
{ "unwind_through_promise", &RacesFrame::Scenario_UnwindThroughPromise },
};
while( m_nextIndex < ALL.size() )
{
const auto& entry = ALL[m_nextIndex];
m_nextIndex += 1;
if( ShouldRun( entry.first ) )
{
LogLine( "[ASYNCIFY_RACES] CASE " + entry.first );
( this->*( entry.second ) )();
return;
}
}
FinalizeSuite();
}
void FinalizeSuite()
{
std::ostringstream oss;
oss << "[ASYNCIFY_RACES] SUMMARY total=" << m_total << " passed=" << m_passed
<< " failed=" << ( m_total - m_passed );
LogLine( oss.str() );
m_summary->SetLabel( wxString::Format( "Done: %d/%d passed", m_passed, m_total ) );
}
// ----- scenario 1: post_park_fiber_swap -------------------------------------
// The KiCad hang topology. OnInit already did a fiber swap, so the park throw
// went through the live trampoline. With the self-heal shim the guard was
// reset and this swap works; with SHIM_DISABLE_TRAMPOLINE_HEAL=1 the guard is
// stuck true, the Call() below never returns, and the watchdog fires.
void Scenario_PostParkFiberSwap()
{
#ifdef __EMSCRIPTEN__
races_arm_watchdog( "post_park_fiber_swap", 2500 );
races_log_state( "S1-pre-swap" );
#endif
CaseContext ctx;
{
TestCoroutine co( []( TestCoroutine& self ) { self.Yield( 42 ); } );
bool running = co.Call( 1 );
ctx.Expect( running, "post-park fiber should yield" );
ctx.Expect( co.LastReturnValue() == 42, "yield value should be 42" );
running = co.Resume( 2 );
ctx.Expect( !running, "post-park fiber should finish" );
}
#ifdef __EMSCRIPTEN__
races_log_state( "S1-post-swap" );
#endif
CheckQuiescent( ctx, "after post-park swap" );
Finalize( "post_park_fiber_swap", std::move( ctx ) );
}
// ----- scenario 2: modal_in_modal_in_modal ----------------------------------
// Three nested ShowModal sleeps (LIFO park stack three deep), closed
// innermost-first, each from a timer firing inside the innermost pump.
void Scenario_TripleModal()
{
#ifdef __EMSCRIPTEN__
races_arm_watchdog( "modal_in_modal_in_modal", 6000 );
#endif
m_tripleCtx = std::make_unique<CaseContext>();
m_tripleSeq.clear();
m_pendingScenario = [this]() { TripleLevelB(); };
m_scenarioTimer.StartOnce( 40 );
RacesDialog dlgA( this, "tripleA" );
m_dlgA = &dlgA;
int ra = dlgA.ShowModal(); // parks this (scenario) stack
m_dlgA = nullptr;
// Resumes only after B and C closed.
m_tripleSeq.push_back( "A" );
m_tripleCtx->Expect( ra == 101, "modal A should return 101, got " + std::to_string( ra ) );
m_tripleCtx->Expect( m_tripleSeq.size() == 3 && m_tripleSeq[0] == "C" && m_tripleSeq[1] == "B"
&& m_tripleSeq[2] == "A",
"modals should resume LIFO (C,B,A)" );
CheckQuiescent( *m_tripleCtx, "after triple modal" );
Finalize( "modal_in_modal_in_modal", std::move( *m_tripleCtx ) );
m_tripleCtx.reset();
}
void TripleLevelB()
{
m_pendingScenario = [this]() { TripleLevelC(); };
m_scenarioTimer.StartOnce( 40 );
RacesDialog dlgB( this, "tripleB" );
m_dlgB = &dlgB;
int rb = dlgB.ShowModal(); // parks the A-pump tick stack
m_dlgB = nullptr;
m_tripleSeq.push_back( "B" );
m_tripleCtx->Expect( rb == 102, "modal B should return 102, got " + std::to_string( rb ) );
if( m_dlgA )
m_dlgA->EndModal( 101 );
}
void TripleLevelC()
{
m_pendingScenario = [this]() {
if( m_dlgC )
m_dlgC->EndModal( 103 );
};
m_scenarioTimer.StartOnce( 40 );
RacesDialog dlgC( this, "tripleC" );
m_dlgC = &dlgC;
int rc = dlgC.ShowModal(); // parks the B-pump tick stack
m_dlgC = nullptr;
m_tripleSeq.push_back( "C" );
m_tripleCtx->Expect( rc == 103, "modal C should return 103, got " + std::to_string( rc ) );
if( m_dlgB )
m_dlgB->EndModal( 102 );
}
// ----- scenario 3: sleep_inside_fiber_inside_modal ---------------------------
// Modal sleep parked -> fiber started inside its pump -> fiber body parks in
// ANOTHER sleep -> resolves -> fiber yields -> resumes -> modal closes.
// Three different buffers (modal malloc, fiber struct, sleep malloc) in flight.
void Scenario_SleepInsideFiberInsideModal()
{
#ifdef __EMSCRIPTEN__
races_arm_watchdog( "sleep_inside_fiber_inside_modal", 6000 );
#endif
m_sifimCtx = std::make_unique<CaseContext>();
m_pendingScenario = [this]() { RunSleepInsideFiber(); };
m_scenarioTimer.StartOnce( 40 );
RacesDialog dlg( this, "sifim" );
m_dlgA = &dlg;
int result = dlg.ShowModal();
m_dlgA = nullptr;
m_sifimCtx->Expect( result == wxID_OK, "sifim modal should return wxID_OK" );
CheckQuiescent( *m_sifimCtx, "after sleep-inside-fiber-inside-modal" );
Finalize( "sleep_inside_fiber_inside_modal", std::move( *m_sifimCtx ) );
m_sifimCtx.reset();
}
void RunSleepInsideFiber()
{
#ifdef __EMSCRIPTEN__
CaseContext* ctx = m_sifimCtx.get();
{
TestCoroutine co( [ctx]( TestCoroutine& self ) {
// Parks the FIBER stack in a malloc'd sleep buffer while the
// modal sleep is also parked.
int r = races_sleep_ms( 150 );
ctx->Expect( r == 1, "fiber-side sleep should return 1" );
self.Yield( 901 );
} );
bool running = co.Call( 1 );
ctx->Expect( running, "fiber should yield after its sleep" );
ctx->Expect( co.LastReturnValue() == 901, "fiber yield value should be 901" );
running = co.Resume( 2 );
ctx->Expect( !running, "fiber should finish" );
}
if( m_dlgA )
m_dlgA->EndModal( wxID_OK );
#endif
}
// ----- scenario 4: out_of_order_sleep_resolution -----------------------------
// Two sleeps parked on independent stacks, resolved FIFO (not LIFO).
void Scenario_OutOfOrder()
{
#ifdef __EMSCRIPTEN__
races_arm_watchdog( "out_of_order_sleep_resolution", 4000 );
m_oooCtx = std::make_unique<CaseContext>();
m_oooSeqStore.clear();
g_oooSeq = &m_oooSeqStore;
g_token2Done = false;
g_token2Value = 0;
// Second parker arrives on a fresh stack at +50ms; resolutions at
// +600 (token 1, parked FIRST) and +1000 (token 2) - FIFO order.
races_schedule_ccall( "races_park_token2", 50 );
races_resolve_token_after( 1, 11, 600 );
races_resolve_token_after( 2, 22, 1000 );
int v1 = races_await_token( 1 ); // parks THIS stack
// Resumed at +600 while token 2 still parked.
m_oooSeqStore.push_back( "t1" );
m_oooCtx->Expect( v1 == 11, "token 1 value should be 11" );
// Wait (event-driven, not blocking) for the second parker to finish.
m_pollPredicate = []() { return g_token2Done; };
m_pollBudgetMs = 3000;
m_onPollDone = [this]( bool aOk ) {
m_oooCtx->Expect( aOk, "second parker should resume within budget" );
m_oooCtx->Expect( g_token2Value == 22, "token 2 value should be 22" );
m_oooCtx->Expect( m_oooSeqStore.size() == 2 && m_oooSeqStore[0] == "t1"
&& m_oooSeqStore[1] == "t2",
"continuations should run in resolution order t1,t2" );
g_oooSeq = nullptr;
CheckQuiescent( *m_oooCtx, "after out-of-order resolution" );
Finalize( "out_of_order_sleep_resolution", std::move( *m_oooCtx ) );
m_oooCtx.reset();
};
m_pollTimer.Start( 50 );
#else
CaseContext ctx;
Finalize( "out_of_order_sleep_resolution", std::move( ctx ) );
#endif
}
// ----- scenario 5: long_parked_sleep_clobbered_by_swap ------------------------
// The KiCad clipboard crash shape: a long-parked sleep crossed by complete
// fiber-swap cycles on fresh stacks. With handlesleep.js the sleep's buffer
// is restored at wakeUp; with SHIM_DISABLE_HANDLESLEEP=1 doRewind reads a
// clobbered currData -> "index out of bounds".
void Scenario_LongParkedSleep()
{
#ifdef __EMSCRIPTEN__
races_arm_watchdog( "long_parked_sleep_clobbered_by_swap", 4000 );
CaseContext ctx;
races_schedule_ccall( "races_swap_once", 300 );
races_schedule_ccall( "races_swap_once", 600 );
races_resolve_token_after( 3, 33, 1200 );
int v = races_await_token( 3 ); // parked for 1.2s, swaps land mid-park
ctx.Expect( v == 33, "long-parked sleep should resume with 33" );
CheckQuiescent( ctx, "after long-parked sleep" );
Finalize( "long_parked_sleep_clobbered_by_swap", std::move( ctx ) );
#else
CaseContext ctx;
Finalize( "long_parked_sleep_clobbered_by_swap", std::move( ctx ) );
#endif
}
// ----- scenario 6 (?only= single): wakeup_during_transition -------------------
// The KiCad "ENTER at state=2" family: a modal teardown arrives on a fresh
// stack while a token sleep is parked inside the modal's own pump, then the
// token resolves into the half-torn-down world. Closest deterministic analog
// of the clipboard-poll + EndModal collision.
void Scenario_WakeupDuringTransition()
{
#ifdef __EMSCRIPTEN__
races_arm_watchdog( "wakeup_during_transition", 5000 );
#endif
m_wdtCtx = std::make_unique<CaseContext>();
g_wdtBDone = false;
g_wdtBValue = 0;
m_pendingScenario = [this]() { RunWdtInsidePump(); };
m_scenarioTimer.StartOnce( 40 );
RacesDialog dlg( this, "wdt" );
g_activeModal = &dlg;
int result = dlg.ShowModal();
g_activeModal = nullptr;
m_wdtCtx->Expect( result == wxID_OK, "wdt modal should return wxID_OK" );
// The B-side sleep resolves after the modal is gone.
m_pollPredicate = []() { return g_wdtBDone; };
m_pollBudgetMs = 3000;
m_onPollDone = [this]( bool aOk ) {
m_wdtCtx->Expect( aOk, "B-side sleep should resume after modal teardown" );
m_wdtCtx->Expect( g_wdtBValue == 2, "B-side value should be 2" );
CheckQuiescent( *m_wdtCtx, "after wakeup-during-transition" );
Finalize( "wakeup_during_transition", std::move( *m_wdtCtx ) );
m_wdtCtx.reset();
};
m_pollTimer.Start( 50 );
}
void RunWdtInsidePump()
{
#ifdef __EMSCRIPTEN__
// Park a fresh stack on token 11 (B side) at +0ms - it outlives the modal.
races_schedule_ccall( "races_wdt_park_b", 0 );
// Tear the modal down from a fresh stack at +200ms (while B is parked
// AND this pump-tick stack is parked on token 10 below).
races_schedule_ccall( "races_end_active_modal", 200 );
// Resolve THIS stack's token at +300ms (after the modal teardown began)
// and B's at +350ms - both land in the post-teardown turbulence.
races_resolve_token_after( 10, 1, 300 );
races_resolve_token_after( 11, 2, 350 );
int a = races_await_token( 10 ); // parks this pump-tick stack
m_wdtCtx->Expect( a == 1, "A-side token should resolve to 1" );
// Immediately extend the in-flight window with a fiber swap cycle.
TestCoroutine co( []( TestCoroutine& self ) { self.Yield( 5 ); } );
co.Call( 1 );
bool running = co.Resume( 2 );
m_wdtCtx->Expect( !running, "post-wake fiber should finish" );
#endif
}
// ----- scenario 7 (?only= single): nested_quasi_modal_pump_error --------------
// c27fe8bf's wxWasmRunNestedLoop pump catches a ProcessEvents rejection and
// stops pumping WITHOUT resolving its promise: the nested DoRun stays parked
// forever (silent stall). Red until the wx-layer resolve-on-error fix.
void Scenario_NestedPumpError()
{
#ifdef __EMSCRIPTEN__
races_arm_watchdog( "nested_quasi_modal_pump_error", 3000 );
CaseContext ctx;
// Queue the bomb as a PENDING EVENT: the nested pump's ProcessEvents ->
// ProcessPendingEvents dispatches it, so the JS error propagates out of
// the pump's awaited ccall and rejects it. (A wx timer would NOT work:
// wasm timers fire via emscripten_async_call/callUserCallback and
// bypass the pump entirely.)
CallAfter( []() { races_throw_js_error(); } );
wxGUIEventLoop nestedLoop;
LogLine( "[ASYNCIFY_RACES] entering nested quasi-modal loop" );
nestedLoop.Run(); // wxWasmRunNestedLoop parks here
LogLine( "[ASYNCIFY_RACES] nested loop returned" );
ctx.Expect( true, "" ); // reaching this line at all is the fix
CheckQuiescent( ctx, "after nested pump error" );
Finalize( "nested_quasi_modal_pump_error", std::move( ctx ) );
#else
CaseContext ctx;
Finalize( "nested_quasi_modal_pump_error", std::move( ctx ) );
#endif
}
// ----- scenario 8 (mode=sleep-park): unwind_through_promise -------------------
// OnInit made the LAST pre-park suspension a sleep, so the park throw escaped
// through that sleep's wakeUp promise reaction. The spec asserts no "unwind"
// reaches pageerror/console; this C++ side just proves the app stayed alive.
void Scenario_UnwindThroughPromise()
{
CaseContext ctx;
// A post-park fiber swap doubles as a liveness check in this mode too.
TestCoroutine co( []( TestCoroutine& self ) { self.Yield( 77 ); } );
bool running = co.Call( 1 );
ctx.Expect( running && co.LastReturnValue() == 77, "post-park fiber should work" );
co.Resume( 2 );
CheckQuiescent( ctx, "after sleep-park startup" );
Finalize( "unwind_through_promise", std::move( ctx ) );
}
// ----- timers -----
void OnScenarioTimer( wxTimerEvent& )
{
if( m_pendingScenario )
{
auto scenario = std::move( m_pendingScenario );
m_pendingScenario = nullptr;
scenario();
}
}
void OnPollTimer( wxTimerEvent& )
{
if( !m_pollPredicate )
{
m_pollTimer.Stop();
return;
}
m_pollBudgetMs -= 50;
bool ok = m_pollPredicate();
if( ok || m_pollBudgetMs <= 0 )
{
m_pollTimer.Stop();
m_pollPredicate = nullptr;
auto done = std::move( m_onPollDone );
m_onPollDone = nullptr;
if( done )
done( ok );
}
}
private:
std::string m_only;
bool m_sleepParkMode;
std::size_t m_nextIndex = 0;
int m_total = 0;
int m_passed = 0;
wxTimer m_scenarioTimer;
std::function<void()> m_pendingScenario;
wxTimer m_pollTimer;
std::function<bool()> m_pollPredicate;
std::function<void( bool )> m_onPollDone;
int m_pollBudgetMs = 0;
wxDialog* m_dlgA = nullptr;
wxDialog* m_dlgB = nullptr;
wxDialog* m_dlgC = nullptr;
std::unique_ptr<CaseContext> m_tripleCtx;
std::vector<std::string> m_tripleSeq;
std::unique_ptr<CaseContext> m_sifimCtx;
std::unique_ptr<CaseContext> m_oooCtx;
std::vector<std::string> m_oooSeqStore;
std::unique_ptr<CaseContext> m_wdtCtx;
wxStaticText* m_summary = nullptr;
};
class RacesApp : public wxApp
{
public:
bool OnInit() override
{
std::string only;
bool sleepPark = false;
#ifdef __EMSCRIPTEN__
// Params travel in the URL HASH (#only=...&mode=...), not the query:
// `npx serve` cleanUrls-redirects *.html and drops the query string on
// the way. The hash never reaches the server. (Query kept as fallback.)
char onlyBuf[64] = { 0 };
EM_ASM( {
try {
var p = new URLSearchParams( ( location.hash || "" ).replace( /^#/, "" ) );
var v = p.get( 'only' ) || new URLSearchParams( location.search ).get( 'only' ) || "";
stringToUTF8( v.slice( 0, 63 ), $0, 64 );
} catch( e ) {}
}, onlyBuf );
only = onlyBuf;
sleepPark = EM_ASM_INT( {
try {
var p = new URLSearchParams( ( location.hash || "" ).replace( /^#/, "" ) );
var m = p.get( 'mode' ) || new URLSearchParams( location.search ).get( 'mode' );
return ( m === 'sleep-park' ) ? 1 : 0;
} catch( e ) { return 0; }
} ) == 1;
LogLine( "[ASYNCIFY_RACES] PARAMS only='" + only + "' sleepPark="
+ std::to_string( sleepPark ? 1 : 0 ) );
#endif
// THE LOAD-BEARING TOPOLOGY: complete a fiber swap cycle during OnInit.
// From here on, main() runs inside Fibers.trampoline()'s do/while; the
// upcoming emscripten_set_main_loop(...,1) park throw will tear through
// that live frame (exactly what KiCad's startup tool burst does).
{
TestCoroutine co( []( TestCoroutine& self ) { self.Yield( 1 ); } );
co.Call( 1 );
co.Resume( 2 );
LogLine( "[ASYNCIFY_RACES] PRE-PARK-SWAP done" );
}
#ifdef __EMSCRIPTEN__
if( sleepPark )
{
// Make the LAST pre-park suspension a sleep: main is then resumed
// from the sleep's wakeUp (trampoline frame already closed), and the
// park throw escapes through the wakeUp promise reaction instead.
races_sleep_ms( 30 );
LogLine( "[ASYNCIFY_RACES] PRE-PARK-SLEEP done (sleep-park mode)" );
}
#endif
RacesFrame* frame = new RacesFrame( only, sleepPark );
frame->Show();
return true;
}
};
wxIMPLEMENT_APP( RacesApp );

View file

@ -0,0 +1,259 @@
import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
// Red-green specs for the Asyncify race-condition harness
// (tests/apps/standalone/asyncify-races/races_test.cpp — see docs/features/async/).
//
// Two kinds of tests here:
// - GREEN-target tests assert the desired end state (clean pass, clean console).
// While a fix is missing they FAIL — that failing run is the recorded "red".
// - ABLATION tests run the shim-ablated builds (races_test_noheal.js /
// races_test_nosleepfix.js) and assert the historical bug REPRODUCES.
// They pin the disease so the shim fixes stay testable forever.
const BATTERY = [
'post_park_fiber_swap',
'sleep_inside_fiber_inside_modal',
'out_of_order_sleep_resolution',
'long_parked_sleep_clobbered_by_swap',
];
const CRASH_SIGNATURES = [
'index out of bounds',
'indirect call to null',
'invalid state',
'unwind',
// assertion-free builds surface a clobbered doRewind as a TypeError
'is not a function',
];
function findSummary(logs: string[]) {
return logs.find((log) => log.includes('[ASYNCIFY_RACES] SUMMARY'));
}
function parseSummary(summary: string) {
const match = summary.match(/total=(\d+)\s+passed=(\d+)\s+failed=(\d+)/);
expect(match, 'summary line should be parseable').not.toBeNull();
return { total: Number(match![1]), passed: Number(match![2]), failed: Number(match![3]) };
}
function crashLines(testLogger: { consoleLogs: string[]; errors: string[] }) {
const all = [...testLogger.consoleLogs, ...testLogger.errors];
return all.filter(
(line) =>
CRASH_SIGNATURES.some((sig) => line.toLowerCase().includes(sig)) &&
// The harness's own meta-output mentions these words legitimately.
!line.includes('[ASYNCIFY_RACES]')
);
}
function realErrors(testLogger: { errors: string[] }) {
return testLogger.errors.filter((e) => !e.includes('favicon'));
}
test.describe('Asyncify races — green targets (full shims)', () => {
test('battery: all chained scenarios pass with a clean console', async ({
page,
testLogger,
}) => {
await page.goto('/standalone/asyncify-races/races_test.html');
const loaded = await tryLoadApp(page, 30000);
expect(loaded, 'races harness should load').toBe(true);
await expect
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
timeout: 60000,
message: 'battery should emit a final SUMMARY line (a missing one means a wedge/hang)',
})
.not.toBeNull();
const { total, passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
const failLogs = testLogger.consoleLogs.filter((l) => l.includes('[ASYNCIFY_RACES] FAIL '));
const passLogs = testLogger.consoleLogs.filter((l) => l.includes('[ASYNCIFY_RACES] PASS '));
expect(total).toBe(BATTERY.length);
expect(passed).toBe(BATTERY.length);
expect(failed).toBe(0);
expect(failLogs, `FAIL lines: ${failLogs.join(' || ')}`).toHaveLength(0);
expect(passLogs).toHaveLength(BATTERY.length);
for (const name of BATTERY) {
expect.soft(
testLogger.consoleLogs.some((l) => l.includes(`[ASYNCIFY_RACES] PASS ${name}`)),
`scenario ${name} should PASS`
).toBe(true);
}
expect(crashLines(testLogger), 'no crash signatures in console').toHaveLength(0);
expect(realErrors(testLogger), 'no page errors').toHaveLength(0);
});
test('modal_in_modal_in_modal: three nested ShowModals resolve LIFO', async ({
page,
testLogger,
}) => {
// RED today: wx dialog.cpp keeps the modal resolver in a single slot
// (Module._endModal = fn; delete after use), so with three nested modals
// the middle EndModal resolves nothing and its ShowModal parks forever.
// GREEN after the Stage-3 wx fix (LIFO resolver stack).
await page.goto('/standalone/asyncify-races/races_test.html#only=modal_in_modal_in_modal');
await tryLoadApp(page, 30000);
await expect
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
timeout: 45000,
message: 'triple modal should complete (middle EndModal must not be lost)',
})
.not.toBeNull();
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
expect(passed).toBe(1);
expect(failed).toBe(0);
expect(crashLines(testLogger), 'no crash signatures in console').toHaveLength(0);
});
test('wakeup_during_transition: modal teardown over parked sleeps stays clean', async ({
page,
testLogger,
}) => {
await page.goto('/standalone/asyncify-races/races_test.html#only=wakeup_during_transition');
await tryLoadApp(page, 30000);
await expect
.poll(() => findSummary(testLogger.consoleLogs) ?? null, { timeout: 45000 })
.not.toBeNull();
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
expect(passed).toBe(1);
expect(failed).toBe(0);
expect(crashLines(testLogger), 'no crash signatures in console').toHaveLength(0);
expect(realErrors(testLogger), 'no page errors').toHaveLength(0);
});
test('nested_quasi_modal_pump_error: pump rejection must not leak the parked DoRun', async ({
page,
testLogger,
}) => {
await page.goto(
'/standalone/asyncify-races/races_test.html#only=nested_quasi_modal_pump_error'
);
await tryLoadApp(page, 30000);
await expect
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
timeout: 45000,
message:
'nested loop must exit after a pump error (silent stall = the c27fe8bf bug, fixed in wx evtloop.cpp)',
})
.not.toBeNull();
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
expect(passed).toBe(1);
expect(failed).toBe(0);
});
test('sleep-park mode: park throw must not escape as an unhandled "unwind" rejection', async ({
page,
testLogger,
}) => {
await page.goto('/standalone/asyncify-races/races_test.html#mode=sleep-park');
await tryLoadApp(page, 30000);
await expect
.poll(() => findSummary(testLogger.consoleLogs) ?? null, { timeout: 45000 })
.not.toBeNull();
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
expect(passed).toBe(1);
expect(failed).toBe(0);
const unwindLeaks = [...testLogger.errors, ...testLogger.consoleLogs].filter(
(l) =>
l.toLowerCase().includes('unwind') &&
!l.includes('[ASYNCIFY_RACES]') &&
// console *log* lines about unwind from our own shims are fine; errors are not
(testLogger.errors.includes(l) || l.toLowerCase().includes('uncaught'))
);
expect(unwindLeaks, `unwind escaped the park: ${unwindLeaks.join(' || ')}`).toHaveLength(0);
});
});
test.describe('Asyncify races — ablation pins (the disease stays reproducible)', () => {
test('no trampoline heal: the park wedges the guard and the post-park swap hangs', async ({
page,
testLogger,
}) => {
await page.goto(
'/standalone/asyncify-races/races_test_noheal.html#only=post_park_fiber_swap'
);
await tryLoadApp(page, 30000);
// The scenario's JS watchdog fires after 2.5s with a state dump.
await expect
.poll(
() =>
testLogger.consoleLogs.find((l) =>
l.includes('[ASYNCIFY_RACES] WATCHDOG post_park_fiber_swap')
) ?? null,
{ timeout: 30000, message: 'watchdog should fire in the ablated build' }
)
.not.toBeNull();
const watchdog = testLogger.consoleLogs.find((l) =>
l.includes('[ASYNCIFY_RACES] WATCHDOG post_park_fiber_swap')
)!;
// The exact stuck-guard signature traced in docs/features/async/: the park throw
// tore through Fibers.trampoline()'s do/while, leaving the guard true.
expect(watchdog, 'stuck trampoline guard should be visible').toContain(
'trampolineRunning=true'
);
expect(
testLogger.consoleLogs.some((l) =>
l.includes('[ASYNCIFY_RACES] FAIL post_park_fiber_swap')
),
'scenario should be reported FAILED by the watchdog'
).toBe(true);
// And the suite never completes — the swap is stranded forever.
expect(findSummary(testLogger.consoleLogs)).toBeUndefined();
});
test('no handleSleep fix: fiber swaps clobber the parked sleep buffer', async ({
page,
testLogger,
}) => {
await page.goto(
'/standalone/asyncify-races/races_test_nosleepfix.html#only=long_parked_sleep_clobbered_by_swap'
);
await tryLoadApp(page, 30000);
// Either the wakeUp crashes (index out of bounds family) or the rewind is
// lost and the watchdog reports the stall — both are the recorded disease.
await expect
.poll(
() => {
const crashed = [...testLogger.errors, ...testLogger.consoleLogs].some(
(l) =>
l.toLowerCase().includes('index out of bounds') ||
l.toLowerCase().includes('indirect call to null') ||
l.toLowerCase().includes('invalid state')
);
const stalled = testLogger.consoleLogs.some((l) =>
l.includes('[ASYNCIFY_RACES] FAIL long_parked_sleep_clobbered_by_swap')
);
return crashed || stalled ? 'reproduced' : null;
},
{ timeout: 30000, message: 'ablated build should reproduce the clobber bug' }
)
.not.toBeNull();
// It must NOT have quietly passed.
expect(
testLogger.consoleLogs.some((l) =>
l.includes('[ASYNCIFY_RACES] PASS long_parked_sleep_clobbered_by_swap')
),
'ablated build must not pass the clobber scenario'
).toBe(false);
});
});

View file

@ -493,7 +493,7 @@ test.describe('Eeschema WASM', () => {
const realErrors = testLogger.errors
.slice(baselineErrorCount)
.filter((error) => !error.includes('favicon') && !error.includes('uncaught exception: unwind'));
.filter((error) => !error.includes('favicon'));
expect(realErrors).toEqual([]);
});
});

View file

@ -190,11 +190,6 @@ function runLoadPcbTest(demo: DemoCfg): void {
scale: 'device',
});
// ── The two things this spike actually asserts: no rtree assert,
// no WASM Aborted during the load. The clipboard-polling
// asyncify RuntimeErrors that fire AFTER the board is rendered
// are a separate, pre-existing wasm-port limitation that we
// do not regress on here. ────────────────────────────────────
const allLines = [...testLogger.consoleLogs, ...testLogger.errors];
const rtreeDiag = allLines.filter((l) => l.includes('[RTREE-DIAG]'));
expect(
@ -206,6 +201,26 @@ function runLoadPcbTest(demo: DemoCfg): void {
aborts,
`WASM aborted during ${demo.name} load:\n${aborts.join('\n\n')}`,
).toEqual([]);
// ── Clean-console gate: NO asyncify corruption may surface anywhere in
// the load — not before, not after the board renders. The formerly
// tolerated post-load clipboard/unwind RuntimeErrors are fixed
// (sync clipboard IsSupported in wx; "unwind" sentinel handling in
// scripts/common/shims/handlesleep.js; see docs/features/asyncify-arbiter/).
const asyncifySignatures = [
'index out of bounds',
'indirect call to null',
'uncaught exception: unwind',
'invalid state',
'is not a function',
];
const asyncifyErrors = allLines.filter((l) =>
asyncifySignatures.some((sig) => l.toLowerCase().includes(sig)),
);
expect(
asyncifyErrors,
`Asyncify corruption surfaced during ${demo.name} load:\n${asyncifyErrors.join('\n\n')}`,
).toEqual([]);
});
}

View file

@ -509,7 +509,7 @@ test.describe('PCBnew WASM', () => {
const realErrors = testLogger.errors
.slice(baselineErrorCount)
.filter((error) => !error.includes('favicon') && !error.includes('uncaught exception: unwind'));
.filter((error) => !error.includes('favicon'));
expect(realErrors).toEqual([]);
});
});

View file

@ -36,7 +36,9 @@
"test:gerbview": "npm run test:gerbview:firefox",
"test:gerbview:headed": "npm run test:gerbview:chrome",
"test:coroutine:firefox": "playwright test --config=playwright-coroutine.config.ts --project=firefox",
"test:coroutine:chrome": "playwright test --config=playwright-coroutine.config.ts --project=chromium --headed"
"test:coroutine:chrome": "playwright test --config=playwright-coroutine.config.ts --project=chromium --headed",
"test:asyncify:firefox": "playwright test --config=playwright-asyncify.config.ts --project=firefox",
"test:asyncify:chrome": "playwright test --config=playwright-asyncify.config.ts --project=chromium --headed"
},
"devDependencies": {
"@playwright/test": "^1.40.0",

View file

@ -0,0 +1,80 @@
import { defineConfig, devices } from '@playwright/test';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
// Runs the Asyncify race-condition red-green harness (tests/asyncify/) in both
// Firefox and system Chrome. Mirrors playwright-coroutine.config.ts (Chrome must
// be --headed on ARM Mac; Firefox headless OK).
const PORT_FILE = path.join(__dirname, '.test-port-asyncify');
function findFreePort(): number {
try {
const result = execSync(
'python3 -c "import socket; s=socket.socket(); s.bind((\'\',0)); print(s.getsockname()[1]); s.close()"',
{ encoding: 'utf-8' }
);
return parseInt(result.trim());
} catch {
return 9100 + Math.floor(Math.random() * 800);
}
}
// Same port-pinning scheme as playwright-kicad.config.ts: the main runner picks a
// fresh port and writes the file before workers spawn; workers always reuse it.
function resolvePort(): number {
const isMainRunner = process.argv.slice(2).includes('test');
if (!isMainRunner) {
try {
const existing = parseInt(fs.readFileSync(PORT_FILE, 'utf-8').trim(), 10);
if (existing > 0 && existing < 65536) return existing;
} catch { /* fall through */ }
}
const port = findFreePort();
fs.writeFileSync(PORT_FILE, port.toString());
return port;
}
const port = resolvePort();
export default defineConfig({
globalSetup: './global-setup.ts',
testDir: './asyncify',
testMatch: /asyncify-races.*\.spec\.ts$/,
fullyParallel: false, // one heavy WASM app at a time
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: 'html',
timeout: 120000,
use: {
baseURL: `http://localhost:${port}`,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
// Firefox: headless, reliable on ARM Mac.
name: 'firefox',
use: { ...devices['Desktop Firefox'], viewport: { width: 1280, height: 720 } },
},
{
// System Chrome (real V8) — run via: npm run test:asyncify:chrome (must be --headed).
name: 'chromium',
use: {
channel: 'chrome',
viewport: { width: 1280, height: 720 },
permissions: ['clipboard-read', 'clipboard-write'],
},
},
],
webServer: {
command: `npx serve apps -p ${port} -c ../serve.json`,
port: port,
reuseExistingServer: !process.env.CI,
},
});

@ -1 +1 @@
Subproject commit 765f469f9ea358c370f4db43156b689aa65ce7b1
Subproject commit 014f67e6c1fa6e854474e12e67d70de0e12b84ee