From f86ef9e4338af38ad2654a0dc009c2e8bf7e0a0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Fri, 31 Jul 2026 20:34:32 +0200 Subject: [PATCH] =?UTF-8?q?test(kicad):=20timer-park=20repro=20lever=20?= =?UTF-8?q?=E2=80=94=20deterministic=20concurrent-Asyncify=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kicadTestArmTimerPark(delayMs, parkMs): a one-shot wxTimer whose Notify() emscripten_sleep()s, entering through the exact GAL-refresh-timer path (emscripten_async_call → TimerCallbackFunc::Run → dispatch guard → Notify) — the fresh-entry-that-parks the prod board-load trap family needs. Pollable kicadTestTimerParkState(); inert unless armed. Registered beside kicadTestSetOpenPark in pcbnew + the merged kicad_editor image. tests/kicad/timer-park-repro.spec.ts drives four escalating cycles (park only, 2× + fiber hammering, + 256MB heap growth mid-park) and asserts the runtime survives every rewind AND that the [wx-asyncify] diagnostics observed the window — engagement is asserted, so a run where the lever never created the overlap cannot pass vacuously. Result so far (docs/features/async/15-timer-park-repro.md): GREEN through both rounds — genuine double-parks, live currData cross-restores, fiber swaps, and mid-park heap growth are all handled by the shim + runtime. The prod trap needs an ingredient this window still lacks (ranked in the doc); the spec stays as the regression gate for whatever the eventual fix is. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4 --- docs/features/async/15-timer-park-repro.md | 128 ++++++++ tests/kicad/timer-park-repro.spec.ts | 325 +++++++++++++++++++++ wasm/bindings/kicad_editor_embind.cpp | 15 + wasm/bindings/pcbnew_embind.cpp | 15 + wasm/bindings/timer_park.h | 106 +++++++ 5 files changed, 589 insertions(+) create mode 100644 docs/features/async/15-timer-park-repro.md create mode 100644 tests/kicad/timer-park-repro.spec.ts create mode 100644 wasm/bindings/timer_park.h diff --git a/docs/features/async/15-timer-park-repro.md b/docs/features/async/15-timer-park-repro.md new file mode 100644 index 0000000..0d4199c --- /dev/null +++ b/docs/features/async/15-timer-park-repro.md @@ -0,0 +1,128 @@ +# 15 — Timer-park repro lever (concurrent-Asyncify collision) + +Status: lever built 2026-07-31 · spec `tests/kicad/timer-park-repro.spec.ts` · +investigation: the v0.1.17–19 prod board-load trap (gal-refresh-timer). + +## Why this exists + +The prod trap ("index out of bounds" + "unreachable executed" in +`doRewind`/`finishContextSwitch`) has survived three shipped fixes and 10+ +local repro attempts. The 2026-07-31 v0.1.19 crash log re-ranked the +hypotheses decisively: + +- The wx diagnostics shipped in v0.1.19 (`[wx-dispatch]` depth-erasure, + `[wx-timer]` retry storms) were **live and silent** on a real crashing run — + the modal depth-zeroing and long-parked-dispatch theories are disfavored. +- The load was **fast** (0.5 s open, warm caches) and still trapped, 214 ms + after `open:settled`, before any collab embind entry — inside the GAL + pre-first-paint 100 ms rearm cascade. + +What remains is the **concurrent-park family** (emscripten #9153): the main +loop spends most wall-clock time Asyncify-parked in `wxWasmYieldToBrowser`; a +wx timer callback is a fresh JS→wasm entry; if the timer handler itself parks, +two live Asyncify contexts share the single-slot `Asyncify.currData`. The +`handlesleep.js` shim silently repairs the pointer aliasing (as of this change +it REPORTS each repair as `[wx-asyncify] …`), but fiber swaps +(`emscripten_fiber_swap`, used by every collab entry via TOOL_MANAGER +coroutines) bypass its accounting entirely — and `finishContextSwitch` is +exactly where the prod trap's second stack dies. + +## The lever + +`wasm/bindings/timer_park.h` + exports in `pcbnew_embind.cpp` and +`kicad_editor_embind.cpp` (mirrors the `kicadTestSetOpenPark` conventions): + +- `kicadTestArmTimerPark(delayMs, parkMs) → bool` — one-shot `wxTimer` whose + `Notify()` runs `emscripten_sleep(parkMs)`. The entry path is byte-for-byte + the GAL refresh timer's: `emscripten_async_call → TimerCallbackFunc::Run → + wxWasmDispatchGuard → Notify()` — then it parks, which is what the GAL + handler is suspected of doing (paint → GAL init / lib bridge) on crashing + loads. +- `kicadTestTimerParkState() → {"fired","done","parked","parkMs"}` — JS-pollable + progress. `fired` without `done` = the park is in flight. + +Production is inert: nothing fires unless armed. + +Side effect worth knowing: while the parked `Notify()` holds its dispatch +guard, every other due timer spins the 17 ms retry loop — a park ≥ ~1 s also +exercises the `[wx-timer] retry storm` diagnostic. + +## The spec + +`tests/kicad/timer-park-repro.spec.ts` (pcbnew-collab harness, merged +`kicad_editor.js`): open a 2k-item board, settle, then three cycles — +park-only, park + fiber hammering (`kicadCollabSnapshotItems` / +`kicadCollabGetPos` every 10 ms through the window), and a second hammered +draw. Asserts: + +- each cycle's `Notify()` fires, is observed parked, and **survives its rewind**; +- no embind entry traps; no trap signature anywhere in the console; +- the runtime stays functional afterwards (snapshot walks the board, a real + apply lands); +- the `[wx-asyncify]` shim diagnostics observed the concurrent-park window — + silence there means the lever never created the overlap (vacuous run), not + a pass. + +**Interpretation:** RED with the prod signature ⇒ hypothesis confirmed, and +the failing interleaving is named by the shim lines. GREEN ⇒ plain +double-park + fiber-during-park is handled; the prod mechanism needs another +ingredient (ranked next: fiber swap racing a park's WAKE, GAL-init-specific +state, memory growth mid-park — see the trace `GREW +187MB` at `stage:done`). + +## Round 1 result (2026-07-31, first run of the lever) + +**GREEN — and the window demonstrably engaged.** Three cycles (park-only, +2× park + fiber hammer) on the fresh build: + +- `[wx-asyncify] aliased-wake-live` fired **6×**: two different chains' + asyncify buffers restored over each other (`83722240 ⇄ 104366080`) — the + literal #9153 cross-chain aliasing, live and deterministic. The shim's + repair held every time; the runtime stayed fully functional. +- `[wx-timer] retry storm: 60 retries (~1s parked, depth=1)` + storm-end — + the v0.1.19 C++ diagnostic channel validated end-to-end. (Prod's crash log + had NO storm line ⇒ prod's fatal window is < ~1 s.) +- Shim-check calibration learned the hard way: `handleSleep` re-entry with + `state=2` (Rewinding) + currData set is NORMAL resume mechanics (~100/s + during the cycles) — the `concurrent-park`/`reentrant-state` checks were + narrowed to `state===0` / `state===1` accordingly. + +**Implication:** plain concurrent park + fiber swaps + live pointer aliasing +is INSUFFICIENT to trap on this build. Round 2 adds the next prod ingredient: +heap growth mid-park (`___libc_malloc(256MB)` through the window — the prod +trace grew +187 MB during the load), cycle 4 of the spec. + +## Round 2 result (2026-07-31, refined shim + growth cycle) + +**Still GREEN — four cycles, growth included.** With the calibrated checks the +picture is precise: 4× genuine `concurrent-park` (state 0 — the timer's +`_emscripten_sleep` starting while the yield park's currData was live, wasm +frames in the report stack), 8× `aliased-wake-live` (the two chains' +buffers cross-restored, both directions), 5× `overlapped-wake`, **0× +`reentrant-state`** (mid-unwind entry does not occur), 256 MB heap growth +mid-park absorbed cleanly. + +So on Firefox/local, the full stack of suspected ingredients — fresh +double-park, fiber swaps through the window, live currData aliasing, heap +growth across parked buffers — is handled by the shim + runtime. The prod +trap requires something this harness still lacks. Ranked next: + +1. **A second parking timer staggered into the FIRST one's wake tick** — the + prod first-trap stack is 3-deep in the async_call rearm cascade; a park + colliding with a *rewind in progress* (not a parked-idle chain) is the one + interleaving the lever does not yet force. +2. GAL pre-first-paint state (prod trapped before first paint; this harness + is long-painted by cycle time). +3. Prod-only environment: real lib realtime resolves + presence WSS fibers + + the user's machine timing. + +The lever + spec stay as the regression gate for the fix regardless: they +deterministically create and verify the concurrent-park window that all three +shipped fixes were blind to. + +## Candidate real fix (only after a red) + +Deliver timer notifies from the main-loop chain: the JS timer callback only +marks the timer due and wakes the yield; the loop dispatches due timers after +its rewind, when it is the sole live context. Structurally removes +fresh-entry parks from timers. Keep the 17 ms retry interlock for the +dispatch-chain case. diff --git a/tests/kicad/timer-park-repro.spec.ts b/tests/kicad/timer-park-repro.spec.ts new file mode 100644 index 0000000..b0a7f63 --- /dev/null +++ b/tests/kicad/timer-park-repro.spec.ts @@ -0,0 +1,325 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "./fixtures"; + +/** + * Timer-park concurrent-Asyncify repro (gal-refresh-timer investigation). + * + * The prod trap ("index out of bounds" + "unreachable executed" in doRewind, + * v0.1.17–19, still un-reproduced naturally): a wx timer callback is a FRESH + * JS→wasm entry (emscripten_async_call → TimerCallbackFunc::Run → Notify()), + * and the main loop spends most wall-clock time Asyncify-parked inside + * wxWasmYieldToBrowser. A timer handler that itself parks therefore creates + * TWO live Asyncify contexts over the single-slot `Asyncify.currData` — the + * emscripten #9153 family that scripts/common/shims/handlesleep.js silently + * repairs. The collab entries add the third ingredient: they run on + * TOOL_MANAGER coroutines (emscripten_fiber_swap), which bypass the shim's + * allocateData accounting entirely — and `finishContextSwitch` is exactly + * where the prod trap's second stack dies. + * + * The natural trigger needs a timer handler that parks mid-paint + * (scheduler-dependent; never hit locally). `kicadTestArmTimerPark` makes the + * window deterministic: a one-shot wx timer whose Notify() emscripten_sleep()s + * for a fixed time. Three escalating cycles: + * + * 1. timer park alone (timer chain × main-loop yield park) + * 2. + collab entry hammering (adds fiber swaps through the window) + * 3. same again (interleaving lottery, second draw) + * + * The spec asserts the runtime SURVIVES every cycle — on a build where the + * hypothesis holds this is deterministically RED, and after the real fix it + * is the regression gate. The `[wx-asyncify]` shim diagnostics must report + * the concurrent-park window engaging; that assert fails only if the lever + * itself never created the overlap (a broken repro, not a passing one). + */ + +const SEG_TARGET = "fa220000-0000-0000-0000-00000000cafe"; +const PROBE_HOME = "10000000,10000000"; // on-disk position (IU) + +/** Compact deterministic board — enough items for a real snapshot walk. */ +function board(): string { + const lines: string[] = []; + lines.push("(kicad_pcb"); + lines.push("\t(version 20241229)"); + lines.push('\t(generator "pcbnew")'); + lines.push('\t(generator_version "9.0")'); + lines.push("\t(general (thickness 1.6))"); + lines.push('\t(paper "A4")'); + lines.push("\t(layers"); + lines.push('\t\t(0 "F.Cu" signal)'); + lines.push('\t\t(2 "B.Cu" signal)'); + lines.push('\t\t(25 "Edge.Cuts" user)'); + lines.push("\t)"); + lines.push("\t(setup)"); + lines.push('\t(net 0 "")'); + const uuid = (n: number) => + `fa2${(n + 1).toString(16).padStart(5, "0")}-0000-0000-0000-000000000000`; + let n = 0; + for (let i = 0; i < 2000; i++) { + const x = 20 + (i % 100) * 1.5; + const y = 20 + Math.floor(i / 100) * 1; + lines.push( + `\t(segment (start ${x} ${y}) (end ${x + 1.2} ${y}) (width 0.2) (layer "F.Cu") (net 0) (uuid "${uuid(n++)}"))`, + ); + } + lines.push( + `\t(segment (start 10 10) (end 15 10) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG_TARGET}"))`, + ); + lines.push(")"); + return lines.join("\n"); +} + +type Mod = { + kicadOpenFile(p: string): unknown; + kicadOpenFileBusy(): boolean; + kicadTestArmTimerPark(delayMs: number, parkMs: number): boolean; + kicadTestTimerParkState(): string; + kicadCollabSnapshotItems(): string; + kicadCollabApply(j: string): unknown; + kicadCollabGetPos(id: string): string; +}; + +interface CycleStats { + armed: boolean; + fired: boolean; + done: boolean; + sawParked: boolean; + hammerIters: number; + /** Bytes the wasm heap grew mid-park (growHeap cycles; 0 = no growth). */ + grewBytes: number; + errors: string[]; + elapsedMs: number; +} + +const TRAP_SIGNATURE = + /Aborted\(|index out of bounds|unreachable executed|indirect call signature|null function or function signature|memory access out of bounds/; + +async function bootHarness(page: Page): Promise { + await page.goto("/kicad/pcbnew-collab.html"); + await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 }); + await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 }); + await page.waitForFunction( + () => { + const m = (window as unknown as { Module?: Partial }).Module; + return ( + typeof m?.kicadOpenFile === "function" && + typeof m?.kicadCollabSnapshotItems === "function" && + typeof m?.kicadTestArmTimerPark === "function" + ); + }, + null, + { timeout: 90000 }, + ); + await page.waitForFunction( + () => + !!window.wxElementRegistry && + window.wxElementRegistry + .findAll({ visible: true }) + .some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")), + null, + { timeout: 90000 }, + ); +} + +/** Open the board and poll until the open chain truly settles. */ +async function openAndSettle(page: Page, content: string): Promise { + await page.evaluate((c) => { + const w = window as unknown as { + FS: { mkdirTree(p: string): void; writeFile(p: string, d: string): void }; + Module: Mod; + }; + const dir = "/home/kicad/documents"; + try { + w.FS.mkdirTree(dir); + } catch { + /* exists */ + } + w.FS.writeFile(`${dir}/timerpark.kicad_pcb`, c); + w.Module.kicadOpenFile(`${dir}/timerpark.kicad_pcb`); + }, content); + await expect + .poll( + () => page.evaluate(() => (window.Module as unknown as Mod).kicadOpenFileBusy()), + { timeout: 120000, intervals: [250] }, + ) + .toBe(false); +} + +/** + * One repro cycle in-page: arm the parking timer, then poll its state until + * the park completes — optionally hammering the fiber-based collab entries + * through the window (the prod settle fan-out shape). Every embind entry here + * runs while the timer chain is Asyncify-parked and the main loop's yield + * park keeps cycling: the exact concurrent-context interleaving under test. + */ +async function armAndRide( + page: Page, + opts: { parkMs: number; hammer: boolean; growHeap?: boolean }, +): Promise { + return page.evaluate(async ({ parkMs, hammer, growHeap }) => { + const m = (window as unknown as { Module: Mod }).Module; + const before = JSON.parse(m.kicadTestTimerParkState()) as { fired: number; done: number }; + const stats = { + armed: false, + fired: false, + done: false, + sawParked: false, + hammerIters: 0, + grewBytes: 0, + errors: [] as string[], + elapsedMs: 0, + }; + if (!m.kicadTestArmTimerPark(30, parkMs)) return stats; + stats.armed = true; + + const t0 = performance.now(); + // Bound = park length + generous rewind budget; exits on completion. + while (performance.now() - t0 < parkMs + 20000) { + try { + const st = JSON.parse(m.kicadTestTimerParkState()) as { + fired: number; + done: number; + parked: boolean; + }; + if (st.fired > before.fired) stats.fired = true; + if (st.parked) stats.sawParked = true; + if (st.done > before.done) { + stats.done = true; + break; + } + } catch (e) { + stats.errors.push(`state poll: ${String(e)}`); + break; + } + // Heap growth mid-park (prod trace: `stage:done … GREW +187MB`): growth + // detaches every JS heap view; a stale view held across it is one of + // the few mechanisms that yields a bad function-table index LATER. + // 256 MB per shot, deliberately leaked — the asyncify buffers of the + // parked chains live in linear memory on both sides of the boundary. + if (stats.fired && growHeap && !stats.grewBytes) { + const alloc = ( + m as unknown as { ___libc_malloc?: (n: number) => number } + ).___libc_malloc; + if (typeof alloc === "function") { + const before = (window as unknown as { Module: { HEAPU8: Uint8Array } }).Module + .HEAPU8.byteLength; + alloc(256 * 1024 * 1024); + const after = (window as unknown as { Module: { HEAPU8: Uint8Array } }).Module + .HEAPU8.byteLength; + stats.grewBytes = after - before; + } else { + stats.errors.push("growHeap: ___libc_malloc not exported"); + } + } + if (stats.fired && hammer) { + for (const [name, fn] of [ + ["snapshotItems", () => m.kicadCollabSnapshotItems()], + ["getPos", () => m.kicadCollabGetPos("fa220000-0000-0000-0000-00000000cafe")], + ] as const) { + try { + fn(); + stats.hammerIters++; + } catch (e) { + stats.errors.push(`${name} during park: ${String(e)}`); + } + } + } + await new Promise((r) => setTimeout(r, 10)); + } + stats.elapsedMs = Math.round(performance.now() - t0); + return stats; + }, opts); +} + +test.describe("timer Notify() Asyncify-park during main-loop yield (concurrent currData)", () => { + test("runtime survives a parking timer handler, alone and under fiber hammering", async ({ + page, + testLogger, + }) => { + test.setTimeout(300000); + await bootHarness(page); + await openAndSettle(page, board()); + + const cycles: Array<{ label: string; hammer: boolean; growHeap?: boolean }> = [ + { label: "park only", hammer: false }, + { label: "park + fiber hammer", hammer: true }, + { label: "park + fiber hammer (2nd draw)", hammer: true }, + // Prod trace showed `GREW +187MB` on the crashing load: growth detaches + // every JS heap view while TWO chains are parked (timer + main-loop + // yield) and fibers swap through — the stale-view stale-buffer scenario. + { label: "park + heap growth + fiber hammer", hammer: true, growHeap: true }, + ]; + for (const { label, hammer, growHeap } of cycles) { + const stats = await armAndRide(page, { parkMs: 1500, hammer, growHeap }); + console.log( + `[TEST] ${label}: fired=${stats.fired} done=${stats.done} parked=${stats.sawParked} ` + + `hammerIters=${stats.hammerIters} grew=${stats.grewBytes} ` + + `elapsed=${stats.elapsedMs}ms errors=${stats.errors.length}`, + ); + expect(stats.armed, `${label}: timer armed`).toBe(true); + expect(stats.fired, `${label}: Notify() entered`).toBe(true); + expect(stats.sawParked, `${label}: the park window engaged`).toBe(true); + expect(stats.done, `${label}: Notify() survived its park and rewound`).toBe(true); + expect(stats.errors, `${label}: no embind entry trapped`).toEqual([]); + if (growHeap) { + expect(stats.grewBytes, `${label}: the heap actually grew mid-park`).toBeGreaterThan(0); + } + } + + // The runtime is still fully functional: snapshots walk the board and a + // real apply lands (a poisoned Asyncify state fails one of these first). + const itemCount = await page.evaluate( + () => + JSON.parse((window.Module as unknown as Mod).kicadCollabSnapshotItems()).added.length, + ); + expect(itemCount, "post-cycle snapshot sees the board").toBeGreaterThan(2000); + await page.evaluate( + (id) => + (window.Module as unknown as Mod).kicadCollabApply( + JSON.stringify({ + added: [], + changed: [ + { + id, + type: "PCB_TRACK", + sx: 12_000_000, + sy: 34_000_000, + ex: 17_000_000, + ey: 34_000_000, + width: 200000, + }, + ], + removed: [], + }), + ), + SEG_TARGET, + ); + await expect + .poll( + () => + page.evaluate( + (id) => (window.Module as unknown as Mod).kicadCollabGetPos(id), + SEG_TARGET, + ), + { timeout: 10000, intervals: [200] }, + ) + .toBe("12000000,34000000"); + + // Console-level trap sweep: the prod signatures must not have appeared + // anywhere (the page survives some of them as "Uncaught" noise). + const trapLines = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => + TRAP_SIGNATURE.test(l), + ); + expect(trapLines, "no wasm trap signature anywhere in the run").toEqual([]); + + // Window-engagement proof, independent of survival: the handlesleep shim + // must have SEEN the concurrent parks (its reporting is new — silence here + // means the lever never created the overlap and the repro is vacuous). + const shimLines = testLogger.consoleLogs.filter((l) => l.includes("[wx-asyncify]")); + console.log(`[TEST] shim diagnostics: ${shimLines.length} line(s)`); + for (const l of shimLines.slice(0, 10)) console.log(`[TEST] ${l}`); + expect( + shimLines.length, + "handlesleep shim observed the concurrent-park window", + ).toBeGreaterThan(0); + }); +}); diff --git a/wasm/bindings/kicad_editor_embind.cpp b/wasm/bindings/kicad_editor_embind.cpp index 312a7f8..c5ad4ee 100644 --- a/wasm/bindings/kicad_editor_embind.cpp +++ b/wasm/bindings/kicad_editor_embind.cpp @@ -41,6 +41,7 @@ #include "pcbjam_libs_reload.h" #include "open_gate.h" +#include "timer_park.h" using namespace emscripten; @@ -175,6 +176,18 @@ static void kicadTestSetOpenPark( int aMs ) pcbjam_open::testParkMs() = aMs; } +// Test-only (timer-park repro, timer_park.h): a one-shot wx timer whose +// Notify() Asyncify-parks — the deterministic concurrent-park window. +static bool kicadTestArmTimerPark( int aDelayMs, int aParkMs ) +{ + return pcbjam_timer_park::arm( aDelayMs, aParkMs ); +} + +static std::string kicadTestTimerParkState() +{ + return pcbjam_timer_park::stateJson(); +} + // Canvas-only chrome toggle (features/mobile): hide/show every AUI pane // except the central draw canvas, plus the menubar and status bar, so the GAL @@ -529,6 +542,8 @@ EMSCRIPTEN_BINDINGS(kicad_editor) { function("kicadOpenFile", &kicadOpenFile); function("kicadOpenFileBusy", &kicadOpenFileBusy); function("kicadTestSetOpenPark", &kicadTestSetOpenPark); + function("kicadTestArmTimerPark", &kicadTestArmTimerPark); + function("kicadTestTimerParkState", &kicadTestTimerParkState); // Canvas-only mobile mode (features/mobile). function("kicadSetChrome", &kicadSetChrome); diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index 81601d9..abf30c1 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -53,6 +53,7 @@ #include "collab_common.h" #include "collab_presence_core.h" #include "open_gate.h" +#include "timer_park.h" #include "collab_presence_style.h" #include "pcbjam_theme.h" #include "pcbjam_libs_reload.h" @@ -124,6 +125,18 @@ void kicadTestSetOpenPark( int aMs ) pcbjam_open::testParkMs() = aMs; } +// Test-only (timer-park repro, timer_park.h): a one-shot wx timer whose +// Notify() Asyncify-parks — the deterministic concurrent-park window. +bool kicadTestArmTimerPark( int aDelayMs, int aParkMs ) +{ + return pcbjam_timer_park::arm( aDelayMs, aParkMs ); +} + +std::string kicadTestTimerParkState() +{ + return pcbjam_timer_park::stateJson(); +} + // Read-only viewer lock (read-only-viewer): flips the process-global // PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist) // and the selection tools (nothing selectable), and mirrors it onto the @@ -2386,6 +2399,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) { function("kicadOpenFile", &kicadOpenFile); function("kicadOpenFileBusy", &kicadOpenFileBusy); function("kicadTestSetOpenPark", &kicadTestSetOpenPark); + function("kicadTestArmTimerPark", &kicadTestArmTimerPark); + function("kicadTestTimerParkState", &kicadTestTimerParkState); function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe); // Read-only viewer lock (read-only-viewer). function("kicadSetReadOnly", &kicadSetReadOnly); diff --git a/wasm/bindings/timer_park.h b/wasm/bindings/timer_park.h new file mode 100644 index 0000000..0d91927 --- /dev/null +++ b/wasm/bindings/timer_park.h @@ -0,0 +1,106 @@ +/* + * Test-only deterministic repro lever for the production board-load trap + * family ("index out of bounds" / "unreachable executed" in doRewind — + * gal-refresh-timer investigation, docs/features/async/14-open-settle-gate.md + * lineage). + * + * The surviving hypothesis after the v0.1.19 crash log (2026-07-31): a wx + * timer callback is a FRESH JS→wasm entry (emscripten_async_call → + * TimerCallbackFunc::Run → Notify). The main loop is Asyncify-parked in + * wxWasmYieldToBrowser for most of wall-clock time, so a timer handler that + * itself parks creates TWO live Asyncify contexts over the single-slot + * Asyncify.currData — the emscripten #9153 family the handlesleep.js shim + * silently repairs. Add a fiber swap (the collab entries run on + * TOOL_MANAGER coroutines — emscripten_fiber_swap, which bypasses the shim's + * allocateData accounting entirely) and the prod trap's exact second stack + * (doRewind → finishContextSwitch under __asyncjs__wxWasmYieldToBrowser) + * becomes constructible on demand. + * + * Natural occurrences need a timer handler that parks mid-paint (GAL init / + * lib bridge) — scheduler-dependent, never reproduced locally in 10+ + * attempts. This lever makes the window deterministic: arm a one-shot wx + * timer whose Notify() emscripten_sleep()s for a fixed time; the e2e then + * hammers fiber-based collab entries through the window and asserts the + * runtime SURVIVES (red on a build where the hypothesis holds). + * + * Production is unaffected: nothing fires unless kicadTestArmTimerPark is + * called. + */ +#pragma once + +#include +#include + +#include +#include +#include + +namespace pcbjam_timer_park +{ + +struct State +{ + int parkMs = 0; + int fired = 0; // Notify() entries + int done = 0; // Notify() completions (park survived + rewound) + bool parked = false; +}; + +inline State& state() +{ + static State s_state; + return s_state; +} + +/** + * The timer under test. Notify() runs as a fresh JS→wasm entry under + * TimerCallbackFunc::Run's wxWasmDispatchGuard (src/wasm/timer.cpp), exactly + * like the GAL refresh timer — then parks, which is what the GAL handler is + * suspected of doing (paint → lib bridge / GAL init) on the crashing loads. + * While it is parked, wxWasmDispatchParked() reads true, so every OTHER due + * timer spins the 17 ms retry loop — the [wx-timer] storm diagnostic should + * report the window, validating that channel too. + */ +class ParkingTimer : public wxTimer +{ +public: + void Notify() override + { + ++state().fired; + state().parked = true; + + if( state().parkMs > 0 ) + emscripten_sleep( state().parkMs ); + + state().parked = false; + ++state().done; + } +}; + +/** Arm one shot: fire in aDelayMs, park Notify() for aParkMs. */ +inline bool arm( int aDelayMs, int aParkMs ) +{ + // Lazy: a wxTimer needs the app/traits up, and by the time a test can + // call embind the app long is. + static ParkingTimer* s_timer = nullptr; + + if( !wxTheApp ) + return false; + + if( !s_timer ) + s_timer = new ParkingTimer(); + + state().parkMs = aParkMs; + return s_timer->StartOnce( aDelayMs ); +} + +/** JS-pollable progress of the armed shot. */ +inline std::string stateJson() +{ + char buf[96]; + snprintf( buf, sizeof( buf ), "{\"fired\":%d,\"done\":%d,\"parked\":%s,\"parkMs\":%d}", + state().fired, state().done, state().parked ? "true" : "false", state().parkMs ); + return buf; +} + +} // namespace pcbjam_timer_park