Commit graph

763 commits

Author SHA1 Message Date
Istvan Matejcsok
89fd78962e fix(ci): avoid duplicate staging SPA fallback 2026-08-07 13:17:54 +02:00
Istvan Matejcsok
f90cf7d2da ci: deploy isolated GPL staging stack 2026-08-07 11:52:25 +02:00
Gergő Törcsvári
4d75d44a78
site(blog): devblog w31 — crash hunt, faster board load, project page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjAHi3JhwwDNDgdr3Abkvp
2026-08-04 13:38:34 +02:00
Gergő Törcsvári
a37174b79b
fix(async): release the nested loop on a scheduled-dispatch error
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
2026-08-03 16:55:50 +02:00
Gergő Törcsvári
6cbcb029ba
fix(async): gate the fresh-task dispatch on loop depth
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
2026-08-03 14:54:42 +02:00
Istvan Matejcsok
1b08a5eb06 feat(editor): report uncaught errors to Better Stack
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>
2026-08-03 12:24:30 +02:00
Istvan Matejcsok
20e5eb941c fix: point GPL corresponding-source URLs at the PCBJam org
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>
2026-08-03 12:19:54 +02:00
Gergő Törcsvári
3ba436d1a8
ci: bump the wasm cache epoch — wx event-loop change must relink
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
2026-08-03 12:01:14 +02:00
Gergő Törcsvári
37c5b7e414
fix(async): the board-load crash, cured at the dispatch site
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
2026-08-03 12:01:06 +02:00
Gergő Törcsvári
84a40d4492
diag(asyncify): write-time instrumentation + local warm-load repro findings
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
2026-08-03 11:05:10 +02:00
Gergő Törcsvári
11da3fce19
fix(editor): ?collab=0 is a full kill-switch — doc room + materialization included
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
2026-08-02 13:09:52 +02:00
Gergő Törcsvári
5c7f8a2e85
fix(editor): stagger the sibling restage out of the settle window
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
2026-08-02 12:13:21 +02:00
Gergő Törcsvári
00b80a923f
feat(asyncify): cumulative collision counters — fcsTotal + rootHotTotal in the STATE dump
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
2026-08-02 08:55:41 +02:00
Gergő Törcsvári
3ac24e85d2
feat(editor): build provenance in every log — app chunk + wasm ETag + head hash
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
2026-08-02 08:18:12 +02:00
Gergő Törcsvári
f734d700a2
fix(asyncify): retire the deferral family — guard-layer road closed
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
2026-08-02 08:18:08 +02:00
Gergő Törcsvári
1b84b77c8d
test(drift): quarantine the whole S4 conflict class — three distinct steps diverge
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
2026-08-01 23:12:31 +02:00
Gergő Törcsvári
45f305b20b
test(drift): quarantine S4 value-vs-value — genuine ~5-8% divergence, own hunt
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
2026-08-01 22:18:49 +02:00
Gergő Törcsvári
653819d383
ci: widen drift-trio convergence windows for starved CI boxes
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
2026-08-01 20:50:14 +02:00
Gergő Törcsvári
875195f721
feat(editor): console copy button + collapsible DOM-floor console + one blue family
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
2026-08-01 19:50:01 +02:00
Gergő Törcsvári
210b079ed9
fix(asyncify): ownership-scoped root deferral — the recorded nested self-rewind, cured
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
2026-08-01 19:49:38 +02:00
Gergő Törcsvári
ae33a100c2
fix(asyncify): consume-once root suspensions — replaces the wake-window deferral
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
2026-08-01 15:53:18 +02:00
Gergő Törcsvári
eff5befd5d
fix(editor): DOM-level blue-screen floor — React can no longer white-screen a crash
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
2026-08-01 14:07:09 +02:00
Gergő Törcsvári
9ca2ac1e52
fix(asyncify): layer 3 — serialize root re-entry out of sleep-wake windows + flight recorder
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
2026-08-01 14:06:53 +02:00
Gergő Törcsvári
f488744797
ci: follow spec — 90s convergence windows (awareness re-broadcast covers lost first delivery)
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
2026-08-01 11:03:25 +02:00
Gergő Törcsvári
5333099810
test(kicad): poisoned-attribution lever — the laundering scenario, red/green
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
2026-08-01 10:05:42 +02:00
Gergő Törcsvári
a8adfa9843
fix(asyncify): stale-fiber-rewind guard — layer 2, attribution-proof
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
2026-08-01 10:05:27 +02:00
Gergő Törcsvári
92e02a54f9
fix(editor): the white screen was React unmounting its own crash reporter
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
2026-08-01 10:05:10 +02:00
Gergő Törcsvári
208cb67959
ci: de-flake follow spec — sequence on A landing before asserting B tracks
"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
2026-08-01 09:19:59 +02:00
Gergő Törcsvári
e561507898
fix(async): fiber resume guard — the prod board-load trap, red/green
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
2026-07-31 23:37:05 +02:00
Gergő Törcsvári
f86ef9e433
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
2026-07-31 20:34:32 +02:00
Gergő Törcsvári
7cfb07a99e
chore(asyncify): report the handlesleep shim's silent concurrent-park repairs
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
2026-07-31 20:34:14 +02:00
Gergő Törcsvári
197f317bfb
fix(editor): fatal overlay matches Firefox trap messages + taps pthread-worker errors
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
2026-07-31 20:33:55 +02:00
Gergő Törcsvári
579f559b6c
perf(shared): drop redundant zod walks from ydoc⇄sexpr conversion (12x)
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
2026-07-31 16:15:50 +02:00
Gergő Törcsvári
9ea8b263cc
perf(libs): realtime only for libs the open document references
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
2026-07-31 15:43:11 +02:00
Gergő Törcsvári
826941a1a6
fix(collab): one bad wire entry no longer discards the whole local-edit batch
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
2026-07-31 15:30:55 +02:00
Gergő Törcsvári
b5ed68ef87
perf(collab): scope board-load connections to what's actually open
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
2026-07-31 15:00:51 +02:00
Gergő Törcsvári
7ccedbf973
fix(editor): a browser refresh reloads the editor instead of bouncing away
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
2026-07-31 13:19:10 +02:00
Gergő Törcsvári
b127b582bc
fix(boot): echo wasm dispatch/timer diagnostics to the browser console
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
2026-07-31 12:35:42 +02:00
Gergő Törcsvári
bf0e195ddc
chore(editor): load timeline with heap tracking, dumped on any fatal
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
2026-07-31 11:47:14 +02:00
Gergő Törcsvári
ad777bcbba
perf(editor): one batched lib resolve, and ydoc file downloads
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
2026-07-31 11:31:54 +02:00
Gergő Törcsvári
1ab46eacd7
fix(editor): show a fatal overlay instead of a blank page, above the console
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
2026-07-31 10:54:21 +02:00
Gergő Törcsvári
3b5d4d0662
fix(routing): decode the router splat for file deep-links
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
2026-07-31 09:47:59 +02:00
Gergő Törcsvári
d35cf4f4eb
fix(load): close the dispatch-interlock hole at open + open gerbers from a project route
- 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
2026-07-30 19:08:57 +02:00
Gergő Törcsvári
a26ef4ebeb
fix(load): open-settle gate — kicadOpenFileBusy probe + collab entry guards for the parked-open embind trap (indirect call signature mismatch) + deterministic collab-load-fuzz e2e
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
2026-07-30 14:17:48 +02:00
Gergő Törcsvári
bd1fbdce85
fix(editor): boot fan-out — parallel presync/doc-room/files, lib-editor enumerate gate, My Symbols 409 (load-ux 0003)
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
2026-07-30 10:37:12 +02:00
Gergő Törcsvári
74e822134f
fix(collab): filenames with spaces — canonical room DO name, injective ydoc keys + lazy open-triggered migration
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
2026-07-29 20:44:58 +02:00
Gergő Törcsvári
da556f6202
ci: bump the wx build-cache epoch (poisoned pcre chartables symlink)
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
2026-07-29 17:42:45 +02:00
Gergő Törcsvári
64d2cdf259
Revert "fix(wasm): never swap to a fiber while an Asyncify context is parked"
This reverts commit 32218122c3.
2026-07-29 17:29:28 +02:00
Gergő Törcsvári
2a47103407
fix(editor): warm libs after the editor opens, and say what we actually download
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
2026-07-29 16:02:22 +02:00
Gergő Törcsvári
32218122c3
fix(wasm): never swap to a fiber while an Asyncify context is parked
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
2026-07-29 15:29:36 +02:00