star_transfer_call_is_synchronous stages exactly the TOOL_MANAGER shape - Call() then read Running() on the next line - and pins that a parked caller resumed by the scheduler is indistinguishable from a synchronous return. star_transfer_chain pins a three-deep nested call (root -> A -> B and back) in strict order, with a parked star context proved undisturbed throughout. A rule learned the hard way here: a fiber entry must NEVER return (emscripten ends the program if it does), so these bodies park forever once their script is done, like libcontext's trampoline loops. Getting it wrong wedges the battery with no output at all. The fiberRunning assertion flips to the stronger Phase B invariant: once the pump is quiescent NO fiber is on the CPU, the scheduler is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EarUW9DS1c1sSW4ZNrkGQS
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
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',
|
|
'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;
|
|
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;
|
|
};
|
|
|
|
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);
|
|
|
|
// --- 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);
|
|
});
|
|
});
|