jspi: fix the dead-tools ownership bug, emscripten-6 fallout, and green the full suite on Playwright 1.62
Live-app fix (Place Footprints / routing dead in Chrome): submodule
bumps carry the coroutine ownership fix (kicad 012d95ecb4) and the
handler-exception survival fix (wxwidgets 1b5f0e31f4).
Emscripten-6 fallout:
- occ/ngspice worker wrappers: mainScriptUrlOrBlob was removed
upstream; pthread children re-run the wrapper blob, so an em-pthread
realm now importScripts the glue and gets out of the way (before:
recursive service boots, pool never fills, silent 180s boot hangs —
every occ spec and ngspice bg_run).
- Makefile.wasm: -sASYNCIFY frankenlinks on the no-wx coroutine repro
targets ported to -sJSPI (the JSPI-only libcontext crashed at first
yield under them); mainloop/gl repro pages drive their tick through a
promising export (emscripten_set_main_loop callbacks cannot suspend);
retired inject-dyncall-shims lines removed (targets were unbuildable
since Phase 8); $stringToNewUTF8 force-included (the EM_ASM value
bridge aborted the runtime on the first decoded exception).
- fiber-park levers: neither embind shape can drive suspending levers
(plain throws on strict-JSPI Firefox; emscripten::async() re-executes
its invoker on settle) — kept sync for manual Chromium probing, spec
coverage moved to the jspi-coroutine harness (18 cases).
Suite work:
- Playwright 1.61.1 -> 1.62.1 (Firefox 153: JSPI on by default).
- fiber-resume-park.spec retired -> coroutine-lifecycle.spec: census
gate over boot / board load / chooser open / cancel (deterministically
red on the pre-fix build).
- Blind asyncify-era pins re-keyed: quasimodal-strand + wait-beacons
beacon regexes, footprint-chooser-close liveness -> wx parking-timer
heartbeat (scheduler counters idle flat on Firefox).
- occ/ngspice test providers: 60s boot timeout + worker error
surfacing (a worker death used to be a silent 180s timeout).
- Harness pages: stale 9.99 config dir -> 10.0 (library_manager wxCHECK
noise, chooser had no libraries).
- gal-webgl harness: missing artifacts rebuilt (boost/glm extracted to
the host sysroot), PgmOrNull stub added for the rebased GAL.
- jspi-scheduler: clean-shutdown console line restored (app-quit
contract), quarantine never yanks SP from a live window.
Gates: test:e2e 699 passed / 0 failed (wx-chromium, kicad-firefox,
kicad-chromium, jspi-firefox, coroutine-firefox); web ff/cr/mobile 71
passed; lint:ci-coverage 166, lint:determinism 163, screenshots
manifest 492 current, corpus 7/7, tools:contract green. Offline
screenshot baselines show expected mass drift from the engine bump —
re-baseline (screenshots:noise -> promote) is a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-13 17:41:28 +02:00
import type { Page } from "@playwright/test" ;
import { test , expect } from "./fixtures" ;
import { loadBoard } from "./utils/threed-viewer" ;
import { waitForPcbnew } from "./utils/pcbnew-ready" ;
import { clickMenuBarItem , clickMenuItemByText } from "../e2e/utils/element-tracker" ;
/ * *
* JSPI coroutine lifecycle in the REAL editor — successor to
jspi cleanup: remove the asyncify-era residue — dead code, conditionals, pipeline scaffolding, stale prose
The runtime is JSPI-only; this removes everything that still pretended
otherwise. Three exhaustive sweeps (C++/JS+build+CI/tests+docs) drove
the inventory; every deletion verified by grep closure + full gates.
Broken-right-now fixes:
- deploy-staging.yml passed the retired opt_level input — the workflow
could not even start. Removed.
- env.sh carried dead exports with a live -sASYNCIFY=1 inside
(WASM_LDFLAGS/PTHREAD_LDFLAGS, zero consumers). Removed; the
WASM_LEGACY_EXCEPTIONS rationale rewritten to the real reason.
- docker/build.sh exported PCBJAM_ASYNC_BACKEND (read nowhere). Gone.
Dead weight removed:
- binaryen submodule (nothing builds or invokes it), wasm-opt-bench
workflow + scripts/bench/, get-wasm-opt.sh, diagnostics.js (242 lines
of Asyncify-API-only code), the KICAD_PIPELINE background-postprocess
scaffolding (existed to parallelize the deleted wasm-opt phase; the
postprocess is a seconds-long node script and now runs inline),
build-monitor's dead asyncify rows, sched-context orphan build
output, dead .gitignore entries, the .jspi-assets spike dir (the two
wf-result research JSONs moved to docs/features/async/migration-evidence/).
- bindings: fiber_park.h + its 12 embind registrations (broken-if-
called under JSPI), the kicadOpenFileStart/OPEN_JOB starter route,
main_stack_runner.h + 5 includes, the always-null context-sleep weak
hook in nanosleep_yield.c.
- shim: the backend field (installed-flag idempotency instead),
noteContextWait (dead both sides), the __wxAsyncifyDump alias (+ the
WasmTool fallback and string-dump normalize branch).
- web: the emscripten-6-ignored mainScriptUrlOrBlob option in boot.ts
(gerber-demo keeps it: it loads the deployed CDN release, which
predates emscripten 6 — noted inline).
Conditionals: all 'backend === jspi' checks reduced to scheduler-
presence checks; races_quiescent re-keyed from Asyncify.state (vacuous)
to real backlog quiescence (resumeReady/mutatorQueue — NOT _windowLive,
which is the probing activation's own window by definition).
Renames (identifiers only, no file renames): ASYNC_LINK_FLAGS→
JSPI_LINK_FLAGS and Makefile ASYNC_LDFLAGS→JSPI_LDFLAGS,
kicadCollabFiberBusy→kicadCollabBusy (embind + web + tests),
collab_common.h fiber*→apply*/coroutine naming, asyncifySignatures→
wasmTrapSignatures (lists byte-identical).
Tests: the two remaining vacuous [wx-asyncify]/fiber-resume-refused
asserts re-keyed to live JSPI beacons; eeschema-load's failure message
no longer sends the developer to a deleted script; wait-beacons' dead
families/parser deleted; lane-0 legacy-glue guards removed (lane 0 is
unconstructible); the embind test.fail re-gated with the JSPI reason
(plain embind invokers cannot suspend — verified still failing);
lint-determinism now scans tests/jspi (166 files clean);
eeschema-collab local-move gated to chromium (~50% flaky on FF even
solo; pcbnew twin covers both engines).
Docs: DEBUG.md rewritten as the JSPI debugging guide; build.md
describes the single-phase build; docs/features/async/README.md
banner-marked historical and repointed at the NEW
23-jspi-runtime.md (current architecture: export census, turnstile,
libcontext ownership + refusal contract, embind call shapes, the
em-pthread service-wrapper trick, exception policy, known gaps).
Gates on the cleaned tree: test:e2e 725 passed / 0 failed (after the
quiescence-probe fix; the 3 other reds were verified contention flakes
solo-green or the documented FF gate), web 76/0, jspi 18/18 both
engines, vitest 295/295 + 17/17, all lints green, live-app census
clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-14 09:25:32 +02:00
* fiber - resume - park . spec . ts ( which pinned the retired asyncify rewind guard ;
* that guard ' s refused - resume beacon string no longer exists , which made the
* old spec ' s engagement assert vacuous ) .
jspi: fix the dead-tools ownership bug, emscripten-6 fallout, and green the full suite on Playwright 1.62
Live-app fix (Place Footprints / routing dead in Chrome): submodule
bumps carry the coroutine ownership fix (kicad 012d95ecb4) and the
handler-exception survival fix (wxwidgets 1b5f0e31f4).
Emscripten-6 fallout:
- occ/ngspice worker wrappers: mainScriptUrlOrBlob was removed
upstream; pthread children re-run the wrapper blob, so an em-pthread
realm now importScripts the glue and gets out of the way (before:
recursive service boots, pool never fills, silent 180s boot hangs —
every occ spec and ngspice bg_run).
- Makefile.wasm: -sASYNCIFY frankenlinks on the no-wx coroutine repro
targets ported to -sJSPI (the JSPI-only libcontext crashed at first
yield under them); mainloop/gl repro pages drive their tick through a
promising export (emscripten_set_main_loop callbacks cannot suspend);
retired inject-dyncall-shims lines removed (targets were unbuildable
since Phase 8); $stringToNewUTF8 force-included (the EM_ASM value
bridge aborted the runtime on the first decoded exception).
- fiber-park levers: neither embind shape can drive suspending levers
(plain throws on strict-JSPI Firefox; emscripten::async() re-executes
its invoker on settle) — kept sync for manual Chromium probing, spec
coverage moved to the jspi-coroutine harness (18 cases).
Suite work:
- Playwright 1.61.1 -> 1.62.1 (Firefox 153: JSPI on by default).
- fiber-resume-park.spec retired -> coroutine-lifecycle.spec: census
gate over boot / board load / chooser open / cancel (deterministically
red on the pre-fix build).
- Blind asyncify-era pins re-keyed: quasimodal-strand + wait-beacons
beacon regexes, footprint-chooser-close liveness -> wx parking-timer
heartbeat (scheduler counters idle flat on Firefox).
- occ/ngspice test providers: 60s boot timeout + worker error
surfacing (a worker death used to be a silent 180s timeout).
- Harness pages: stale 9.99 config dir -> 10.0 (library_manager wxCHECK
noise, chooser had no libraries).
- gal-webgl harness: missing artifacts rebuilt (boost/glm extracted to
the host sysroot), PgmOrNull stub added for the rebased GAL.
- jspi-scheduler: clean-shutdown console line restored (app-quit
contract), quarantine never yanks SP from a live window.
Gates: test:e2e 699 passed / 0 failed (wx-chromium, kicad-firefox,
kicad-chromium, jspi-firefox, coroutine-firefox); web ff/cr/mobile 71
passed; lint:ci-coverage 166, lint:determinism 163, screenshots
manifest 492 current, corpus 7/7, tools:contract green. Offline
screenshot baselines show expected mass drift from the engine bump —
re-baseline (screenshots:noise -> promote) is a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-13 17:41:28 +02:00
*
* The prod - shaped gate for the August 2026 ownership bug : coroutine.h ' s
* ~ CALL_CONTEXT released a BORROWED context record ( the live enterer of a
* nested dispatch ) ; the JSPI backend read that as destroy - while - parked ,
* killed the record , and the poisoned caller slot made PCB_SELECTION_TOOL ' s
* first Wait ( ) dereference the raw - 1 refusal : " coroutine 1 entry REJECTED :
* memory access out of bounds " at boot, then a " [ wx - scheduler ] job tick
* error " per tool activation and no dialog ever opening . This walks
* boot → board load → Place Footprints → chooser opens → Cancel → chooser
* closes , asserting a zero ghost / deadParked census and no rejection / trap
* lines at every stage . Deterministically RED on the pre - fix build .
*
* ( The old spec ' s mid - park - resume staging lives on as the jspi - coroutine
* standalone harness , 18 protocol cases in both engines — the doc - 15
* refusal , destroy - while - parked containment , and the phantom - release
* refusals are pinned there , against the same libcontext . cpp . )
* /
const TRAP_SIGNATURE =
/Aborted\(|index out of bounds|unreachable executed|indirect call signature|null function or function signature|memory access out of bounds|entry REJECTED|job tick error/ ;
interface CoroCensus {
ghosts : number ;
deadParked : number ;
refusedResumes : number ;
quarantines : number ;
parkedCoroutines : string [ ] ;
}
function census ( page : Page ) : Promise < CoroCensus > {
return page . evaluate ( ( ) = > {
const L = ( globalThis as any ) . __libctxJspi ;
const S = ( globalThis as any ) . __wxScheduler ;
const ring : [ number , string , string , number ] [ ] = S ? . _ring ? ? [ ] ;
return {
ghosts : L?.ghosts ? ? - 1 ,
deadParked : L?.deadParked ? ? - 1 ,
refusedResumes : ring.filter ( ( r ) = > r [ 1 ] === "libctxRefusedResume" ) . length ,
quarantines : ring.filter ( ( r ) = > r [ 1 ] === "libctxQuarantine" ) . length ,
parkedCoroutines : [ . . . ( S ? . _suspended ? . keys ? . ( ) ? ? [ ] ) ] . filter ( ( k : string ) = >
String ( k ) . startsWith ( "lc" ) ,
) ,
} ;
} ) ;
}
// The footprint chooser is a wxFrame, not a wxDialog — count top-level frames
// (same detection as footprint-chooser-close.spec.ts).
function frameCount ( page : Page ) : Promise < number > {
return page . evaluate (
( ) = >
( window . wxElementRegistry ? . findAll ( { visible : true } ) ? ? [ ] ) . filter ( ( e ) = >
/Frame$/ . test ( e . typeName || "" ) ,
) . length ,
) ;
}
// Synthetic emscripten mouse events on the canvas: a Playwright click is
// intercepted by the wx scrollbar overlay (same helper as
// footprint-chooser-close.spec.ts).
async function synthClick ( page : Page , x : number , y : number ) : Promise < void > {
await page . evaluate (
( [ cx , cy ] ) = > {
const c = document . querySelector ( "#canvas" ) as HTMLCanvasElement ;
const opt = ( b : number ) = > ( {
clientX : cx ,
clientY : cy ,
bubbles : true ,
cancelable : true ,
view : window ,
button : 0 ,
buttons : b ,
} ) ;
c . dispatchEvent ( new MouseEvent ( "mousemove" , opt ( 0 ) ) ) ;
c . dispatchEvent ( new MouseEvent ( "mousedown" , opt ( 1 ) ) ) ;
c . dispatchEvent ( new MouseEvent ( "mouseup" , opt ( 0 ) ) ) ;
c . dispatchEvent ( new MouseEvent ( "click" , opt ( 0 ) ) ) ;
} ,
[ x , y ] ,
) ;
}
test . describe ( "JSPI coroutine lifecycle (prod-shaped)" , ( ) = > {
test ( "boot, board load and tool activation stay coroutine-clean; chooser opens and cancels" , async ( {
page ,
testLogger ,
} ) = > {
test . setTimeout ( 240000 ) ;
await page . goto ( "/kicad/pcbnew.html" ) ;
await waitForPcbnew ( page ) ;
const atBoot = await census ( page ) ;
console . log ( ` [TEST] boot census: ${ JSON . stringify ( atBoot ) } ` ) ;
expect ( atBoot . ghosts , "no ghost/refused transitions at boot" ) . toBe ( 0 ) ;
expect ( atBoot . deadParked , "no coroutine died parked at boot" ) . toBe ( 0 ) ;
expect (
atBoot . parkedCoroutines . length ,
"the always-on selection tool is parked in Wait()" ,
) . toBeGreaterThan ( 0 ) ;
// The board load is the historical trigger surface (SetBoard →
// ResetTools(MODEL_RELOAD) × 2 → wake/dispatch storm over the parked
// selection tool — where the phantom release fired).
await loadBoard ( page , testLogger ) ;
const afterLoad = await census ( page ) ;
console . log ( ` [TEST] after-load census: ${ JSON . stringify ( afterLoad ) } ` ) ;
expect ( afterLoad . ghosts , "no ghost transitions across the board load" ) . toBe ( 0 ) ;
expect ( afterLoad . deadParked , "no coroutine died across the board load" ) . toBe ( 0 ) ;
// Place Footprints via the menu, then a canvas click opens the chooser
// (the pre-fix build swallowed the activation: no dialog, job tick OOB).
const framesBefore = await frameCount ( page ) ;
expect ( await clickMenuBarItem ( page , "Place" ) , "Place menu findable" ) . toBe ( true ) ;
await clickMenuItemByText ( page , "Place Footprints" ) ;
const canvas = ( await page . locator ( "#canvas" ) . boundingBox ( ) ) ! ;
await synthClick ( page , Math . round ( canvas . width * 0.35 ) , Math . round ( canvas . height * 0.45 ) ) ;
await expect
. poll ( ( ) = > frameCount ( page ) , { timeout : 40000 , intervals : [ 200 ] } )
. toBeGreaterThan ( framesBefore ) ;
const chooserOpen = await census ( page ) ;
console . log ( ` [TEST] chooser-open census: ${ JSON . stringify ( chooserOpen ) } ` ) ;
expect ( chooserOpen . ghosts , "no ghost transitions opening the chooser" ) . toBe ( 0 ) ;
expect ( chooserOpen . deadParked , "no coroutine died opening the chooser" ) . toBe ( 0 ) ;
// Cancel the quasimodal. NOTE: in the harness the chooser's library
// enumeration stays parked on its `fp-lib` bridge wait forever (the page
// installs no `window.kicadLibs` provider), so the CLOSE itself is not
// assertable here — the same reason footprint-chooser-close.spec.ts
// soft-waits it. The live app (with a provider) closes on Cancel — probed
// as part of the 2026-08-13 fix verification. What IS assertable, and
// what the pre-fix build fails: the census stays clean and the app stays
// responsive across the whole exercise.
const cancel = await page . evaluate ( ( ) = > {
const btn = ( window . wxElementRegistry ? . findAll ( { visible : true } ) ? ? [ ] ) . find (
( e ) = > /Button/i . test ( e . typeName || "" ) && /cancel/i . test ( e . label || "" ) ,
) ;
return btn ? { x : btn.centerX , y : btn.centerY } : null ;
} ) ;
expect ( cancel , "Cancel button found in the chooser" ) . not . toBeNull ( ) ;
await synthClick ( page , cancel ! . x , cancel ! . y ) ;
await expect
. poll ( ( ) = > frameCount ( page ) , { timeout : 15000 , intervals : [ 200 ] } )
. toBe ( framesBefore )
. catch ( ( ) = >
console . log (
"[TEST] chooser close not observable without a lib provider (fp-lib park) — soft" ,
) ,
) ;
// Responsiveness after the cancel: a wx timer still gets delivered (the
// doc-19 dead-app class froze the loop here).
const firedBefore = await page . evaluate ( ( ) = > {
try { return JSON . parse ( ( window as any ) . Module . kicadTestTimerParkState ( ) ) . fired as number ; }
catch { return - 1 ; }
} ) ;
await page . evaluate ( ( ) = > ( window as any ) . Module . kicadTestArmTimerPark ( 30 , 0 ) ) ;
await expect
. poll (
( ) = >
page . evaluate ( ( ) = > {
try { return JSON . parse ( ( window as any ) . Module . kicadTestTimerParkState ( ) ) . fired as number ; }
catch { return - 1 ; }
} ) ,
{ timeout : 10000 , intervals : [ 100 ] } ,
)
. toBeGreaterThan ( firedBefore ) ;
const afterCancel = await census ( page ) ;
console . log ( ` [TEST] after-cancel census: ${ JSON . stringify ( afterCancel ) } ` ) ;
expect ( afterCancel . ghosts , "cancel left no ghost transitions" ) . toBe ( 0 ) ;
expect ( afterCancel . deadParked , "cancel killed no coroutine" ) . toBe ( 0 ) ;
const trapLines = [ . . . testLogger . consoleLogs , . . . testLogger . errors ] . filter ( ( l ) = >
TRAP_SIGNATURE . test ( l ) ,
) ;
expect ( trapLines , "no trap/rejection signature anywhere in the run" ) . toEqual ( [ ] ) ;
} ) ;
} ) ;