test(kicad): timer-park repro lever — deterministic concurrent-Asyncify window
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
This commit is contained in:
parent
7cfb07a99e
commit
f86ef9e433
5 changed files with 589 additions and 0 deletions
128
docs/features/async/15-timer-park-repro.md
Normal file
128
docs/features/async/15-timer-park-repro.md
Normal file
|
|
@ -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.
|
||||||
325
tests/kicad/timer-park-repro.spec.ts
Normal file
325
tests/kicad/timer-park-repro.spec.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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<Mod> }).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<void> {
|
||||||
|
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<CycleStats> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
|
|
||||||
#include "pcbjam_libs_reload.h"
|
#include "pcbjam_libs_reload.h"
|
||||||
#include "open_gate.h"
|
#include "open_gate.h"
|
||||||
|
#include "timer_park.h"
|
||||||
|
|
||||||
using namespace emscripten;
|
using namespace emscripten;
|
||||||
|
|
||||||
|
|
@ -175,6 +176,18 @@ static void kicadTestSetOpenPark( int aMs )
|
||||||
pcbjam_open::testParkMs() = 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
|
// 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
|
// 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("kicadOpenFile", &kicadOpenFile);
|
||||||
function("kicadOpenFileBusy", &kicadOpenFileBusy);
|
function("kicadOpenFileBusy", &kicadOpenFileBusy);
|
||||||
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
|
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
|
||||||
|
function("kicadTestArmTimerPark", &kicadTestArmTimerPark);
|
||||||
|
function("kicadTestTimerParkState", &kicadTestTimerParkState);
|
||||||
|
|
||||||
// Canvas-only mobile mode (features/mobile).
|
// Canvas-only mobile mode (features/mobile).
|
||||||
function("kicadSetChrome", &kicadSetChrome);
|
function("kicadSetChrome", &kicadSetChrome);
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
#include "collab_common.h"
|
#include "collab_common.h"
|
||||||
#include "collab_presence_core.h"
|
#include "collab_presence_core.h"
|
||||||
#include "open_gate.h"
|
#include "open_gate.h"
|
||||||
|
#include "timer_park.h"
|
||||||
#include "collab_presence_style.h"
|
#include "collab_presence_style.h"
|
||||||
#include "pcbjam_theme.h"
|
#include "pcbjam_theme.h"
|
||||||
#include "pcbjam_libs_reload.h"
|
#include "pcbjam_libs_reload.h"
|
||||||
|
|
@ -124,6 +125,18 @@ void kicadTestSetOpenPark( int aMs )
|
||||||
pcbjam_open::testParkMs() = 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
|
// Read-only viewer lock (read-only-viewer): flips the process-global
|
||||||
// PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist)
|
// PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist)
|
||||||
// and the selection tools (nothing selectable), and mirrors it onto the
|
// and the selection tools (nothing selectable), and mirrors it onto the
|
||||||
|
|
@ -2386,6 +2399,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
||||||
function("kicadOpenFile", &kicadOpenFile);
|
function("kicadOpenFile", &kicadOpenFile);
|
||||||
function("kicadOpenFileBusy", &kicadOpenFileBusy);
|
function("kicadOpenFileBusy", &kicadOpenFileBusy);
|
||||||
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
|
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
|
||||||
|
function("kicadTestArmTimerPark", &kicadTestArmTimerPark);
|
||||||
|
function("kicadTestTimerParkState", &kicadTestTimerParkState);
|
||||||
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
|
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
|
||||||
// Read-only viewer lock (read-only-viewer).
|
// Read-only viewer lock (read-only-viewer).
|
||||||
function("kicadSetReadOnly", &kicadSetReadOnly);
|
function("kicadSetReadOnly", &kicadSetReadOnly);
|
||||||
|
|
|
||||||
106
wasm/bindings/timer_park.h
Normal file
106
wasm/bindings/timer_park.h
Normal file
|
|
@ -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 <cstdio>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <emscripten.h>
|
||||||
|
#include <wx/app.h>
|
||||||
|
#include <wx/timer.h>
|
||||||
|
|
||||||
|
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
|
||||||
Loading…
Reference in a new issue