Picks up the wxwidgets correction for the CI failure: the loop-depth gate is
replaced by releasing the parked nested DoRun from the scheduled tick's own
error path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
Picks up the wxwidgets follow-up CI caught: the top-level loop must not
schedule dispatches while a quasi-modal's nested pump owns event delivery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
The editor reported nothing when a session died. Evidence lived only in-tab —
an 800-line React array behind a "Show console" button — so diagnosis meant
asking a user to paste a screenshot.
Better Stack's Error Tracking ingests the Sentry wire protocol, so this runs
the stock @sentry/browser against a Better Stack DSN. Sentry.init installs its
own window error/unhandledrejection handlers, so uncaught main-thread errors
and the wasm traps that escape emscripten's DOM event handlers are captured
with no instrumentation at the throw sites. Not their JS tag: it has no
beforeSend or fingerprint hooks, its runtime spawns workers from cross-origin
CDN hosts (this page is COEP: require-corp), and it ships session replay on by
default — which on a CAD canvas records customers' board geometry.
@sentry/browser is imported in exactly one file so the vendor stays swappable,
mirroring how lib/analytics.ts isolates Plausible.
Also replaces the terminal-signature regex with a shared, unit-tested predicate
(wasm/terminal-error.ts) used by BOTH the fatal overlay and the reporter, so
they cannot disagree. The regex was a type check written as a string match and
had three live holes: `RuntimeError` was listed but never appears IN
`.message`; Chrome's bare "unreachable" and "null function" matched nothing
(the v0.1.20 prod log is exactly those); and narrowing "table index is out of
bounds" to `\bindex out of bounds` for Firefox in 197f317 silently stopped
matching Chrome's spelling. Checking the TYPE — every trap in this family is a
WebAssembly.RuntimeError — covers all engines and ends the spelling chase; the
message patterns remain as a fallback for paths that lose the Error object,
such as a worker ErrorEvent crossing the realm boundary with error: null.
197f317's pthread-worker tap, promote() and Firefox findings are kept as-is.
Notes:
- Off unless VITE_ERRORS_DSN is set AND VITE_ALLOW_USER_OVERRIDE !== "1" (dev
servers and every Playwright harness set the latter, and production builds
never do), so a production DSN in a local .env still cannot report. With no
DSN the whole SDK is const-folded out: 1,193,080 vs 1,282,463 bytes of JS.
- browserApiErrors integration removed. It wraps setTimeout/rAF/addEventListener
in try/catch, which is exactly how KiCad-on-Emscripten drives its main loop.
- Console breadcrumbs off (collab/debug.ts's clog fires per Yjs update and would
evict the ring before any crash); dom/fetch/navigation breadcrumbs kept.
- beforeSend redacts token/apiKey/Bearer — collab/provider.ts puts the collab
token in the y-partyserver URL, so a connection-failure string carries a live
credential — and guards the cascade: one wedge produced 8 errors in prod, and
after the first terminal event the rest are dropped into cascade_count.
Verified end to end against the real EU host from a cross-origin-isolated page:
POST /api/<id>/envelope/ -> 200, and 4 terminal throws produce 1 event
(control: 1 throw, same count).
Privacy policy 9, cookie policy 6 and the licenses page are updated: Better
Stack is disclosed as an EU processor, and the licenses page now describes the
browser app's own JS dependencies, which it never did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every repo moved from emergence-engineering/ to PCBJam/, but the published
source pointers still named the old org. GitHub's transfer redirect resolves
them (all six checked, 301 -> 200), so nothing is broken today — but it stops
working the moment anyone creates a repo at an old path, and a GPLv3
corresponding-source pointer is a poor thing to leave depending on a redirect.
CI never passes --repo, so the hardcoded default is what actually ships: the
editor's version badge on editor.pcbjam.com has been linking users to the old
org for their source.
Covers the source pointers (licenses.md, terms.md 12.4, REPO_URL and its doc
comment, the three build-script --repo defaults, the site footer's build-commit
link) and the two "our GitHub" org links. Bumps the pcbjam-shared pointer for
the same fix there.
Deliberately untouched: emergence-engineering.com, the company domain behind
contact@ and the EE credit block — the trailing slash in the substitution keeps
it out. And docs/security-audit-glm/, which describes a finding rather than
linking anywhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up the wxwidgets fix (main loop schedules its events into a fresh JS
task instead of dispatching them inside its own Asyncify wake continuation)
and documents the whole round in docs/features/async/16.
Local verification on the warm-load repro built yesterday — the case that
failed every warm load on every build since v0.1.12: 3/3 loads settle with a
fully rendered Leonardo board, rootHotTotal=0 (it was exactly 1 at every
death), fcsTotal=72, no traps. Full kicad e2e: 136 passed, 1 failed, and that
one (occ-probe's GLB format) fails identically on a build without this change
— a pre-existing OCC build-flag issue, tracked separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
The prod differential ladder finished: staged byte VOLUME on a warm load is
the only trigger left (V1a siblings-without-lib-tables dies, V1b +120 files
survives, V1c sibling KiCad files renamed byte-for-byte dies, V1d Leonardo +
123MB of inert markdown dies on loads 3-4; 14MB never dies). 3D models,
collab/ydoc/presence, lib tables, sibling KiCad handling and file count are
all exonerated — volume only loads the dice on the underlying race.
That made the crash reproducible locally for the first time in six campaigns:
a persistent browser profile + a 110MB project fails every warm load with the
exact prod signature. Iteration is now ~12 minutes instead of a release cycle.
Shim: every fiber switch now records the departing side's remaining asyncify
buffer and its recorded rewind entry (rem=/rf=), which is what identified the
unrewindable capture and disproved buffer overflow. The deferral family is
closed for good — a microtask-deferred retry on a clean empty stack died
identically to the nested rewind, because the suspension is broken at write
time, not by nesting.
Shell: log the origin stack when wx reports the top window destroyed. That
notification fires from ~wxTopLevelWindowWasm for ANY top-level window, so a
transient frame dying mid-load navigates the user out of the editor — a real
bug in its own right, found while chasing the empty flight-recorder dumps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
The 8/2 crash-hunt bisection attempt with ?collab=0 was silently invalid: the
flag only gated the attach, while the boot fan-out joined the doc room and
materialized the target file from the ydoc regardless (the "collab=0" prod
log shows both, plus an attach). The flag now also skips the doc-room join —
the file falls back to the plain fetch path — making it a real lever for the
warm-siblings crash bisection (ydoc-vs-sexpr file source, the next suspect
after sibling restage and collab attach were exonerated) and an honest
user-facing escape hatch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
The differential-repro ladder (2026-08-02, five prod runs + local counter
measurements) narrowed the crash trigger empirically: warm loads of
sibling-heavy projects die at settle (V1/V4 fail 2/2 warm; V2/V3 without
siblings never fail, warm or cold), while the flight-recorder counters show
the settle-time collision windows themselves are universal (fcsTotal=72,
rootHotTotal=3 on V1 AND V3, every load, cold and warm — so the windows are
the shared fan-out, not sibling-made). The sibling restage's room connects +
restage fetches are the only sibling-specific traffic contending with those
windows, and warm IDB compresses it into exactly that moment.
Nothing in the restage is needed for first paint — the boot snapshot staged
every sibling seconds earlier — so it now starts on requestIdleCallback
(5s timeout; setTimeout(3s) fallback), well clear of the settle storm.
Unmount-safe via disposedRef (armed per mount, checked in the deferred
starter and on handle resolution).
Validation is empirical by design: the counters won't move (windows are not
sibling-made); the test is warm V1/V4 prod loads no longer dying. If they
still die, the sibling lever is exonerated too and the remaining suspects
narrow to the ydoc-materialization path差 (second-load file source) — the
next probe either way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
The 96-event recorder ring holds under a second of history at idle tick rate
(~110Hz), so settle-time collision pressure scrolls out before any poll can
read it — the prod dumps only caught the kill because the trap froze the
moment. Scroll-proof totals since boot: every finishContextSwitch increments
fcsTotal; every root entry inside a sleep-wake window increments rootHotTotal
(the fatal precondition). Both appear in the [wx-asyncify] STATE line, i.e.
in every trap auto-dump, every __wxAsyncifyDump() call, and every blue-screen
console — turning ANY prod load (crashing or clean) into a dose measurement
for the differential-repro experiment (which project ingredient generates
collision windows: siblings, 3D models, libs).
.ci-cache-epoch 7→8.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
Crash-hunt sessions kept re-asking "was that even the new build?". Every boot
now logs two self-identifying lines into the in-app log (and thus the fatal
ring + blue screens): the app's own bundled chunk name + manifest base + UA,
and the wasm's CDN ETag + SHA-256 of the first 128KiB + length — teed from
the same stream the progress counter reads (no second download, no
buffering), emitted early so the line exists even when the load dies later.
Verify against the CDN with: curl -r 0-131071 <wasm-url> | shasum -a 256.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ac/code/session_019SE4o46Lnq3hF574FFq8x4
v0.1.24 in prod, doubly convicted the same morning: the Leonardo open
crawled/hung (open:settled result=failed at the 60s escape, heap never past
256MB — every main-loop iteration runs INSIDE its yield-wake extent, so the
"root-owned wake" scope matched thousands of legitimate nested coroutine
Call/returns per open, each paying a deferred macrotask, throttled to ≥1s in
a background tab), AND the Nano crashed 22ms after deferrals=1 fired.
Harmful and insufficient: the fatal nested-rewind interleave and the benign
bulk are observationally identical at this layer — no discriminator exists.
Retired (second and final retraction, async/16 round 5). What stays shipped
and clean: consume-once root suspensions, the internally-parked quarantine +
laundering check, the flight recorder + beacons, the WSOD floor, the
pendingSleeps leak fix (confirmed by pendingSleeps=[] in the Nano dump). The
rare nested-rewind crash is ACCEPTED and fully observable until the
structural fix — the design-B fiber-first runtime (async/06,12,13), where
one scheduler owns every suspension and this interleave cannot exist.
.ci-cache-epoch 6→7.
Local: fiber 2/2 (one refusal beacon) + timer + firefox sweep 21 passed,
chromium scenarios 11 passed/4 quarantine-skips, web fatal+follow 2/2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
move-vs-move diverged on CI right after value-vs-value was extracted: across
this week's runs, THREE different same-item conflict steps have each failed
to converge (settleConverged 90s, byte equality never reached). This is a
conflict-resolution class bug, not a per-step flake — same evidence profile
as before (no guard beacons, reproduces locally, not poll timing). Whole S4
test fixme'd alongside S4b; the sequenced-edit scenarios (S1–S3, S5–S8) have
never diverged and remain active. Tracking: memory s4-value-race-divergence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
Two clients racing setValue genuinely diverge in ~5-8% of runs:
settleConverged times out at 90s with the trio never reaching byte equality.
Not timing (windows already widened, reproduces locally at single-worker) and
not the asyncify guards (zero beacons in failing runs) — a real CRDT/apply
race this harness exists to catch, gating unrelated releases in the meantime.
Extracted into its own test.fixme (S4b) with the full original body;
move-vs-delete and move-vs-move stay active in S4. Tracking notes: memory
s4-value-race-divergence — next steps are capturing both tabs' modelText diff
at timeout and bisecting the value-apply path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ac/code/session_019SE4o46Lnq3hF574FFq8x4
drift-trio-scenarios S4/S5 flaked 4× across this week's CI runs (three wasm
instances + full-suite load on the runner) while passing 77/78 locally under
stress — and the one local miss carried zero guard beacons, i.e. the same
under-load convergence shortfall, not a code path. Same treatment as the
follow spec: condition-based polls keep their shape, windows grow to what a
starved box actually needs (inline S4/S5 polls 20s→60s, waitAllContain
30s→90s, settleConverged 30s→90s). Local convergence stays ~1s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
The first prod blue screen (finally!) also surfaced the console UX gaps: the
in-app log had to be hand-copied (and arrived truncated — the flight-recorder
dump missing), and the DOM-floor screen's log was a fixed block.
- React console: a copy button beside the toggle (writes the full log to the
clipboard, confirms in the log); stays collapsible on a fatal as in normal
editing.
- DOM-floor screen: its console mirrors the editor one — toggle bar
(▾/▸ console) + copy, collapsible — and its content is ring + trace dump.
- One blue family: both fatal screens now use the boot overlay's #1a1a2e, so
every full-screen state (boot, consent, fatal, floor) is coherent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
The v0.1.23 flight recorder caught the kill live (console-export-2026-8-1_19-16-8):
dozens of benign fiber round-trips at w=0, the yield cycling healthily on its
buffer — then "fcs … ROOT w=1" and the trap, state frozen at Rewinding with
currData=root+20. The fatal condition, observed rather than inferred: a fiber
round-trip inside the ROOT's OWN sleep-wake continuation re-suspends and
re-rewinds the root nested inside its live wake rewind. Consume-once passed
correctly — it guards a different corruption and stays.
The round-3 deferral was aimed right but unscoped (taxed fiber-owned wakes,
flaked S4). Final form: every fresh sleep is tagged root- or fiber-owned
(fiber ⇔ started inside a finishContextSwitch fiber slice or a fiber-owned
wake; root entries don't count as slices); finishContextSwitch(root) defers
one macrotask ONLY while a root-owned wake is live (Asyncify.__wakingRoot).
Beacon: root-entry-deferred. Verified inert where it must be: zero beacons
across all 13 drift-trio-scenarios logs (26/26 + 25/26-then-26/26 stress —
the single miss carried no beacons, i.e. the pre-existing under-load flake).
Also: resume re-entries no longer push sleep contexts (the v0.1.23 dump
carried ~380 leaked zero-linked entries), and wake events in the recorder are
tagged R/f for ownership.
.ci-cache-epoch 5→6.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
The deferral (9ca2ac1) modeled the wrong condition and taxed every parked
fiber completion with a macrotask hop: under CI load that stretched
three-client apply chains and flaked drift-trio S4 twice consecutively
(26/26 green locally under stress) — retracted.
The actual fatal state, readable in all four prod stacks once seen: a SECOND
rewind of the same root suspension. Root suspends once per fiber_swap out of
it; two parked fibers completing against one root suspension epoch (a tool
fiber + a collab fiber both waking around open:settled) each drive
finishContextSwitch(root) — the second rewinds already-consumed data →
"unreachable executed" → poisoned runtime, with the wake-side "index out of
bounds" as the sibling symptom.
Cure: stop exempting root from the validity check the shim already keeps.
First consumption proceeds synchronously — zero added latency anywhere; the
second is refused ([wx-asyncify] "root suspension already consumed") — the
yielded fiber stays properly suspended and resumable, root continues via its
real pending resume, libcontext's ghost-epoch contract enforced one layer
lower. Root remains exempt only from the internally-parked quarantine (its
yield park is routine).
.ci-cache-epoch 4→5 (the epoch-4 cache holds the retracted deferral shim).
Local: fiber 2/2 + timer 1/1, firefox sweep 20 passed, drift-trio-scenarios
kicad-chromium 26/26 under 3-worker stress, web fatal+follow 2/2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
v0.1.22's WasmErrorBoundary was still not enough: a commit-phase throw in
WasmTool's OWN effects unmounts the root, and no boundary below it helps.
fatal-screen.ts is the floor: plain-DOM blue screen with its own mirrored
log ring (append feeds recordFatalLog), installed at module import in
main.tsx — before and independent of React. It cooperates with the React
overlay: hidden while [data-testid="fatal-overlay"] exists, takes over via a
1Hz ensure-loop the moment it disappears. Fatal promotions also append the
asyncify flight-recorder dump so whichever screen survives carries the
targeting data.
fatal-overlay.spec.ts now also rips out the React root after the fatal and
asserts the DOM floor takes over with the mirrored [fatal] log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
v0.1.22 still trapped with BOTH guards silent: the fatal rewind's target is
the ROOT context, which layer 2 exempted. All four prod stacks are the same
collision — a fiber completes its yield-back to main while main's sleep-wake
rewind is still on the stack (maybeStopUnwind → trampoline →
finishContextSwitch → doRewind(root) → unreachable), two "resume main" paths
interleaved in one tick; the 8ms-earlier "index out of bounds" is the wake
side of the same event.
Root entry is legal and constant in healthy flow; only the wake-window
overlap is fatal. So: serialize, don't refuse. The shim marks the
synchronous wake window (Asyncify.__inSleepWake around wakeUp) and DEFERS a
root finishContextSwitch landing inside it by one macrotask
([wx-asyncify] root-entry-deferred beacon, trampoline retry) — an ordering
change only, nothing dropped. Suspension recording happens before the
deferral branch, so the yielding fiber's validity survives the wake chain
nulling currData.
Plus a flight recorder: a 96-entry ring of asyncify/fiber events (sleeps,
wakes, every context switch with ROOT/wake-depth, refusals, deferrals),
silent in normal operation, auto-dumped with full machine state next to the
first trap signature in the console; window.__wxAsyncifyDump() on demand.
The next prod export reads like a black box, not a stack-shape puzzle.
.ci-cache-epoch 3→4 (wasm cache key omits scripts/**).
Local: fiber-resume-park 2/2 (one refusal beacon), timer-park 1/1, sweep 20
passed, web fatal+follow 2/2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
4 flakes in 6 CI runs on 2026-08-01, all with clean traces: no guard beacons,
A demonstrably landed (the 208cb67 sequencing gate passed), B just never got
the one-shot rect applied within 30s while two wasm instances starved the CI
box. Awareness re-broadcasts state periodically, so a 90s condition-poll
converges on re-delivery; local runs stay fast (converge in <2s).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
kicadTestFiberParkStartSecond/PokeSecond: a second coroutine started while
the first body is asyncify-parked reproduces the misattributed jump that
launders the parked fiber past the C++ guard (the v0.1.21 prod bypass).
Spec scenario 2 stages it and asserts the JS stale-rewind guard quarantines
the laundered resume (exactly one fiber-resume-refused beacon), the parked
body completes undisturbed, and both coroutines finish cleanly.
Doc: async/16 rounds 2 + WSOD section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
v0.1.21 still trapped with ZERO jump-refused beacons: the fatal swap PASSED
the C++ swap_suspended guard. Mechanism (async/16 round 2): a fresh JS entry
executing while g_current_context still points at a parked fiber gets
attributed to that fiber — fiber_swap writes a fresh, valid-LOOKING foreign
suspension into the parked fiber's struct and re-marks the flag. The flag
lies; the resume rewinds garbage.
This guard tracks truth at the emscripten-fiber layer (handlesleep.js wraps
Fibers.finishContextSwitch):
- valid suspensions = real swap-outs (currData == oldFiber+20 when the
trampoline runs), consumed on rewind;
- internally-parked = an entered slice that ended in a handleSleep park
(currData set, no nextFiber) — quarantined until a GENUINE swap-out,
where genuine means the fiber's pending sleep has resolved
(__pendingSleepContexts), so a laundering write cannot lift it;
- entering a quarantined or suspension-less fiber is REFUSED
([wx-asyncify] fiber-resume-refused, ghost contract).
The ROOT context is exempt from quarantine and refusal: its rewound
continuation runs the whole main loop, whose routine yield park says nothing
about a fiber body — the first build of this guard quarantined main off that
signal and starved every coroutine return (19 collab e2e reds, empty
results). Root = the old side of the first switch ever.
.ci-cache-epoch 2→3: the wasm output cache key omits scripts/**.
Red/green: fiber-resume-park.spec.ts scenario 2 (laundered resume → exactly
one refusal beacon, both coroutines complete); full fiber-heavy sweep green
(21 passed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
All three prod crash cascades (v0.1.19–21) ended white for the same reason:
the final trap lands inside a child's EFFECT (an embind call reached through
a react-query subscription), React unmounts the entire root, and the fatal
overlay + console panel die with the tree they were built to survive.
- WasmErrorBoundary: crash-capable children live inside it; the fatal screen
and the console panel live OUTSIDE and keep rendering. WasmTool state
(logs included) survives a descendant render/effect throw.
- The fatal screen is now an actual blue screen (:( + solid blue), and every
promotion path (window error, unhandled rejection, worker error, boundary)
auto-opens the console — the log is the only account of what was loading.
- tests/web/fatal-overlay.spec.ts pins the contract: a terminal uncaught
error after boot ⇒ visible fatal overlay + open console with the [fatal]
record. Green locally (23.9s).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
"B never tracked the second viewport move" ate three v0.1.21 release
attempts (2026-08-01) while the same build passed 10/10 locally, including
under 4-worker contention. The T2 block asserted B's convergence without
first confirming A's second fit LANDED — the T1 block has that sequencing
point, T2 didn't — so on a starved CI box any A-side fit delay was
misreported as a B-side tracking failure, and B's 30s window burned while A
was still moving. Mirror the T1 pattern: poll A onto its target, then start
B's clock.
Verified 4/4 green locally (web-chromium, 2 workers). Reminder that cost 20
minutes tonight: web/standalone/.env poisons local web e2e (untitled/title
mismatches) — stash it before local runs (standalone-env-e2e-poison).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
Companion to kicad f0ce20ef64 (libcontext swap_suspended guard), which this
pins. The v0.1.20 diagnostics decoded the crash that survived v0.1.13–19:
TOOL_MANAGER Resume()s a coroutine whose body is asyncify-parked inside
handleSleep, the swap rewinds the stale fiber suspension, and the runtime is
poisoned. Full chain of evidence in docs/features/async/16-fiber-resume-guard.md
(+ round-3 addendum in 15-timer-park-repro.md).
- wasm/bindings/fiber_park.h + kicadTestFiberPark{Start,Prime,Poke,State}
exports (pcbnew + merged kicad_editor): stages Call→yield→legitimate
resume→sleep park→mid-park Resume, the exact prod state machine. The
first yield matters: it primes a real (then stale) suspension, matching
long-lived tool loops rather than a first-slice park.
- tests/kicad/fiber-resume-park.spec.ts: asserts the healthy contract on
polled state only (embind returns across fiber swaps are unwind
placeholders). RED on the unguarded build — fiber/sleep buffer
cross-restores, a jump-ghost beacon, the parked body zombified. GREEN with
the guard: mid-park poke refused ([collab-fcontext] jump-refused beacon),
park completes, post-yield resume works, no trap signatures.
- Regression sweep green: timer-park-repro, collab-load-fuzz, load-pcb,
pcbnew-collab, collab-undo, eeschema-collab (19 passed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
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
The shim has been silently restoring Asyncify.currData across overlapping
parks since it was written — so a prod console dump could never say whether
the #9153 family fired, mislinked, or was bypassed. Rate-limited [wx-asyncify]
reporting, calibrated against the timer-park e2e (2 rounds, 2026-07-31):
- concurrent-park: a FRESH park (state 0) starting while another chain's
currData is live. state-2 entries are NOT reported — every legal resume
re-enters handleSleep while Rewinding (~100/s measured on a healthy run).
- reentrant-state: handleSleep entered mid-unwind (state 1) — never observed;
if it ever fires it IS the bug.
- aliased-wake-live / overlapped-wake: the restore actually repairing —
dangerous (other chain still parked) vs benign (other chain done). The
e2e observed 8 live cross-restores in one run, all survived.
Console.log only, no printf plumbing needed; stacks name the parker
(__asyncjs__wxWasmYieldToBrowser vs _emscripten_sleep etc.).
.ci-cache-epoch 1→2: the wasm output cache key omits scripts/**.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
The v0.1.19 prod crash (console-export-2026-7-31_17-49-20.log) opened with
Firefox's bare "index out of bounds" — no "RuntimeError" prefix, no "table" —
which the terminal-signature regex only knew in Chrome's spelling, so the
overlay this feature exists for never promoted on the very trap it was built
against. Match the bare form (+ "null function or function signature", the
other Firefox spelling in this family).
Also wrap the Worker constructor attach-only: a pthread worker's uncaught
error fires an ErrorEvent on the Worker OBJECT, never on window, so worker
crashes (raytracer pool etc.) were invisible to both listeners. The editor's
main()/wx run on the page thread (no PROXY_TO_PTHREAD on the link line) — the
tap is defense-in-depth, not the primary fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
Bump web/pcbjam-shared: hot-path zod parses replaced with a hand-rolled
structural validator; ydoc→sexpr materialization ~12x faster on big boards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPYYvNtRgyAFq9EScSxKYr
Under realtime "shared-only" a board/schematic session holds no socket per
org lib — which silently broke the lib-update toast (a peer editing a
PLACED symbol never reached the open session live). Complete the design
with the deferred-realtime upgrade: after open, scan the staged target
document for lib-table nicknames (lib_id / footprint / lib_symbols tokens)
and promote exactly those libs' stacks to realtime via the new
LibsSource.enableRealtime. One socket per lib the document actually uses
(typically 0-5) instead of one per lib in scope (60+); every other lib
still catches up on the next load via the descriptor digests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4EzyhjhDzLdNZsFAYjz7X
The onItems handler let unwrapWireItem's throw unwind through embind into the
C++ emitter — a bare pageerror, the whole batch lost, and flushDiff already
rebaselined so the dropped items could never be re-sent. Field-seen case:
Update PCB from Schematic emitted 67 changed entries, one an item-less board
envelope (pcbnew writes nothing for a standalone footprint field); all 67 were
dropped and two new footprints existed only on the syncing tab.
The conversion now skips un-resolvable entries per-entry (shared items-wire
fix), every conversion site warns via warnSkip, and the handler body is
wrapped so nothing escapes into the C++ caller again. The tests/web spec
drives the real serializer's empty envelope through onItems and holds that a
good entry batched with it still reaches the peer (proven red before the fix).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019daWLdW5xrRhjUvCUWoSAe
Presence-scoped sibling restage: every tab announces the document it is
actively editing in the project presence room (PresenceState.sheetPath,
re-published on eeschema sheet navigation), and a pcbnew session connects a
sibling schematic's board-room only while a peer announces it open — instead
of eagerly holding one socket per sheet for the whole session. Works because
/files/ materializes from the ydoc, so the boot MEMFS snapshot is room-fresh;
a sheet can only drift while someone is editing it, and that someone is in
the roster. On leave the watch lingers 30s, flushes the pending restage, and
closes; without a presence room the eager mode remains as fallback.
A solo board session now holds 3 sockets (doc, presence, lib-mirror mux) —
down from 92 on an 8-project repo before this series.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4EzyhjhDzLdNZsFAYjz7X
Refreshing ANY editor URL navigated to the management app's project overview
instead of reloading. Not related to spaces in paths — verified path-independent
by reproducing it on a fileless `-/pcbnew` route with no file path at all.
Mechanism, from probing the actual ordering rather than reading the code: the wx
port registers its unload handler via emscripten_set_beforeunload_callback,
which lands in the CAPTURE phase and so runs before any bubble-phase listener
regardless of registration order. It closes the top frame -> fires
wxAppTopWindowClosed -> the quit dispatcher -> location.assign(exitUrl), and
that navigation overrode the in-flight reload (the reload itself failed with
NS_ERROR_FAILURE).
The quit hook already latched off for our OWN navigations
(markDeliberateNavigation) and on pagehide — which the existing comment notes
fires too late, at commit time. Nothing covered a browser-INITIATED unload:
reload, Back, closing the tab. This adds that latch, in the capture phase so it
precedes emscripten's handler; a bubble-phase listener registered at module
scope was NOT enough, which the probe showed directly.
Tradeoff, documented at the call site: if a beforeunload prompt appears and the
user stays, the latch remains set and a later File->Quit won't self-navigate.
That is strictly better than a page that cannot be refreshed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
Module.print feeds only the in-page log panel; it echoes to the JS console
solely under ?trace=. That is right for ordinary wasm chatter and wrong for the
two diagnostics wxwidgets now emits: production crash reports reach us as SAVED
BROWSER CONSOLE DUMPS, so a diagnostic that never leaves the page is invisible
in the one artifact we actually receive — and the in-page buffer is capped at
800 lines, so a long load can evict it before anyone reads it.
Narrow by construction: only the "[wx-dispatch]" and "[wx-timer]" prefixes, both
rate-limited in C++ and silent on a healthy load, so this cannot become noise.
Verified by driving Module.print directly in a real browser: the two prefixes
reach console.log / console.warn and an ordinary line does not.
Also bumps wxwidgets for those diagnostics.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
Every production report of the board-load crash so far has been a console dump
with no timing and no state, which is why five theories died slowly and none
reproduced locally. This records the few facts that would actually discriminate
between the ones still standing, and nothing else.
Marks: fs:wait, fs:ready, stage:done, open:start, open:settled, presync:settled,
ui:ready — on one monotonic clock, each carrying the wasm heap size, with growth
called out explicitly.
Why these:
- The open window is where the crash lives: OpenProjectFiles runs the footprint
library preload INLINE on the main thread in the WASM build. Bracketing it
lets a crash be placed inside or outside that window instead of inferred from
log order.
- ui:ready is first paint. The symbolized trap needs !m_gal->IsInitialized(),
so a report containing this mark rules that mechanism out and one stopping
before it does not.
- Heap size at every mark, because growing wasm memory detaches JS-side views,
and a stale view writing into a detached buffer is one of the few mechanisms
that yields a bad function-table index much later. Already earning its keep:
a local Leonardo load grows 256MB -> 443MB during staging, immediately before
the open. Locally that is survivable; production should not be guessed at.
dump() prints the whole timeline and is wired into all three fatal paths (boot
catch, window error, unhandled rejection), so one paste from a user carries the
full load shape. Bounded at 200 entries; unit-tested including eviction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
Measured on a real Leonardo load against the local platform stack:
per-lib sync-stack POSTs : 174-200 -> 0
batch sync-stacks POSTs : 0 -> 1 (216 stacks in one request)
file GETs negotiating ydoc: 0 -> 107 of 133
Lib resolve: syncedScopeLibsSource now resolves every stack up front through
the shared paged batch client and feeds them to the per-lib sources, which skip
their own POST on a hit. Best-effort throughout — a failure (older backend
without the route, a network blip) leaves the map empty and every lib resolves
exactly as before, so this can only remove requests, never break a load. A
batched `null` is recorded as "backend says unresolvable" so a stale pin isn't
then retried one-by-one.
File downloads: fetchFileBytes sends Accept: application/x-pcbjam-ydoc and
converts client-side with the converters shared already exports, moving the
per-request materialize cost off the metered Worker. A backend that doesn't
negotiate answers with text and the branch never fires; a ydoc we fail to
convert re-fetches without negotiating rather than making the file
undownloadable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
The boot overlay only renders while `!ready`, so anything that killed the
runtime AFTER the editor came up — a wasm trap, an abort, a staging fetch
failing late — unmounted the last thing on screen and left a white page with
no explanation.
Adds a `fatal` state rendered INDEPENDENTLY of `ready`, set both from the boot
catch and from window error / unhandledrejection listeners that promote only
genuinely terminal signatures (RuntimeError, abort, table index out of bounds,
indirect call signature, unreachable). Ordinary app errors must not hijack a
working editor, so anything else is ignored.
The console panel moves from z-20 to z-40, above both overlays, and is forced
visible on a fatal even with chrome hidden: when a load fails, that log is the
only account of what was loading when it happened, and it was being covered by
the very overlay reporting the failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
React Router 6.30 decodes named params but leaves the `*` splat
percent-encoded, so a deep-link like
/:scope/projects/arduino/Repo-main/KiCad%20Projects/Mega.kicad_pcb
reached ToolPage as `name: "arduino"` (decoded) alongside a splat still
reading `KiCad%20Projects/...`. Every consumer of the resulting targetPath
expects the decoded form: the project file list carries real spaces, and
project-source's encodePath re-encodes per segment when building API URLs,
so a %20 target double-encodes to %2520.
Decodes per SEGMENT so an encoded separator can never silently become a path
boundary, and keeps a malformed segment verbatim rather than throwing during
a route render. The unit test drives matchPath directly so a future router
upgrade that changes this behaviour fails loudly instead of silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
- kicadOpenFile now holds wxWasmDispatchGuard (open_gate.h). It enters through
embind, so the interlock read "nothing parked" for the whole load and wx timers
dispatched into the half-built board — the residual prod "index out of bounds"
that survived the settle gate.
- new wasm/bindings/gerbview_embind.cpp (the bundle had no embind surface at all):
kicadOpenFile / kicadOpenFiles / kicadOpenFileBusy. Clicking one gerber opens the
whole fabrication set in its folder, since a lone layer is not a useful view.
- cross-app presence rejoins in the boot fan-out (network-only; the wasm-bound half
still waits for the open to settle) — it had been pushed behind the board load.
- tests: gerber-set selection units + a gerbview multi-file open e2e.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
The 7/29 "presync after open" reorder misdiagnosed the slow board open: the
problem was ordering/serialization (libs awaited before serially-fetched
project files), not bandwidth — the ~155 lib requests are latency-bound. It
also regressed the symbol editor, whose frame eagerly enumerates every lib
through the mutex-serialized bridge crossings: with no warm-up running during
the wasm download, each cold lib fetched one-at-a-time inside its own serial
crossing.
- WasmTool: at consent-OK start in parallel — wasm boot, doc-room connect
(websocket up BEFORE any file fetch; errors captured and rethrown at the
await), lib presync (concurrency 8, presyncSettled never rejects), and
project-file staging (the glue runs FS.staticInit() at script-eval, so
MEMFS staging always overlapped the download).
- Lib editors (fileless): installLibsProvider enumerateGate parks the whole
"list" op (a plain name list also cold-fetches the bundle) until the
presync settles, and the boot overlay waits for it before waitForWxUi —
downloads run 8-wide in JS, the serialized crossings become IDB read +
parse. pcbnew/eeschema stay ungated (a silent mid-session park would read
as a hang). The reentry-guard mutex itself is untouched.
- Overlay: "Project files — n/m" staging line (DriveOptions.onFileProgress),
and the lib line returns worded "Checking <kind> libraries — n/155" (the
walk checks every lib but downloads only new or changed ones).
- boot: hasWritableLib accepts lib type "org" (the private platform's rename
of "user") — boot re-created "My Symbols" on every load and the backend
409'd.
typecheck clean; 239/239 standalone tests; web e2e tools-open 7/7 +
chrome-toggle 2/2 + comments 1/1 (web-firefox).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N925iFAdnVcMCdvS4pzXER
The partykit provider splices the room into the WS URL verbatim, so it now
travels as ONE percent-encoded path segment (the sync worker decodes it at
its edge); bumps pcbjam-shared for the injective key scheme.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfEPcy9dStnSfm1hmaiTe4
The v0.1.14.1 release gate died in "Build wxWidgets (wxUniversal WASM)" with
"No rule to make target 3rdparty/pcre/src/pcre2_chartables.c". Nothing in the
tree changed wx — the submodule is the same SHA that released fine as v0.1.13
— and the two main CI runs on this tree built wx and went on to run e2e. The
restored build-wasm/wxwidgets cache is the variable: pcre's makefile rm's that
path and re-links it to pcre2_chartables.c.dist at configure time, so a cache
captured around that window restores a tree whose pcre rule cannot be
satisfied from a clean checkout.
CI's wx cache key hashes scripts/build-wx-wasm.sh, so a header edit is the
lever that discards it. Documented as an explicit epoch counter there, since
the next person hitting this will not guess that editing a build script is how
you evict a cache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H8jo7zz1ZwzYpjJ64UZKN4
Two fixes to the same complaint — opening a board took minutes and the dialog
explaining why was misleading.
Ordering: the scope-wide lib presync ran "in PARALLEL with the wasm download",
so ~155 libraries' worth of requests competed with the 26MB+ bundle and the
project's own files — the things the user is actually blocked on. It now
starts after driveProjectIntoTool, warming IndexedDB behind an editor that is
already open. Still fire-and-forget and best-effort (the SyncStack dedups, so
a lib the wasm reaches mid-presync awaits the same in-flight fetch), now
abortable on unmount, and no longer shown on the boot overlay as a counter the
user is waiting on — the background indicator owns it.
Wording: the dialog quoted the compressed size with no hint that it was
compressed ("~35 MB" for a bundle whose progress bar then counts ~150 MB raw),
and quoted only the count of libs whose BODIES need downloading — while the
progress bar went to 155, because every warm lib still costs a sync-stack
resolve plus a manifest GET to find nothing changed. It now reads "~35 MB
compressed / ~150 MB uncompressed" and "downloads 1 library, checks all 155
for updates". Every existing degradation path is kept: unknown sizes still
say "large", sizesKnown:false degrades to "at least ~X MB", and the
no-warmth-answer case now says "fetched in the background" rather than the
newly-false "downloaded now".
Investigated and NOT changed: the symbol path is not structurally more
deferred than footprints — both enumerateLibrary() implementations are already
no-ops, so neither face does bridge crossings at boot. The perceived
schematic-vs-board difference was this presync timing plus set size.
typecheck clean; 233/233 standalone tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H8jo7zz1ZwzYpjJ64UZKN4
Opening a large board could kill the editor runtime outright: "index out of
bounds" / "unreachable executed" / "indirect call signature mismatch", after
which every later focus/key event trapped. Reported in prod against a big
uploaded board, and correlated by the reporter with the moment the presence
WebSocket connects.
That correlation is the tell. Presence/collab work enters the wasm through
runOnFiber -> COROUTINE::Call, i.e. emscripten_fiber_swap, whose stop_unwind
corrupts Asyncify's single currData slot when ANOTHER context is already
parked there. docs/features/async/13 pins the invariant: exactly one
unwind/rewind transition in flight, and prescribes "a single shared is-a-
transition-in-flight guard the pumps consult before re-driving".
Normally the slot is free when fibers drain: the main loop's per-frame
wxWasmYieldToBrowser completes every frame, so drainFibers runs between
yields. It is NOT free when a nested/modal pump tick drives ProcessEvents
while the chain that opened the modal is parked deeper down — precisely a big
board open (progress dialog over a parked load). The wx dispatch interlock
does not cover this: the modal parks deliberately zero its count so their own
pump may dispatch.
So gate the swap itself: drainFibers defers (re-CallAfter) while
asyncifyInFlight(). Bodies are viewport/overlay/apply work, so waiting out the
park costs latency, never correctness.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H8jo7zz1ZwzYpjJ64UZKN4
The boot-time MEMFS staging fetched one file per round-trip, serially, so a
many-file project (an uploaded repo) paid full request latency per file before
the editor could open anything — the dominant cost of opening such a project.
Fetch with a bounded pool (8, same as the lib presync) and write as each
lands; the writes are synchronous FS calls on distinct paths, so completion
order does not matter. A failed fetch still rejects the stage, after the
in-flight siblings settle so none can write into MEMFS behind the caller.
Tests cover all three properties: every file lands under reverse-staggered
fetch delays, the overlap is >1 and <=8, and a failing fetch rejects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H8jo7zz1ZwzYpjJ64UZKN4
Bump wxwidgets to fd30c08b18. The interlock landed in 4c7839e turned the
clipboard test app's pre-existing Emscripten abort ("cannot start an async
operation when one is already in flight" — its EM_ASYNC_JS park suspends
inside the synchronous wx_dom_event ccall) into a permanent input wedge: the
dead chain's guard never unwound, so every later event deferred behind it and
4 clipboard specs timed out. The dead chain is now released explicitly from
the JS catch that sees it die.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H8jo7zz1ZwzYpjJ64UZKN4
Bump wxwidgets to 8a80bad7ef (cherry-pick of 5526b599c6 from
fix/wasm-dispatch-interlock): gated event pump (paint-only while a chain is
Asyncify-parked), queue-when-parked input events, deferred timer fire, and
pause/restore around the long-lived modal parks.
Fixes the prod board-load trap (2026-07-29, editor v0.1.12/Firefox): the GAL
refresh timer fired via emscripten_async_call into wx dispatch while the slow
board open was parked in wxWasmYieldToBrowser, corrupting asyncify state
("index out of bounds" → "unreachable executed" in the fiber rewind →
permanently poisoned runtime). Same family as the flaky CI fp-selector trap
the branch was originally written for.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H8jo7zz1ZwzYpjJ64UZKN4