pcbjam/docs/features/async/19-quasimodal-fiber-strand.md
Gergő Törcsvári cc7c1d1f16
design-b D3: docs — doc 19 FIXED, and the plan re-scoped around that
Doc 19: status DIAGNOSED+PINNED RED -> FIXED, with the mechanism and the
explicit note that waits still park in place; the cure was to stop
parking on a COROUTINE stack, not to stop parking.

Doc 20: D3 work log (layering table, gate numbers) and a status that no
longer claims a phase queue. The bug that motivated the whole core
rewrite is closed WITHOUT contexts, so D2/D4/D5/D6 have lost their
forcing function and must be re-justified rather than continued by
default — including the honest option of deleting D1's unused context
layer.

Trap recorded: detection must capture the main stack's bounds at
top-level DoRun, not query them live — finishContextSwitch resets the
limits to the incoming fiber's, so a live query reports "main stack"
from everywhere and detects nothing.

Gate: kicad 139 passed / 1 failed (pre-existing occ-probe glb); wx +
asyncify + coroutine 394 passed / 1 failed (pre-existing
environment-sensitive modal:125). coroutine-nested — the battery D2
regressed — is green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEHGiiXMShNXbBr7gSJ7iz
2026-08-10 10:14:17 +02:00

120 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 19 — Symbol Properties dialog hangs: a stranded tool fiber (live investigation)
> **Status: FIXED (2026-08-06).** The nested (quasi-modal) event loop no longer parks on
> the tool coroutine's stack: `wxGUIEventLoop::DoRun` detects that its park would land on a
> non-main stack and bounces it onto the main stack via the host runner
> (`wx/wasm/private/mainstack.h` + `wasm/bindings/main_stack_runner.h` →
> `TOOL_MANAGER::RunOnMainStackIfActiveTool`). The coroutine is then suspended the
> legitimate way — a fiber swap the layer records — so §4's quarantine never applies and
> its resume is never refused. `tests/kicad/quasimodal-strand.spec.ts` is a plain green
> regression pin: 3/3 `closed=true dialogs=0 refused-resumes=0`.
>
> Note what this does NOT do: waits still park in place (§6 direction 3 — handler contexts —
> is untaken). The cure was to stop parking on a *coroutine* stack, not to stop parking.
>
> Original diagnosis follows.
>
> ~~**Status: DIAGNOSED + PINNED RED, NOT FIXED (2026-08-05).**~~ Reproduced end-to-end in a
> real browser against the dev platform on the scheduler build, and since D0 also
> **deterministically in e2e**: `tests/kicad/quasimodal-strand.spec.ts` (6/6 identical —
> `closed=false dialogs=1 refused-resumes=1`). The fix lands at doc 20 **D3**, where that
> spec's `test.fail()` marker comes off. Regression-vs-pre-existing is **not yet
> determined** — see §5. Related: [`17`](17-mailbox-scheduler-plan.md) S4 (waits),
> [`16`](16-fiber-resume-guard.md) (the quarantine guard), the 8/4 three-UI-bugs triage
> (which blamed the interlock drain — that is the *symptom layer*, not the proximate cause).
## 1. Repro
**Automated (D0, ~30 s, deterministic):**
`npx playwright test --project=kicad-firefox kicad/quasimodal-strand.spec.ts`. The
"staging" test proves the window is real (dialog open + timer fired + concurrent-park
beacons); the "doc-19 red" test clicks OK and records the hang. The overlap is
structural, not a race: the parking timer (`wasm/bindings/timer_park.h`) is armed
*after* the dialog is confirmed open, so it necessarily parks on top of the opener's
open-ended fiber park. Note the strand reproduces on a **2-object fixture schematic**
Leonardo's byte volume is not an ingredient, only concurrent parks are.
**Manual (100%, ~40 s):** dev platform (`npm run dev`), editor at `:3048`, Arduino
Leonardo schematic:
1. Open the project URL, wait for load (~30 s).
2. Double-click the USB receptacle (J1) → **Symbol Properties** opens.
3. Click OK / Cancel / any control → **nothing happens**. Only the titlebar × closes it.
## 2. Frozen state (captured live)
```
Fibers.__internallyParked = [607191040] // tool fiber, quarantined
Fibers.__parkSleepBuf = {607191040 → 607977472}
Asyncify.__pendingSleepContexts= [{buf 607977472, rootOwned:false, cleaned:false},
{buf 205586432, rootOwned:true, cleaned:false}]
Asyncify.currData = 205586432, state = 0
Fibers.__validSuspensions ∌ 607191040 // no resumable suspension
scheduler: mailbox=3 enqueued=655 delivered=652 // FROZEN (0 progress in 4 s)
waits=1 waitsBegun=1 waitsResolved=0 // the quasi-modal wait, unresolved
```
Console beacons, in order:
```
[wx-asyncify] overlapped-wake × 10 (benign: restore over null)
[wx-asyncify] aliased-wake-live: restoring currData=205586432 over 607977472
[wx-asyncify] fiber-resume-refused: fiber=607191040 is asyncify-parked mid-body (sleep in flight)
```
## 3. What is NOT the cause
- **Not clicks failing to reach wx.** The OK click produced exactly one
`wx_dom_event(domId 81, kind 1)` ccall; the button is enabled and hit-testable.
(Rules out the `WINDOW_DISABLER`/`IsEnabled()` hypothesis.)
- **Not slow I/O.** The last library network resource completed at t=5 s; the hang was
inspected at t=277 s with nothing in flight. The fiber's park is waiting on a promise
that will never settle — a **lost wake**, not pending work.
- **Not the mailbox/embind/wait bookkeeping.** `strayWrites=0`, `mutQ=0`, no deferred
wakes, no stranded messages beyond the 3 blocked by the interlock.
## 4. Mechanism
The tool fiber running the dialog parks mid-body (the quasi-modal wait). The stale-fiber
guard correctly marks it `__internallyParked` — its slice ended with a sleep in flight.
Its resume then arrives and is **refused** (`fiber-resume-refused`), because a quarantined
fiber has no valid suspension; the guard's contract is "the parked body completes via its
own wake." Here that wake *is* the refused resume, so nothing ever completes:
- the fiber never resumes → the dispatch guard it holds is never released →
- `wxWasmDispatchParked()` stays true forever → `ProcessEvents` is Paint-only and
`wxWasmMailboxDeliver` bails → **every** subsequent click is deferred and never drained,
and timer delivery stops (the frozen 652).
- The × works because `wx_window_close` (`toplevel.cpp`) is ungated and synchronous.
The 8/4 triage saw the *outer* ring of this (deferred clicks + a drain gated on the same
predicate) and proposed flushing the queue at depth 0 — that cannot help: the depth never
returns to 0 because the holder is stranded.
## 5. Open: regression or pre-existing?
Undetermined. The quarantine guard and the aliasing repair are pre-existing (ported
verbatim into the scheduler at S2), but S4 changed *how* a quasi-modal parks
(`wxWasmRunNestedLoop` pump → `wxWasmBeginWait`/`wxWasmYieldUntil`). Both shapes park the
fiber mid-body, so the quarantine interaction plausibly predates S4 — but that must be
proven, not assumed. **A hand-edited legacy glue is not a valid comparison** (attempted
2026-08-05: stripping the scheduler and re-injecting `handlesleep.js` into a C-lane build
booted to a fatal `TypeError: … reading 'mode'`). The differential needs a real
`WX_SCHEDULER=0` docker build of `kicad_editor`, same flow.
## 6. Fix directions (ranked, none implemented)
1. **Don't drop a refused resume — defer it.** When `__refuseFiber` fires for a fiber in
`__internallyParked`, record the pending resume and retry it when that fiber's park
resolves (`__parkSleepBuf` already maps fiber → its sleep buffer, and the sleep's
context leaving `__pendingSleepContexts` is the exact "now safe" signal). This is the
same drop→deliver flip the whole mailbox migration is built on, applied one layer down.
Caveat: doc 16 closed the *deferral family* for the root-hot case (a suspension broken
at write time). This is a different case — a healthy fiber quarantined mid-body — so the
closure does not automatically apply, but the round-6 evidence must be re-read first.
2. **Make a permanently-held interlock loud** (watchdog beacon after N seconds with a
parked holder). Diagnostic only, but it turns this class from "UI mysteriously dead"
into a one-line console verdict.
3. **Handler fibers** (the ledgered Design-B step): if parkable handlers own scheduler
contexts, "a parked chain holds the global interlock" stops being representable. This is
the structural cure and the S5 ledger's unlock for deleting the interlock entirely.