design-b Phase A: absorb libcontext's wasm backend (doc 22)

Bumps wxwidgets (fiber lane in sched_context.h) and kicad (libcontext's wasm
backend becomes an adapter over it), so there is now ONE party performing
every emscripten_fiber_swap and recording who is on the CPU. Behaviour is
preserved throughout - libcontext still decides, the registry observes and
beacons any disagreement - which is the de-risking step doc 20's D2 never had.

Harness + spec gain three fiber-lane scenarios: a fresh fiber enters at its
entry and a swap suspends the swapper (fiber_roundtrip), releasing a suspended
fiber is legal while a stale id refuses instead of use-after-free
(fiber_release_suspended), and symmetric swaps leave a parked star context
undisturbed (fiber_and_star_coexist). The spec asserts the lane's counters,
including the tripwire that must stay zero: fiberNonEnterableSwaps.

Doc 22 gains the Phase A work log: the gate (kicad suite 139/1, the 1 being the
pre-existing occ-probe glb matrix; wx 346/1/3; batteries 48/48; zero tripwires
anywhere), the four bugs the tripwires caught and the single rule behind three
of them, the grace ring and the 33-coroutine measurement Phase B needs from it,
and the process traps this run paid for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EarUW9DS1c1sSW4ZNrkGQS
This commit is contained in:
Gergő Törcsvári 2026-08-06 20:19:42 +02:00
commit 586fbadfe9
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 322 additions and 3 deletions

View file

@ -212,7 +212,112 @@ a wx-only change ~20 min. Budget phases accordingly; batch experiments per build
Branch: `feature/async-mailbox`, unpushed across 6 repos. Baseline tags:
`mailbox-s0-baseline`, `d-1-pre-delete`. Tag before each phase's flip.
## 10. Open questions
## 10. Work log
### Phase A — DONE, gate met (2026-08-06) ✅
Final gate: full kicad suite **139 passed / 1 failed** — the failure is the known
pre-existing `occ-probe` `glb` format matrix, i.e. the baseline exactly. wx battery
346 passed / 1 failed (pre-existing `e2e/modal.spec.ts:125`) / 3 skipped; coroutine
trio + nested + races + asyncify 48/48; sched-context 2/2 including three new
fiber-lane scenarios. **Tripwire sweep over every suite log: zero
`sched-divergence-*`, zero `swap-lost`, zero `fiber_swap()` refusals, zero
`FIBER-SWAP-NONENTERABLE`, zero `FIBER-RELEASE-RUNNING`, zero
`jump-into-reclaimed`, zero `grace-ring-evict`.** The registry's view agreed with
libcontext's protocol on every swap in the suite — which is the whole claim Phase A
had to earn before Phase B moves the decision.
**Landed (working tree, uncommitted):**
- `sched_context.h` grew the **fiber lane**: `fiber_adopt_current` / `fiber_create`
(caller-owned C stack adopted, registry-owned asyncify buffer) / `fiber_swap` /
`fiber_enterable` / `fiber_release`, with its own counters so the D1 star memory
gate keeps meaning what it meant. Suspended-capture high-water is sampled
**before** the resume consumes it (afterwards it always reads 0) — that number is
Phase E's sizing input.
- `libcontext.cpp`'s wasm backend became the adapter: `wasm_fcontext` keeps only
protocol state (return_to, transfer_value, epochs, refcounts); the
`emscripten_fiber_t` + buffer live in the registry; every swap (jump and
trampoline return) goes through `pcbjam_sched::fiber_swap`. `swap_suspended`
stays AUTHORITATIVE for the parked-jump refusal and the registry only observes,
beaconing `sched-divergence-*` on disagreement (see bug 3).
- `thirdparty/libcontext/CMakeLists.txt` gained the wx include path under
EMSCRIPTEN. Harness + spec: 3 fiber-lane scenarios, fiber stats assertions.
**Four real bugs, every one caught by a tripwire rather than by guessing.** Each is
worth reading before Phase B, because three of them are the same mistake:
1. **Never infer the swap's `from` side.** The first cut derived it from the
lane's "current", which goes stale across a handleSleep park exactly like
`g_current_context` does — the wrong context got marked Suspended, the real
swapper stayed "Running" forever, and every later jump into it was wrongly
refused (a dispatch the legacy guard would have allowed). libcontext KNOWS who
is swapping out; `fiber_swap(from, to)` now mirrors its answer, keeping the
two layers in lockstep by construction.
2. **A release must always release.** libcontext's refcount drop deletes the
struct unconditionally; the registry refusing a "Running" release while the
caller frees anyway left a permanent ghost (plus a dangling
`g_current_context` — the garbage-id beacon) that poisoned every later
enterability answer. Now: release always, `FIBER-RELEASE-RUNNING` beacon.
3. **The registry must not OVERRULE the protocol, only record it.** Keying the
parked-jump refusal on `fiber_enterable()` refused a dispatch the legacy
backend permitted: Symbol Properties stopped opening (`quasimodal-strand`,
a fiber the registry still called Running while `swap_suspended` said validly
suspended). Phase A moves the BOOKKEEPING, not the decision. `swap_suspended`
is authoritative again; every disagreement is now a recorded fact instead of a
behaviour change, and those recordings are the evidence Phase B's flip gets
designed on.
4. **Absorbing the buffer un-hid a KiCad use-after-free.** `TOOL_MANAGER` keeps raw
`fcontext_t`s that outlive the `COROUTINE` owning them and jumps into them after
the last refcount is gone. This "worked" for years for a reason worth writing
down: the freed block was ~512 K (the asyncify buffer lived INSIDE the struct),
and malloc parks that size in a large bin, so freed stayed readable essentially
forever. Moving the buffer into the registry left a ~64-byte struct — recycled
on the next call — so `sched_id` read back as a freelist pointer (`0x16554B0`),
the swap was refused, and EVERY canvas tool wedged ("click never landed a
selection", "box-select never selected anything", 19 suite failures). The change
did not introduce the bug; it removed the size accident that concealed it.
**The rule all four share, and the one to carry into BE: the registry may only
record what the protocol actually did — it may not guess it, and it may not refuse
anything the legacy code permitted.** Phase A is behaviour-preserving or it is
nothing.
**The grace ring (bug 4's containment, and a Phase B input).** Released contexts are
kept, not freed: the struct is never deleted (protocol state only now — tens of
bytes, cheaper than the accident it replaces, and it makes a stale jump land on a
real `sched_id`), and the FIBER (512 K + registry entry) is bounded by a 32-entry
ring. Eviction takes only coroutines that actually FINISHED — evicting by age alone
dropped long-lived tool coroutine #1 that `TOOL_MANAGER` re-enters, and `m`-move
plus lock-resist failed. An evicted context keeps its struct with `sched_id` zeroed
so a later jump takes the ghost contract callers already handle.
**Measured, and it is the number Phase B needs:** `grace-ring-over-capacity: 33`
fired in 4 specs — i.e. 33 released coroutines were live with NONE evictable,
because none had finished. So KiCad really does hold 30+ never-finished coroutines
whose owners are gone, and the ring cannot reclaim them: worst case that is ~16 MB
of retained asyncify buffers, and it grows with the working set rather than being
capped by the ring. This is not a leak Phase A may fix (that would change
behaviour); it is exactly what Phase B removes by giving coroutines
scheduler-owned lifetimes, and it should be re-measured after that flip.
**Process traps paid for in this run:**
- Build against the **libs volume** (`COMPOSE_PROJECT_NAME=kicad-wasm-libs`). A bare
`docker/build.sh` creates a cold per-branch volume whose sysroot lacks glm and
dies at the gl1 shim.
- `npx playwright test` does NOT sync `output/*.wasm` into `tests/apps/kicad/`; only
`npm run test:kicad` (which runs `setup:kicad` first) does. A retest after a
rebuild that shows byte-identical beacons is testing the OLD binary — this cost a
full diagnosis cycle.
- A Docker VM at 100% disk fails builds with exit 137 and simultaneously crash-loops
the user's unrelated postgres containers (`could not write lock file`). Diagnose
and report; never prune or delete Docker state to clear it.
Baseline tags `phase-a-pre` on root/pcbjam/kicad/wxwidgets.
## 11. Open questions
1. **pthreads.** Doc 21 §2 settled that every Asyncify park is main-thread and the lib
bridge's worker path is a blocking proxy. Phase A must re-check that libcontext is never