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:
parent
23c636dced
commit
586fbadfe9
5 changed files with 322 additions and 3 deletions
|
|
@ -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 B–E: 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
|
||||
|
|
|
|||
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit 2c777efedee85b5ee5cd8e46fbd10736cecc1729
|
||||
Subproject commit 73dc150bd459983ece0825fddc0c857c506b5199
|
||||
|
|
@ -523,6 +523,176 @@ void Scenario_ForeignStackRefused()
|
|||
report( "foreign_stack_refused", true );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FIBER LANE scenarios (doc 22 Phase A) — the symmetric semantics libcontext's
|
||||
// wasm backend adapts onto. These pin what the adapter relies on: a fresh
|
||||
// fiber enters at its entry, a swap suspends the swapper, enterability is
|
||||
// registry truth, releasing a suspended fiber is legal, a stale id refuses
|
||||
// instead of use-after-free, and the star lane is undisturbed throughout.
|
||||
// ---------------------------------------------------------------------------
|
||||
ContextId g_fiber_root = 0;
|
||||
|
||||
ContextId fiber_root()
|
||||
{
|
||||
// Adopted once for the whole battery, like libcontext's main context.
|
||||
if( !g_fiber_root )
|
||||
g_fiber_root = fiber_adopt_current( 64 * 1024, "test-root" );
|
||||
|
||||
return g_fiber_root;
|
||||
}
|
||||
|
||||
alignas( 16 ) char g_fiber_a_stack[64 * 1024];
|
||||
ContextId g_fiber_a = 0;
|
||||
int g_fiber_a_runs = 0;
|
||||
int g_fiber_a_saw_current = -1;
|
||||
int g_fiber_a_saw_root_enterable = -1;
|
||||
int g_fiber_a_saw_self_enterable = -1;
|
||||
|
||||
void fiber_a_entry( void* )
|
||||
{
|
||||
// Never returns, like libcontext's trampoline: run, swap back, repeat.
|
||||
for( ;; )
|
||||
{
|
||||
++g_fiber_a_runs;
|
||||
g_fiber_a_saw_current = ( fiber_current() == g_fiber_a ) ? 1 : 0;
|
||||
g_fiber_a_saw_root_enterable = fiber_enterable( g_fiber_root ) ? 1 : 0;
|
||||
g_fiber_a_saw_self_enterable = fiber_enterable( g_fiber_a ) ? 1 : 0;
|
||||
fiber_swap( g_fiber_a, g_fiber_root );
|
||||
}
|
||||
}
|
||||
|
||||
void Scenario_FiberRoundtrip()
|
||||
{
|
||||
if( !fiber_root() )
|
||||
return report( "fiber_roundtrip", false, "could not adopt the root" );
|
||||
|
||||
g_fiber_a_runs = 0;
|
||||
g_fiber_a = fiber_create( fiber_a_entry, nullptr, g_fiber_a_stack,
|
||||
sizeof( g_fiber_a_stack ), 32 * 1024, "fiber-a" );
|
||||
|
||||
if( !g_fiber_a )
|
||||
return report( "fiber_roundtrip", false, "fiber_create failed" );
|
||||
|
||||
if( status_of( g_fiber_a ) != Status::Fresh || !fiber_enterable( g_fiber_a ) )
|
||||
return report( "fiber_roundtrip", false, "a fresh fiber should be enterable" );
|
||||
|
||||
if( g_fiber_a_runs != 0 )
|
||||
return report( "fiber_roundtrip", false, "body ran before the first swap" );
|
||||
|
||||
if( !fiber_swap( g_fiber_root, g_fiber_a ) )
|
||||
return report( "fiber_roundtrip", false, "first swap refused" );
|
||||
|
||||
// Back here: the fiber ran once and swapped back to the root.
|
||||
if( g_fiber_a_runs != 1 )
|
||||
return report( "fiber_roundtrip", false,
|
||||
"runs=" + std::to_string( g_fiber_a_runs ) + " after first swap" );
|
||||
|
||||
if( fiber_current() != g_fiber_root )
|
||||
return report( "fiber_roundtrip", false, "root is not fiber_current() again" );
|
||||
|
||||
if( status_of( g_fiber_a ) != Status::Suspended || !fiber_enterable( g_fiber_a ) )
|
||||
return report( "fiber_roundtrip", false,
|
||||
std::string( "suspended fiber status: " )
|
||||
+ status_name( status_of( g_fiber_a ) ) );
|
||||
|
||||
// What the fiber observed mid-run: it was current, the suspended root was
|
||||
// enterable, and it itself (Running) was not — registry truth, no guessing.
|
||||
if( g_fiber_a_saw_current != 1 )
|
||||
return report( "fiber_roundtrip", false, "fiber did not see itself as current" );
|
||||
|
||||
if( g_fiber_a_saw_root_enterable != 1 )
|
||||
return report( "fiber_roundtrip", false, "suspended root was not enterable" );
|
||||
|
||||
if( g_fiber_a_saw_self_enterable != 0 )
|
||||
return report( "fiber_roundtrip", false, "a RUNNING fiber claimed to be enterable" );
|
||||
|
||||
// Re-entry takes the loop again — the second swap resumes, not restarts.
|
||||
if( !fiber_swap( g_fiber_root, g_fiber_a ) || g_fiber_a_runs != 2 )
|
||||
return report( "fiber_roundtrip", false, "second swap did not re-run the body" );
|
||||
|
||||
report( "fiber_roundtrip", true );
|
||||
}
|
||||
|
||||
void Scenario_FiberReleaseSuspended()
|
||||
{
|
||||
// Fiber A is Suspended after the roundtrip. Releasing it mid-suspend is
|
||||
// LEGAL — libcontext refcounts drop never-finished coroutines — and the
|
||||
// stale id must then refuse instead of resuming freed state.
|
||||
if( !g_fiber_a || status_of( g_fiber_a ) != Status::Suspended )
|
||||
return report( "fiber_release_suspended", false, "precondition: fiber A suspended" );
|
||||
|
||||
if( !fiber_release( g_fiber_a ) )
|
||||
return report( "fiber_release_suspended", false, "release refused" );
|
||||
|
||||
if( fiber_enterable( g_fiber_a ) )
|
||||
return report( "fiber_release_suspended", false, "released fiber still enterable" );
|
||||
|
||||
if( fiber_swap( g_fiber_root, g_fiber_a ) )
|
||||
return report( "fiber_release_suspended", false,
|
||||
"swap into a released fiber was allowed (use-after-free)" );
|
||||
|
||||
report( "fiber_release_suspended", true );
|
||||
}
|
||||
|
||||
alignas( 16 ) char g_fiber_b_stack[64 * 1024];
|
||||
ContextId g_fiber_b = 0;
|
||||
int g_fiber_b_runs = 0;
|
||||
int g_coexist_stage = 0;
|
||||
int g_coexist_result = 0;
|
||||
|
||||
void fiber_b_entry( void* )
|
||||
{
|
||||
for( ;; )
|
||||
{
|
||||
++g_fiber_b_runs;
|
||||
fiber_swap( g_fiber_b, g_fiber_root );
|
||||
}
|
||||
}
|
||||
|
||||
void body_coexist_park( void* )
|
||||
{
|
||||
g_coexist_stage = 1;
|
||||
g_coexist_result = yield_park( "coexist-park" );
|
||||
g_coexist_stage = 2;
|
||||
}
|
||||
|
||||
void Scenario_FiberAndStarCoexist()
|
||||
{
|
||||
// One registry, two lanes: a PARKED star context must sit undisturbed
|
||||
// while symmetric fiber swaps happen, and resume cleanly afterwards —
|
||||
// production Phase A runs tool fibers while D1's star layer idles.
|
||||
g_coexist_stage = 0;
|
||||
g_fiber_b_runs = 0;
|
||||
|
||||
const ContextId star = create( body_coexist_park, nullptr, "coexist-star" );
|
||||
drain(); // parks
|
||||
|
||||
if( g_coexist_stage != 1 || status_of( star ) != Status::Parked )
|
||||
return report( "fiber_and_star_coexist", false, "star context did not park" );
|
||||
|
||||
g_fiber_b = fiber_create( fiber_b_entry, nullptr, g_fiber_b_stack,
|
||||
sizeof( g_fiber_b_stack ), 32 * 1024, "fiber-b" );
|
||||
|
||||
if( !g_fiber_b || !fiber_swap( g_fiber_root, g_fiber_b ) || g_fiber_b_runs != 1 )
|
||||
return report( "fiber_and_star_coexist", false, "fiber roundtrip failed" );
|
||||
|
||||
if( status_of( star ) != Status::Parked )
|
||||
return report( "fiber_and_star_coexist", false,
|
||||
"fiber swaps disturbed the parked star context" );
|
||||
|
||||
if( !mark_ready( star, 7 ) )
|
||||
return report( "fiber_and_star_coexist", false, "mark_ready failed" );
|
||||
|
||||
drain();
|
||||
|
||||
if( g_coexist_stage != 2 || g_coexist_result != 7 )
|
||||
return report( "fiber_and_star_coexist", false, "star context did not resume" );
|
||||
|
||||
destroy( star );
|
||||
fiber_release( g_fiber_b );
|
||||
report( "fiber_and_star_coexist", true );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenario 10: a REAL async wake — JS timeout resolves the park
|
||||
// The park's wake crosses a JS turn, which is the shape every production
|
||||
|
|
@ -609,6 +779,9 @@ int main()
|
|||
Scenario_DeepPark();
|
||||
Scenario_FiberNestsInContext();
|
||||
Scenario_ForeignStackRefused();
|
||||
Scenario_FiberRoundtrip();
|
||||
Scenario_FiberReleaseSuspended();
|
||||
Scenario_FiberAndStarCoexist();
|
||||
|
||||
// Async last: it emits STATS + SUMMARY when its wake lands.
|
||||
Scenario_AsyncWake();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ const SCENARIOS = [
|
|||
'deep_park_sizing',
|
||||
'fiber_nests_in_context',
|
||||
'foreign_stack_refused',
|
||||
'fiber_roundtrip',
|
||||
'fiber_release_suspended',
|
||||
'fiber_and_star_coexist',
|
||||
'async_wake',
|
||||
];
|
||||
|
||||
|
|
@ -55,6 +58,21 @@ type Stats = {
|
|||
cStackBytes: number;
|
||||
asyncifyBytes: number;
|
||||
asyncifyHighWater: number;
|
||||
// Fiber lane (doc 22 Phase A) — libcontext's clients, separate counters so
|
||||
// this battery's star assertions keep meaning what they meant.
|
||||
fiberLive: number;
|
||||
fiberPeakLive: number;
|
||||
fiberCreated: number;
|
||||
fiberReleased: number;
|
||||
fiberSwaps: number;
|
||||
fiberRefusals: number;
|
||||
fiberReleasedSuspended: number;
|
||||
fiberReleasedRunning: number;
|
||||
fiberNonEnterableSwaps: number;
|
||||
fiberRunning: number;
|
||||
fiberBytes: number;
|
||||
fiberPeakBytes: number;
|
||||
fiberAsyncifyHighWater: number;
|
||||
};
|
||||
|
||||
function findLine(logs: string[], marker: string): string | undefined {
|
||||
|
|
@ -195,5 +213,28 @@ test.describe('Design B D1 — scheduler contexts', () => {
|
|||
// No buffer-pressure beacon should have fired (>75% use).
|
||||
const pressure = testLogger.consoleLogs.filter((l) => l.includes('BUFFER-PRESSURE'));
|
||||
expect(pressure, `buffer pressure: ${pressure.join(' || ')}`).toHaveLength(0);
|
||||
|
||||
// --- fiber lane (doc 22 Phase A): libcontext semantics over the registry --
|
||||
// The adopted root is the only fiber that outlives the battery (libcontext's
|
||||
// main context never dies); everything else was released.
|
||||
expect(stats.fiberLive, 'only the adopted root fiber remains').toBe(1);
|
||||
expect(stats.fiberRunning, 'the root is the fiber lane current').not.toBe(0);
|
||||
expect(stats.fiberCreated, 'root + fiber A + fiber B').toBe(3);
|
||||
expect(stats.fiberReleased, 'fibers A and B were released').toBe(2);
|
||||
expect(stats.fiberSwaps, 'symmetric swaps happened').toBeGreaterThanOrEqual(6);
|
||||
// Both releases happened mid-suspend — libcontext's refcount-drop shape.
|
||||
expect(stats.fiberReleasedSuspended, 'suspended releases are legal and counted').toBe(2);
|
||||
// One deliberate stale-id swap was refused (use-after-free made loud).
|
||||
expect(stats.fiberRefusals, 'a stale fiber id was refused').toBeGreaterThanOrEqual(1);
|
||||
// THE Phase A tripwires: no swap ever entered a non-enterable fiber, and
|
||||
// no release ever hit a fiber the registry believed was running.
|
||||
expect(stats.fiberNonEnterableSwaps, 'zero swaps into stale rewind state').toBe(0);
|
||||
expect(stats.fiberReleasedRunning, 'zero releases of a running fiber').toBe(0);
|
||||
// The sizing input Phase E reads: a suspended fiber's capture was measured
|
||||
// (sampled before the resume consumes it, when the buffer is non-empty).
|
||||
expect(
|
||||
stats.fiberAsyncifyHighWater,
|
||||
'fiber-lane asyncify use was measured while suspended',
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 3d37db3bf1cdffaaefe657047d7f6129f81fdb01
|
||||
Subproject commit 719fd98798a79301d7c0f89e085bf0a1a289d72d
|
||||
Loading…
Reference in a new issue