Phase E retry: K1 lands — early-resolve retention, not the lossy guard; pthread-ondemand link fixed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2SU74acXyviwSxBhMunFe
This commit is contained in:
Gergő Törcsvári 2026-08-08 16:18:00 +02:00
commit 75209dc07e
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
6 changed files with 125 additions and 7 deletions

View file

@ -1 +1 @@
12 13

View file

@ -912,6 +912,80 @@ chooser, one lib request), not the full suite:
(`eeschema.spec.ts` or a chooser spec), not `npm run test:kicad` — this attempt cost a (`eeschema.spec.ts` or a chooser spec), not `npm run test:kicad` — this attempt cost a
1.2-hour suite run to learn one bit. The full suite is the confirmation, not the probe. 1.2-hour suite run to learn one bit. The full suite is the confirmation, not the probe.
### Phase E retry: K1 LANDED — the strand was a certainty, not a race (2026-08-08)
**Gate: full KiCad suite 139 passed / 1 failed (pre-existing occ-probe glb) / 30
skipped in 7.2 min — the Phase A baseline exactly**, with the K1 conversion IN. The
casualty class went first: `eeschema-ui` 3/3 in 17.7 s (attempt 1 hung it at boot for
3 min per test), then strand + sim + modal-stack 6/6. Zero `[wx-wait]` beacons fired.
**The diagnosis, from evidence attempt 1 left behind rather than a rebuild.** The
broken run's playwright report survived (`tests/playwright-report`, 111 expected / 26
unexpected) and all 26 failures are the same shape: `#canvas` never appeared — BOOT
hangs, not chooser hangs. A live probe of the bare test page then supplied the missing
fact: **test pages install no `kicadLibs` provider at all.** So on every spec page the
converted bridge resolved its wait synchronously (no-hook → `resolveWait(token, 0)`
inside the same wasm turn), `resolveWait` DELETED the entry, and the C++ then parked a
context whose wake had already been spent. Not a race — a certainty on every
provider-less page, which is why 26 specs died broadly instead of one chooser flow.
(Hypothesis 1 was falsified first: both exports are real export assignments in the
surviving glue, and they come from the wx side of the link, identical in both builds.)
**The §"first attempt" guard is LOSSY and was NOT landed — this landed instead.** The
4-line `waits.has` guard returns 0 for an already-resolved wait, dropping the result
(a malloc'd payload pointer for lib requests, a return code for modals). The
result-preserving shape, all three pieces required together:
1. **Retention (shim):** `resolveWait` keeps a resolved-but-unawaited non-context
entry in the map with `result` attached (counter `earlyWaitResolves`), instead of
deleting it. Context-parked and promise-awaited resolves behave exactly as before.
2. **Peek-and-consume (wx):** `wxWasmYieldUntil` checks `waitEarlyResolved(token)`
before parking a context and returns `takeWaitResult(token)` instead of parking;
`waitPromise` consumes a retained entry too — which also fixes a PRE-EXISTING data
loss (a synchronous C++ resolve before `wxWasmYieldUntilJs` used to return 0 with
an "unknown token" warning).
3. **Deferred resolution (bridge contract):** a converted bridge must never call
`resolveWait` synchronously from its start function — every path defers to at
least a microtask. `pcbjam_libs_request_start` wraps the provider in
`Promise.resolve(...)` so even a synchronous provider resolves on a microtask.
Beacons on the two previously-silent links stay in: a resolve for an unregistered
context-wait token, and a refused `mark_ready`, both warn with the token identity.
**Why attempt 1 "reproduced with the guard in place" could not be reproduced:** not
resolvable from surviving evidence; the equivalent-but-stronger mechanism gates green
today. The stale-sync trap (a retest against unsynced binaries) remains the likeliest
explanation. The retention design is landed regardless — it is strictly stronger.
**Honest coverage note:** the bare-page probe shows `waitsBegun == 0` at boot — K1's
main-thread path is THIN on test pages (the chooser path proxies from pthread
workers, untouched). The park-then-resolve half of the mechanism is exercised by
every modal at D-off (same `wxWasmYieldUntil` path); the async-provider half needs
the standalone/web smoke before Phase E is called done. For K2K7: the retention and
peek live in shared code — only the defer-the-resolve contract must be repeated per
bridge.
**A latent break the battery rerun un-hid (NOT this retry's doing):**
`coroutine-pthread-ondemand` aborted at `missing function:
_Z23pcbjam_context_sleep_msd` on both engines. The 8/7 context-sleep commit added a
bare `extern int pcbjam_context_sleep_ms(double)` to `wasm/shims/nanosleep_yield.c`;
the test-app Makefile compiles that file with `$(CXX)` and its comment ("em++ keys
language off the .c extension") is wrong — em++ compiles it as C++, so the
declaration MANGLES, and the standalone apps do not link `context_sleep.cpp` at all.
Broken since 8/7; unnoticed because the wx battery was never rerun after that commit
(the KiCad gate was). Fix in `nanosleep_yield.c`: the declaration is now
`extern "C"`-guarded AND `__attribute__((weak))` with a null check — an app without
the scheduler shim falls back to the in-place Asyncify yield, which is exactly the
behaviour those apps pin. Lesson for the phase log: **a wx-shim commit gated only on
the KiCad suite can silently break the standalone battery — run both nets when
`wasm/shims/` changes.**
**Battery after the fix: 388 passed / 8 failed / 3 skipped — the failure set is
EXACTLY the documented pre-existing D-off landing set** (nested case-3 ×3 tests ×2
engines, `wakeup_during_transition` layout-sensitive, `modal.spec.ts:125`), with
pthread-ondemand green on both engines. The shim's resolveWait retention change is
regression-free against every modal/nested/popup wait the battery stages.
1. **pthreads.** Doc 21 §2 settled that every Asyncify park is main-thread and the lib 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 bridge's worker path is a blocking proxy. Phase A must re-check that libcontext is never
driven from a worker before assuming the scheduler is main-thread-only. driven from a worker before assuming the scheduler is main-thread-only.

2
kicad

@ -1 +1 @@
Subproject commit c1ba4d3c3b086a4e0d9fdf04a4987dedef539662 Subproject commit ab1ac9b940596a97820eab3b8403f443db1e111a

View file

@ -187,11 +187,12 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
// _wxNestedLoopExit — deleted at doc 20 D-1). Resolution flows // _wxNestedLoopExit — deleted at doc 20 D-1). Resolution flows
// through the S2 deferred-wake law automatically: resolving a wait wakes // through the S2 deferred-wake law automatically: resolving a wait wakes
// its parked sleep via the wrapped handleSleep path. // its parked sleep via the wrapped handleSleep path.
waits: new Map(), // token → {kind, promise, resolve, resolved} waits: new Map(), // token → {kind, promise, resolve, resolved, result, awaited, contextParked}
waitSeq: 0, waitSeq: 0,
waitStacks: {}, // kind → [unresolved tokens], LIFO waitStacks: {}, // kind → [unresolved tokens], LIFO
waitsBegun: 0, waitsBegun: 0,
waitsResolved: 0, waitsResolved: 0,
earlyWaitResolves: 0, // resolves that landed before their waiter parked (Phase E)
beginWait: function (kind) { beginWait: function (kind) {
var token = ++this.waitSeq; var token = ++this.waitSeq;
var entry = { kind: kind, resolved: false, resolve: null, promise: null }; var entry = { kind: kind, resolved: false, resolve: null, promise: null };
@ -208,8 +209,30 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
console.warn("[wx-scheduler] waitPromise(" + token + "): unknown token"); console.warn("[wx-scheduler] waitPromise(" + token + "): unknown token");
return Promise.resolve(0); return Promise.resolve(0);
} }
if (entry.resolved) {
// Resolved before the waiter parked (Phase E early-resolve window).
// Consume the retained entry and hand the real result over — the old
// path warned "unknown token" and returned 0, dropping it.
this.waits.delete(token);
return Promise.resolve(entry.result | 0);
}
entry.awaited = true;
return entry.promise; return entry.promise;
}, },
// Phase E: a wait resolved before its C++ waiter reached the park keeps its
// entry (see resolveWait) so the result is not lost. wxWasmYieldUntil peeks
// before parking a context and consumes the result instead of parking a
// context nobody will ever resume.
waitEarlyResolved: function (token) {
var entry = this.waits.get(token);
return entry && entry.resolved ? 1 : 0;
},
takeWaitResult: function (token) {
var entry = this.waits.get(token);
if (!entry || !entry.resolved) return 0;
this.waits.delete(token);
return entry.result | 0;
},
// doc 22 Phase C: this token's waiter parked a SCHEDULER CONTEXT instead of // doc 22 Phase C: this token's waiter parked a SCHEDULER CONTEXT instead of
// suspending its stack in place, so there is no promise anyone awaits — // suspending its stack in place, so there is no promise anyone awaits —
// resolving one would strand the context forever. Marked from C++ at park // resolving one would strand the context forever. Marked from C++ at park
@ -228,12 +251,12 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
var idx = stack.indexOf(token); var idx = stack.indexOf(token);
if (idx !== -1) stack.splice(idx, 1); if (idx !== -1) stack.splice(idx, 1);
} }
this.waits.delete(token);
if (entry.contextParked) { if (entry.contextParked) {
// Mark ready only — never resume inline. The pump picks it up from a // Mark ready only — never resume inline. The pump picks it up from a
// fresh task, which is doc 13 §1.4's deferred-wake law applied to // fresh task, which is doc 13 §1.4's deferred-wake law applied to
// contexts (a rewind inside this resolver's own turn is the whole // contexts (a rewind inside this resolver's own turn is the whole
// class of bug the scheduler exists to remove). // class of bug the scheduler exists to remove).
this.waits.delete(token);
try { try {
Module["_wxWasmSchedResolveContextWait"](token, result | 0); Module["_wxWasmSchedResolveContextWait"](token, result | 0);
} catch (e) { } catch (e) {
@ -242,7 +265,18 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
this._armSchedPump(); this._armSchedPump();
return true; return true;
} }
entry.result = result | 0;
entry.resolve(result | 0); entry.resolve(result | 0);
if (entry.awaited) {
this.waits.delete(token);
} else {
// Nobody has parked on this token yet (Phase E early-resolve window:
// a bridge whose request settled before the C++ frame reached the
// park). Keep the entry, result attached — wxWasmYieldUntil or a late
// waitPromise consumes it. Deleting here is what stranded the first
// Phase E attempt: the later park waited on a wake nobody could send.
this.earlyWaitResolves++;
}
return true; return true;
}, },
// Resolve the INNERMOST unresolved wait of a kind (wx LIFO semantics). // Resolve the INNERMOST unresolved wait of a kind (wx LIFO semantics).

View file

@ -42,8 +42,18 @@ EM_ASYNC_JS( void, __wasm_main_thread_yield_ms, ( double ms ), {
* This is what makes TOOL_MANAGER::RunSynchronousAction's spin loop safe under * This is what makes TOOL_MANAGER::RunSynchronousAction's spin loop safe under
* Phase D: an in-place park inside a tool body leaves a capture in flight for a * Phase D: an in-place park inside a tool body leaves a capture in flight for a
* star transfer to land on (doRewind -> "index out of bounds"). * star transfer to land on (doRewind -> "index out of bounds").
*
* extern "C" + WEAK, both load-bearing: some app builds compile this file as
* C++ (em++ keys language off the DRIVER, not the .c extension a bare extern
* here mangles and the app aborts at "missing function" on first sleep), and
* the standalone wx test apps do not link context_sleep.cpp at all. A weak
* null resolves to "no context lane here — always yield in place", which is
* exactly the pre-context behaviour those apps pin.
*/ */
extern int pcbjam_context_sleep_ms( double ms ); #ifdef __cplusplus
extern "C"
#endif
int pcbjam_context_sleep_ms( double ms ) __attribute__(( weak ));
int nanosleep( const struct timespec* req, struct timespec* rem ) int nanosleep( const struct timespec* req, struct timespec* rem )
{ {
@ -52,7 +62,7 @@ int nanosleep( const struct timespec* req, struct timespec* rem )
double ms = (double) req->tv_sec * 1000.0 + (double) req->tv_nsec / 1.0e6; double ms = (double) req->tv_sec * 1000.0 + (double) req->tv_nsec / 1.0e6;
if( emscripten_is_main_runtime_thread() ) if( emscripten_is_main_runtime_thread() )
{ {
if( !pcbjam_context_sleep_ms( ms ) ) if( !pcbjam_context_sleep_ms || !pcbjam_context_sleep_ms( ms ) )
__wasm_main_thread_yield_ms( ms ); /* yield -> event loop runs -> Worker boots */ __wasm_main_thread_yield_ms( ms ); /* yield -> event loop runs -> Worker boots */
} }
else else

@ -1 +1 @@
Subproject commit 8f61561f061f1a38e98be5ac4b9c7b0fd2bbd234 Subproject commit 52608b79328a8aa577bdc587e8b474e6873060ee