From b109825990202b79ba699a61556f8493b587df5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Wed, 5 Aug 2026 19:28:19 +0200 Subject: [PATCH] =?UTF-8?q?plan:=20Design=20B=20core=20=E2=80=94=20parkabl?= =?UTF-8?q?e=20activities=20as=20scheduler=20contexts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc 20: the remaining core of Design B, scoped against what S0-S6 built and motivated by the doc-19 hang. Core rule: nothing parks in place — every suspendable activity yields its own context, so a context's state is authoritative and the guessing layer (quarantine, consume-once, libcontext refusals, dispatch interlock) gets deleted rather than tuned. Phases D0-D6 with gates, 6-10 wk; memory/partial-migration/ pthread risks called out; index updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TfxKn5utcntBSnxz4ZnYKs --- docs/features/async/20-design-b-core-plan.md | 158 +++++++++++++++++++ docs/features/async/README.md | 2 + 2 files changed, 160 insertions(+) create mode 100644 docs/features/async/20-design-b-core-plan.md diff --git a/docs/features/async/20-design-b-core-plan.md b/docs/features/async/20-design-b-core-plan.md new file mode 100644 index 0000000..1372640 --- /dev/null +++ b/docs/features/async/20-design-b-core-plan.md @@ -0,0 +1,158 @@ +# 20 — Design B core: parkable activities as scheduler contexts + +> **Status: PLAN (2026-08-05), not started.** The remaining core of Design B +> ([`06`](06-design-b-fiber-first-runtime.md) B1+B2), scoped against what S0–S6 actually +> built ([`17`](17-mailbox-scheduler-plan.md)) and motivated by the stranded-fiber hang +> ([`19`](19-quasimodal-fiber-strand.md)). Supersedes doc 12's phases 2–3 for this layer. + +## 1. The one-sentence goal + +**No activity may park "in place".** Every suspendable activity runs on a scheduler-owned +context (own stack + own asyncify buffer) and suspends by *yielding that context*, so a +context's state is always authoritative — never a fiber struct saying one thing while its +body sits in a `handleSleep` the fiber layer cannot see. + +That hidden second suspension is the disease. Everything else in this plan follows from +curing it. + +## 2. Why now — what the hang proved + +Doc 19: a tool fiber's body parked inside a wait; the stale-fiber guard quarantined it +(correctly, given the ambiguity), then **refused its legitimate resume** and dropped it. +The fiber never released the dispatch guard, so the interlock froze the whole UI. + +The guard is not the bug — the guard is the *cost* of the ambiguity. `__internallyParked`, +`__validSuspensions`, `__parkSleepBuf`, the libcontext `swap_suspended` refusals and the +ghost-resume epochs all exist to guess "is entering this fiber safe?". Under this plan the +question has a recorded answer, so the guessing layer is deleted rather than tuned. + +## 3. What S0–S6 already gives us (do not rebuild) + +| Asset | Status | +|---|---| +| Single writer of `currData`/`state` + N1 stray tripwire | done (S2) | +| Deferred wakes, drain from a clean macrotask | done (S2) | +| Wait registry: tokens, per-kind LIFO, resolve-before-yield safe | done (S4) — **API is right, implementation parks in place** | +| No awaited-ccall pumps anywhere (#13302 boundary gone) | done (S3) | +| Mailbox for browser stimuli (timers, wheel, mutating embind) | done (S1) | +| Teardown latch | done (S6) | +| Registry/`park`/`resume`/`drain` | **stubs that throw — this plan fills them** | +| Per-fiber 512 KB buffers (tool coroutines already are contexts, storage-wise) | done (pre-existing) | + +The edges are Design-B shaped; the core is not. `wxWasmYieldUntil` is still an +`EM_ASYNC_JS` park of whatever stack calls it — which is exactly how a tool fiber's body +ends up with an invisible suspension. + +## 4. Park-site inventory (the migration surface) + +**wx** (`src/wasm/`): `wxWasmYieldUntilJs` (the wait primitive — the one that matters), +`wxWasmYieldToBrowser` (main loop, per-frame, completes every frame), `wxWasmRunNestedLoop` ++ `startModal` + `wxDomPopupMenuModal` (legacy-only since S4), clipboard ×4 +(`clipbrd.cpp`), font enum (`fontenum.cpp`). + +**KiCad/bridges**: symbol lib (`sch_io_pcbjam_lib`), footprint lib (`pcb_io_pcbjam_fp`), +3D model fetch (`pcbjam_model_fetch`), STEP export / OCE / ngspice stubs +(`wasm/stubs/*`), `nanosleep` shim, and the embind park levers +(`open_gate` test park, `timer_park.h`, `fiber_park.h`). + +**Classification each site needs** (D0's deliverable): does it park on a *tool fiber*, on +a *fresh entry stack* (timer/DOM/embind), or on the *main loop*? Only the first two are +hazardous; the main loop's per-frame yield is safe by construction (S3/doc 13 §6c). + +## 5. Architecture + +``` +browser tick ─► scheduler.drain() + │ picks the next READY context; at most one transition in flight + ├─► dispatch context (runs wx handlers; ONE runnable at a time) + ├─► tool contexts (KiCad COROUTINEs — already fibers) + └─► wait/bridge contexts (a lib fetch owns the context it suspends) + +any activity that must wait: + token = beginWait(kind) → registry records the reason + yieldUntil(token) → SWAP the whole context out to the scheduler + (JS resolves) resolveWait() → mark READY; drain() resumes it by swapping in +``` + +Two consequences worth stating plainly: + +- **Mutual exclusion becomes a scheduler policy, not a global flag.** The dispatch + interlock exists to stop two handler chains interleaving over one widget tree. That + invariant survives — but expressed as "at most one *runnable* dispatch context; parked + ones do not count", which is precisely what today's ad-hoc `depth = 0` zero/restore + around modal parks emulates by hand. The scheduler knows *why* a context is parked, so a + parked opener no longer freezes delivery of everything else (doc 19's failure). +- **A refused resume becomes impossible**, because resume is not a guess: the registry says + the context is parked and holds its buffer. + +## 6. Phases (each gated; rollback = the previous tag) + +- **D0 · Park-site audit + red spec (3–4 d).** Classify every site from §4 into + on-fiber / on-entry-stack / main-loop, with the routing decision per site (doc 18's + method). Land the doc-19 hang as a deterministic **red** e2e spec (Symbol Properties → + click OK → dialog closes). Nothing else may proceed without that red. +- **D1 · Context primitives (1–2 wk).** Implement `park`/`resume`/`drain` for real on + libcontext: create context, swap-out to the scheduler, resume by swap-in, registry as + truth, one transition in flight. Exercised only by a dedicated test app — **no + production path switched yet**. Gate: new unit/app tests + full battery unchanged. +- **D2 · Dispatch contexts (1–2 wk).** The tick resumes a dispatch context instead of + running handlers on the entry stack; scheduler policy enforces one runnable dispatch + context. The interlock stays, demoted to a **tripwire** asserting it never disagrees with + the scheduler. Gate: full net + zero tripwire divergence across the fuzz suites. +- **D3 · Waits on contexts (1 wk).** `wxWasmYieldUntil` becomes a context yield. **The + doc-19 red spec goes green here** — modal/nested/popup no longer park a tool fiber's body. + Gate: red→green + battery + kicad suite. +- **D4 · Bridges on contexts (1–2 wk).** One helper (`wasm_await_promise`-style) routes + lib/3D/clipboard/font/nanosleep/stub parks through the same yield. After this **no + `handleSleep` park happens inside a fiber at all** — the quarantine guard is demoted to a + dev-build assertion. Gate: battery + kicad + warm-load N8. +- **D5 · Main loop as a context (≈1 wk, B2).** Main becomes a scheduler context; the + browser tick resumes the scheduler. Closes the 68/1 "swap inside main's own wake" class + *by construction* rather than by the v0.1.28 scheduling trick. Gate: N8 warm-load + pressure + `rootHotTotal == 0`. +- **D6 · Deletions (few d).** Per the S5 ledger, now unlocked: quarantine/consume-once, + libcontext refusals → dev asserts, dispatch interlock + zero/restore sites, timer retry, + open/fiber-busy/enumerate gates, and the legacy `WX_SCHEDULER=0` path with its C++ twins. + Gate: full net, scheduler-only, zero references to deleted diagnostics. + +**Effort: 6–10 weeks.** Doc 12's 4–6 week estimate predates what S1–S6 taught us; D2 and +D4 are each larger than any single step of the mailbox migration. + +## 7. Risks specific to this layer + +1. **Memory.** Every context costs a stack + a 512 KB asyncify buffer. The Leonardo load + already peaks ~633 MB. A dispatch-context pool plus tool contexts plus in-flight bridge + contexts must be *bounded and measured* — D1 must ship a context-count/peak-RSS gate, + and buffer sizes must be re-derived from `rem=` telemetry rather than inherited. +2. **Partial migration is worse than none** (doc 12). One park site left parking in place + inside a fiber keeps the ambiguity alive while the guards that covered it are being + removed. D0's audit is the contract; D4 must assert "no handleSleep on a fiber stack". +3. **Reentrancy becomes visible** (doc 06). Parked contexts observably share the world; + C++ that assumed "nothing changes while I block" gets exposed. Needs the N6-style + curated cases from doc 17 §3d extended to modal-open-while-mutating. +4. **pthreads.** Each thread has its own Asyncify state; the scheduler owns the main + thread's contexts only. The raytracer/PROXY_TO_PTHREAD interplay must be scoped + explicitly in D1, not discovered in D5. +5. **The #13302 boundary still binds.** Yields must never be driven from inside an awaited + export; S3's plain-call discipline carries forward unchanged. +6. **Doc 16's write-time closure.** Round 6 closed *deferral* for the root-hot case because + the suspension was broken when written. D5 must not re-introduce that shape: main's + context must never be swapped out inside its own wake continuation. + +## 8. Test fates (delta on doc 17 §3) + +- **Keep**: the whole outcome net (kicad ~60 specs, wx app battery, coroutine trio, races + semantics, N2/N5/N8). +- **Rewrite at D6**: anything asserting quarantine/refusal beacons + (`fiber-resume-park.spec.ts` flips from "refused" to "resumed after its park resolves"), + interlock diagnostics. +- **New**: doc-19 strand red spec (D0, green at D3); dispatch-context exclusion invariant + (D2); context-count/memory ceiling (D1); "no handleSleep on a fiber stack" assertion + test (D4). + +## 9. Decision this plan does not make + +Whether to keep the legacy runtime alive through D0–D5. Carrying it doubles the surface at +exactly the moment the core changes; dropping it early removes the fallback that made +S1–S6 safe. Recommendation: **keep it until D3's red goes green, then drop it in D4** — +that is the first point where the new core is proven on the failure that motivated it. diff --git a/docs/features/async/README.md b/docs/features/async/README.md index 159b1c6..034d169 100644 --- a/docs/features/async/README.md +++ b/docs/features/async/README.md @@ -44,6 +44,8 @@ or **hang** (a swap unwinds but is never rewound). | [`09-dom-window-lifetime-hypothesis.md`](09-dom-window-lifetime-hypothesis.md) | Concrete failure story and first fix experiment for the DOM-port stale `wxWindow` hypothesis: destructor ordering, DOM event reentry, and validation plan. | | [`10-resolution-menubar-uaf.md`](10-resolution-menubar-uaf.md) | **RESOLVED:** the regression was a freed `wxMenuBar` left in a live frame's child list by `wxMenuBarBase::Detach()` (DOM-port only — the bar is a real child there). One-line fix in `wxMenuBar::Detach()`; full kicad suite green, zero corruption signatures. | | [`11-asyncify-nesting-raytracer.md`](11-asyncify-nesting-raytracer.md) | **Finding + decision:** the WASM 3D raytracer is single-core because `emscripten_sleep` can't nest on an already-unwinding Asyncify context — yielding to join worker threads aborts with `invalid state: 1` since the viewer renders inside a suspended wx modal pump. Multi-core pool (~6–7×) built + parked; unpark needs a *nestable* yield (fiber/JSPI). | +| [`19-quasimodal-fiber-strand.md`](19-quasimodal-fiber-strand.md) | **DIAGNOSED 2026-08:** the Symbol Properties dialog hang — a tool fiber parks mid-body, the stale-fiber guard refuses (and drops) its resume, the dispatch interlock is never released, every click is deferred forever. Live-captured frozen state; ranked fixes; regression-vs-pre-existing still open. | +| [`20-design-b-core-plan.md`](20-design-b-core-plan.md) | **PLAN (2026-08):** the remaining Design B core — every parkable activity becomes a scheduler context that yields instead of parking in place, which deletes the guessing layer (quarantine/consume-once/interlock) rather than tuning it. Phases D0–D6, 6–10 wk, with the memory and partial-migration risks called out. | | [`17-mailbox-scheduler-plan.md`](17-mailbox-scheduler-plan.md) | **PLAN (2026-08):** the mailbox/scheduler implementation plan — Design B's phasing revised with the July–August guard record (dispatch interlock, open-settle gate, v0.1.28 schedule-don't-dispatch). Test inventory with per-test fate (keep / rewrite / retire / new), 7 steps S0–S6 with gates and rollback, ≈5–7 wk. Supersedes 12/13's phasing; overturns 13 §6f's "no scheduler needed". | ## The single decisive next step