pcbjam/tests/asyncify/sched-context.spec.ts

249 lines
11 KiB
TypeScript
Raw Normal View History

design-b D1b/c: context harness + the bounded-and-measured memory gate Doc 20 §6 D1, second slice: the dedicated test app D1 calls for, plus the doc 20 §7 risk-1 gate. tests/apps/standalone/sched-context/ drives the primitives with no wx linked (a failure can only be the contexts layer) and assertions ON — unlike the races harness, an emscripten "cannot start an async operation" state here IS the bug, not a tolerated state. Nine scenarios, each pinning one invariant; the load-bearing ones: - parked_does_not_block: a parked context does not stop others running. This is doc 19's freeze made unrepresentable — there a parked activity held a global interlock and the UI died; here "parked" simply means "not runnable", and three workers run past it before it resumes. - one_transition_in_flight: a context calling drain() gets a no-op, with a second ready context queued so a buggy nested drain would actually run something and be caught. - async_wake: a real macrotask hop (setTimeout → mark_ready → drain), the shape every production bridge has. - deep_park_sizing: parks 64 live frames deep to measure what a park actually costs. The memory gate asserts the ceiling (peak live contexts, peak bytes), that nothing leaked (live=0, bytes=0, created==finished), that nothing was left mid-transition, and that refusals occurred (zero would mean the illegal-operation scenarios stopped provoking). Measured here: ~34 B/frame, 2200 B for a 64-frame park, 1 MB peak for 4 concurrent contexts. Recorded with the caveat that the harness's frames carry three locals each, so this is a FLOOR — real bridges save far more per frame (libcontext runs 512 K after a 64 K buffer silently overflowed). The apparatus and its units are validated; the sizing DECISION needs deep-park numbers from real bridges at D3/D4. playwright.config: asyncify-firefox now matches every spec in ./asyncify instead of only asyncify-races* — a new harness there is covered by construction rather than by remembering to widen the pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEHGiiXMShNXbBr7gSJ7iz
2026-08-05 21:41:24 +02:00
import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
/**
* Design B D1 gate scheduler contexts (docs/features/async/20 §6 D1, 21).
*
* Drives tests/apps/standalone/sched-context/sched_context_test.cpp, which
* exercises wasm/sched/context.{h,cpp}: create / yield_park / mark_ready /
* drain. The point of the layer is that a resume is something the REGISTRY
* knows rather than something a guard guesses (doc 19's disease), so the
* battery asserts the invariants, and this spec additionally gates the thing
* doc 20 §7 risk 1 demands: contexts must be bounded and MEASURED.
*
* No production path runs on contexts yet that starts at D2.
*/
const SCENARIOS = [
'create_runs_on_drain',
'park_and_resume',
'no_park_in_place',
'parked_does_not_block',
'fifo_order',
'one_transition_in_flight',
'registry_refusals',
'deep_park_sizing',
'fiber_nests_in_context',
'foreign_stack_refused',
'fiber_roundtrip',
'fiber_release_suspended',
'fiber_and_star_coexist',
'star_transfer_call_is_synchronous',
'star_transfer_chain',
design-b D1b/c: context harness + the bounded-and-measured memory gate Doc 20 §6 D1, second slice: the dedicated test app D1 calls for, plus the doc 20 §7 risk-1 gate. tests/apps/standalone/sched-context/ drives the primitives with no wx linked (a failure can only be the contexts layer) and assertions ON — unlike the races harness, an emscripten "cannot start an async operation" state here IS the bug, not a tolerated state. Nine scenarios, each pinning one invariant; the load-bearing ones: - parked_does_not_block: a parked context does not stop others running. This is doc 19's freeze made unrepresentable — there a parked activity held a global interlock and the UI died; here "parked" simply means "not runnable", and three workers run past it before it resumes. - one_transition_in_flight: a context calling drain() gets a no-op, with a second ready context queued so a buggy nested drain would actually run something and be caught. - async_wake: a real macrotask hop (setTimeout → mark_ready → drain), the shape every production bridge has. - deep_park_sizing: parks 64 live frames deep to measure what a park actually costs. The memory gate asserts the ceiling (peak live contexts, peak bytes), that nothing leaked (live=0, bytes=0, created==finished), that nothing was left mid-transition, and that refusals occurred (zero would mean the illegal-operation scenarios stopped provoking). Measured here: ~34 B/frame, 2200 B for a 64-frame park, 1 MB peak for 4 concurrent contexts. Recorded with the caveat that the harness's frames carry three locals each, so this is a FLOOR — real bridges save far more per frame (libcontext runs 512 K after a 64 K buffer silently overflowed). The apparatus and its units are validated; the sizing DECISION needs deep-park numbers from real bridges at D3/D4. playwright.config: asyncify-firefox now matches every spec in ./asyncify instead of only asyncify-races* — a new harness there is covered by construction rather than by remembering to widen the pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEHGiiXMShNXbBr7gSJ7iz
2026-08-05 21:41:24 +02:00
'async_wake',
];
/** Frame count the deep-park scenario recurses to before parking (must match
* DEEP_PARK_FRAMES in the harness) the divisor for the per-frame cost. */
const DEEP_PARK_FRAMES = 64;
/** Memory ceiling for the battery (doc 20 risk 1). The worst scenario holds
* ~6 live contexts; 16 leaves headroom without letting a leak hide. */
const MAX_PEAK_LIVE = 16;
/** Peak bytes the battery may charge to contexts. At 256 KB per context
* (128 KB C stack + 128 KB asyncify buffer) 16 contexts = 4 MB. */
const MAX_PEAK_BYTES = 4 * 1024 * 1024;
type Stats = {
live: number;
peakLive: number;
created: number;
finished: number;
transitions: number;
refusals: number;
foreignStackRefusals: number;
design-b D1b/c: context harness + the bounded-and-measured memory gate Doc 20 §6 D1, second slice: the dedicated test app D1 calls for, plus the doc 20 §7 risk-1 gate. tests/apps/standalone/sched-context/ drives the primitives with no wx linked (a failure can only be the contexts layer) and assertions ON — unlike the races harness, an emscripten "cannot start an async operation" state here IS the bug, not a tolerated state. Nine scenarios, each pinning one invariant; the load-bearing ones: - parked_does_not_block: a parked context does not stop others running. This is doc 19's freeze made unrepresentable — there a parked activity held a global interlock and the UI died; here "parked" simply means "not runnable", and three workers run past it before it resumes. - one_transition_in_flight: a context calling drain() gets a no-op, with a second ready context queued so a buggy nested drain would actually run something and be caught. - async_wake: a real macrotask hop (setTimeout → mark_ready → drain), the shape every production bridge has. - deep_park_sizing: parks 64 live frames deep to measure what a park actually costs. The memory gate asserts the ceiling (peak live contexts, peak bytes), that nothing leaked (live=0, bytes=0, created==finished), that nothing was left mid-transition, and that refusals occurred (zero would mean the illegal-operation scenarios stopped provoking). Measured here: ~34 B/frame, 2200 B for a 64-frame park, 1 MB peak for 4 concurrent contexts. Recorded with the caveat that the harness's frames carry three locals each, so this is a FLOOR — real bridges save far more per frame (libcontext runs 512 K after a 64 K buffer silently overflowed). The apparatus and its units are validated; the sizing DECISION needs deep-park numbers from real bridges at D3/D4. playwright.config: asyncify-firefox now matches every spec in ./asyncify instead of only asyncify-races* — a new harness there is covered by construction rather than by remembering to widen the pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEHGiiXMShNXbBr7gSJ7iz
2026-08-05 21:41:24 +02:00
running: number;
transitionInFlight: boolean;
readyQueued: number;
bytes: number;
peakBytes: number;
perContextBytes: number;
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;
design-b D1b/c: context harness + the bounded-and-measured memory gate Doc 20 §6 D1, second slice: the dedicated test app D1 calls for, plus the doc 20 §7 risk-1 gate. tests/apps/standalone/sched-context/ drives the primitives with no wx linked (a failure can only be the contexts layer) and assertions ON — unlike the races harness, an emscripten "cannot start an async operation" state here IS the bug, not a tolerated state. Nine scenarios, each pinning one invariant; the load-bearing ones: - parked_does_not_block: a parked context does not stop others running. This is doc 19's freeze made unrepresentable — there a parked activity held a global interlock and the UI died; here "parked" simply means "not runnable", and three workers run past it before it resumes. - one_transition_in_flight: a context calling drain() gets a no-op, with a second ready context queued so a buggy nested drain would actually run something and be caught. - async_wake: a real macrotask hop (setTimeout → mark_ready → drain), the shape every production bridge has. - deep_park_sizing: parks 64 live frames deep to measure what a park actually costs. The memory gate asserts the ceiling (peak live contexts, peak bytes), that nothing leaked (live=0, bytes=0, created==finished), that nothing was left mid-transition, and that refusals occurred (zero would mean the illegal-operation scenarios stopped provoking). Measured here: ~34 B/frame, 2200 B for a 64-frame park, 1 MB peak for 4 concurrent contexts. Recorded with the caveat that the harness's frames carry three locals each, so this is a FLOOR — real bridges save far more per frame (libcontext runs 512 K after a 64 K buffer silently overflowed). The apparatus and its units are validated; the sizing DECISION needs deep-park numbers from real bridges at D3/D4. playwright.config: asyncify-firefox now matches every spec in ./asyncify instead of only asyncify-races* — a new harness there is covered by construction rather than by remembering to widen the pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEHGiiXMShNXbBr7gSJ7iz
2026-08-05 21:41:24 +02:00
};
function findLine(logs: string[], marker: string): string | undefined {
return logs.find((l) => l.includes(`[SCHED_CTX] ${marker}`));
}
function parseTagged<T>(logs: string[], marker: string): T | null {
const line = findLine(logs, marker);
if (!line) return null;
const json = line.slice(line.indexOf(`[SCHED_CTX] ${marker}`) + `[SCHED_CTX] ${marker}`.length);
return JSON.parse(json.trim()) as T;
}
test.describe('Design B D1 — scheduler contexts', () => {
test('battery: every context invariant holds', async ({ page, testLogger }) => {
await page.goto('/standalone/sched-context/sched_context_test.html');
expect(await tryLoadApp(page, 30000), 'sched-context harness should load').toBe(true);
await expect
.poll(() => findLine(testLogger.consoleLogs, 'SUMMARY') ?? null, {
timeout: 30000,
message: 'battery should emit SUMMARY (a missing one means a wedge — e.g. a context that never resumed)',
})
.not.toBeNull();
const summary = findLine(testLogger.consoleLogs, 'SUMMARY')!;
const m = summary.match(/total=(\d+)\s+passed=(\d+)\s+failed=(\d+)/);
expect(m, `unparseable summary: ${summary}`).not.toBeNull();
const [total, passed, failed] = [Number(m![1]), Number(m![2]), Number(m![3])];
const fails = testLogger.consoleLogs.filter((l) => l.includes('[SCHED_CTX] FAIL '));
expect(fails, `failures: ${fails.join(' || ')}`).toHaveLength(0);
expect(failed).toBe(0);
expect(total, 'every scenario ran').toBe(SCENARIOS.length);
expect(passed).toBe(SCENARIOS.length);
for (const name of SCENARIOS) {
expect
.soft(
testLogger.consoleLogs.some((l) => l.includes(`[SCHED_CTX] PASS ${name}`)),
`scenario ${name} should PASS`,
)
.toBe(true);
}
});
test('memory: contexts are bounded and measured (doc 20 risk 1)', async ({
page,
testLogger,
}) => {
await page.goto('/standalone/sched-context/sched_context_test.html');
expect(await tryLoadApp(page, 30000)).toBe(true);
await expect
.poll(() => findLine(testLogger.consoleLogs, 'STATS') ?? null, { timeout: 30000 })
.not.toBeNull();
const stats = parseTagged<Stats>(testLogger.consoleLogs, 'STATS')!;
console.log(`[TEST] context stats: ${JSON.stringify(stats)}`);
// --- the ceiling: a leak must fail here, not in a user's tab ------------
expect(stats.peakLive, 'peak live contexts within the ceiling').toBeLessThanOrEqual(
MAX_PEAK_LIVE,
);
expect(stats.peakBytes, 'peak context bytes within the budget').toBeLessThanOrEqual(
MAX_PEAK_BYTES,
);
// --- no leaks: every context the battery created was destroyed ----------
expect(stats.live, 'no context left live after the battery').toBe(0);
expect(stats.bytes, 'no context bytes left charged').toBe(0);
expect(stats.finished, 'every created context finished').toBe(stats.created);
// --- the layer did real work (guards against a vacuous pass) -----------
expect(stats.created, 'the battery created contexts').toBeGreaterThan(5);
expect(stats.transitions, 'the scheduler performed swaps').toBeGreaterThan(
stats.created,
);
// --- the invariant: nothing left mid-transition ------------------------
expect(stats.transitionInFlight, 'no transition left in flight').toBe(false);
expect(stats.running, 'no context left running').toBe(0);
expect(stats.readyQueued, 'ready queue drained').toBe(0);
// --- refusals are EXPECTED here: three scenarios provoke them ----------
// (yield_park off a context, destroy while parked, mark_ready twice /
// unknown id). Zero would mean those scenarios stopped provoking.
expect(stats.refusals, 'illegal operations were refused and counted').toBeGreaterThan(0);
// --- the D3 blocker, pinned -------------------------------------------
// A wait called from a fiber running ON TOP of a context (a KiCad tool
// coroutine opening a dialog) must be refused, not allowed to yield
// someone else's context — that would save the tool fiber's stack into the
// host context's fiber struct. Exactly one scenario provokes it.
expect(
stats.foreignStackRefusals,
'a yield from a foreign stack was refused rather than corrupting the host context',
).toBe(1);
design-b D1b/c: context harness + the bounded-and-measured memory gate Doc 20 §6 D1, second slice: the dedicated test app D1 calls for, plus the doc 20 §7 risk-1 gate. tests/apps/standalone/sched-context/ drives the primitives with no wx linked (a failure can only be the contexts layer) and assertions ON — unlike the races harness, an emscripten "cannot start an async operation" state here IS the bug, not a tolerated state. Nine scenarios, each pinning one invariant; the load-bearing ones: - parked_does_not_block: a parked context does not stop others running. This is doc 19's freeze made unrepresentable — there a parked activity held a global interlock and the UI died; here "parked" simply means "not runnable", and three workers run past it before it resumes. - one_transition_in_flight: a context calling drain() gets a no-op, with a second ready context queued so a buggy nested drain would actually run something and be caught. - async_wake: a real macrotask hop (setTimeout → mark_ready → drain), the shape every production bridge has. - deep_park_sizing: parks 64 live frames deep to measure what a park actually costs. The memory gate asserts the ceiling (peak live contexts, peak bytes), that nothing leaked (live=0, bytes=0, created==finished), that nothing was left mid-transition, and that refusals occurred (zero would mean the illegal-operation scenarios stopped provoking). Measured here: ~34 B/frame, 2200 B for a 64-frame park, 1 MB peak for 4 concurrent contexts. Recorded with the caveat that the harness's frames carry three locals each, so this is a FLOOR — real bridges save far more per frame (libcontext runs 512 K after a 64 K buffer silently overflowed). The apparatus and its units are validated; the sizing DECISION needs deep-park numbers from real bridges at D3/D4. playwright.config: asyncify-firefox now matches every spec in ./asyncify instead of only asyncify-races* — a new harness there is covered by construction rather than by remembering to widen the pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEHGiiXMShNXbBr7gSJ7iz
2026-08-05 21:41:24 +02:00
// --- sizing evidence (doc 20 risk 1: derive, don't inherit) ------------
// The high-water mark is what a future buffer size must be justified by.
// Assert it is both real (>0 — the parks did save frames) and comfortably
// inside the buffer, so the number in the log is trustworthy.
expect(stats.asyncifyHighWater, 'asyncify use was measured').toBeGreaterThan(0);
expect(
stats.asyncifyHighWater,
'asyncify high-water inside the per-context buffer',
).toBeLessThan(stats.asyncifyBytes);
// Per-frame cost from the deep-park scenario: the number a future buffer
// size must be derived from. The high-water is dominated by that park
// (every other scenario parks 1-2 frames deep).
//
// CAVEAT, do not skip when quoting this number: the harness's frames carry
// three locals each, so this is a FLOOR for per-frame cost, not a
// production estimate. Real park sites (a lib fetch inside commit.Push →
// connectivity → font work) save far more per frame — that is why
// libcontext runs a 512 K buffer after a 64 K one silently overflowed.
// Treat this as "the measurement apparatus works and these are its units";
// the sizing decision needs deep-park numbers from real bridges at D3/D4.
const perFrame = stats.asyncifyHighWater / DEEP_PARK_FRAMES;
console.log(
`[TEST] asyncify sizing: high-water ${stats.asyncifyHighWater}B of ` +
`${stats.asyncifyBytes}B buffer ` +
`(${((stats.asyncifyHighWater / stats.asyncifyBytes) * 100).toFixed(1)}%); ` +
`~${perFrame.toFixed(0)}B per frame over ${DEEP_PARK_FRAMES} frames ` +
`→ the ${stats.asyncifyBytes}B buffer holds ~${Math.floor(
stats.asyncifyBytes / Math.max(perFrame, 1),
)} frames`,
);
// The deep park must actually dominate — otherwise the per-frame number
// above is noise from a shallow park and cannot justify any size.
expect(
stats.asyncifyHighWater,
'the deep park (64 frames) drove the high-water mark',
).toBeGreaterThan(1024);
// 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);
// Phase B invariant, stronger than the Phase A one it replaces: once the
// pump is quiescent NO fiber is on the CPU — the scheduler is. Under the
// star that is what "between transitions" means.
expect(stats.fiberRunning, 'no fiber is current when the pump is quiescent').toBe(0);
expect(stats.fiberCreated, 'every fiber the battery made').toBeGreaterThanOrEqual(8);
expect(stats.fiberReleased, 'all but the adopted root were released').toBe(
stats.fiberCreated - 1,
);
expect(stats.fiberSwaps, 'symmetric swaps and star transfers happened').toBeGreaterThanOrEqual(
12,
);
// 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);
design-b D1b/c: context harness + the bounded-and-measured memory gate Doc 20 §6 D1, second slice: the dedicated test app D1 calls for, plus the doc 20 §7 risk-1 gate. tests/apps/standalone/sched-context/ drives the primitives with no wx linked (a failure can only be the contexts layer) and assertions ON — unlike the races harness, an emscripten "cannot start an async operation" state here IS the bug, not a tolerated state. Nine scenarios, each pinning one invariant; the load-bearing ones: - parked_does_not_block: a parked context does not stop others running. This is doc 19's freeze made unrepresentable — there a parked activity held a global interlock and the UI died; here "parked" simply means "not runnable", and three workers run past it before it resumes. - one_transition_in_flight: a context calling drain() gets a no-op, with a second ready context queued so a buggy nested drain would actually run something and be caught. - async_wake: a real macrotask hop (setTimeout → mark_ready → drain), the shape every production bridge has. - deep_park_sizing: parks 64 live frames deep to measure what a park actually costs. The memory gate asserts the ceiling (peak live contexts, peak bytes), that nothing leaked (live=0, bytes=0, created==finished), that nothing was left mid-transition, and that refusals occurred (zero would mean the illegal-operation scenarios stopped provoking). Measured here: ~34 B/frame, 2200 B for a 64-frame park, 1 MB peak for 4 concurrent contexts. Recorded with the caveat that the harness's frames carry three locals each, so this is a FLOOR — real bridges save far more per frame (libcontext runs 512 K after a 64 K buffer silently overflowed). The apparatus and its units are validated; the sizing DECISION needs deep-park numbers from real bridges at D3/D4. playwright.config: asyncify-firefox now matches every spec in ./asyncify instead of only asyncify-races* — a new harness there is covered by construction rather than by remembering to widen the pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEHGiiXMShNXbBr7gSJ7iz
2026-08-05 21:41:24 +02:00
});
});