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:
parent
66ce367703
commit
14ca16cbd3
16 changed files with 1593 additions and 13 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -73,3 +73,4 @@ output/
|
|||
/bench/
|
||||
/scripts/bench/vm/
|
||||
|
||||
/tests/.test-port-asyncify
|
||||
|
|
|
|||
155
docs/features/async/07-decisions-and-outcome.md
Normal file
155
docs/features/async/07-decisions-and-outcome.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# 07 — Decisions and outcome (2026-06-12)
|
||||
|
||||
> The dossier (01–06) 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) |
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
59
docs/features/asyncify-arbiter/baseline.md
Normal file
59
docs/features/asyncify-arbiter/baseline.md
Normal 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).
|
||||
65
docs/features/asyncify-arbiter/redgreen.md
Normal file
65
docs/features/asyncify-arbiter/redgreen.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -52,7 +52,20 @@ if (typeof Asyncify !== "undefined") {
|
|||
Asyncify.currData = sleepCtx.capturedData;
|
||||
}
|
||||
cleanup();
|
||||
return wakeUp(result);
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
863
tests/apps/standalone/asyncify-races/races_test.cpp
Normal file
863
tests/apps/standalone/asyncify-races/races_test.cpp
Normal 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 );
|
||||
259
tests/asyncify/asyncify-races.spec.ts
Normal file
259
tests/asyncify/asyncify-races.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
80
tests/playwright-asyncify.config.ts
Normal file
80
tests/playwright-asyncify.config.ts
Normal 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
|
||||
Loading…
Reference in a new issue