From 2b11e2b556ebfe6142270550b6dde321ff52e87d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 08:15:00 -0700 Subject: [PATCH 1/5] test(oracle-gaps): log each CLI run's start, end, and kill-timeout in the e2e driver Test-side only: GAP_RUN_LOG names a file the driver appends one line to per CLI run (runCli and runClisAtOnce): its duration, status, signal, and whether the driver's 300 s kill timeout ended it. Measures where the daemon history's time goes on Windows (about 900 s sits outside any finished check). Diagnostic job daemon-history-runs prints the summary; remove before merge. Co-authored-by: Isaac --- .github/workflows/pr.yml | 38 +++++++++++++++++++ .../gap-report-oracle.property.test.mjs | 36 +++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8cbab1b..7ea12ca 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -288,6 +288,44 @@ jobs: env: SHARD: ${{ matrix.shard }} run: pnpm exec vitest --run --config oracle-gaps/vitest.config.mjs gap-report-oracle -t "$(node scripts/pr-shards.mjs pattern oracle-gaps-fixed-seed-windows "$SHARD")" + daemon-history-runs: + # Temporary diagnostic (the per-run log of the daemon history); remove before merge. + name: Daemon history run log (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + continue-on-error: true + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Setup Tools + uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 + - run: pnpm --filter @tanstack/oracles run build + - name: Daemon history, with its run log + shell: bash + working-directory: packages/oracles + env: + GAP_RUN_LOG: ${{ runner.temp }}/runs.jsonl + run: | + start=$(date +%s) + pnpm exec vitest --run --testTimeout=2700000 --reporter=verbose --config oracle-gaps/vitest.config.mjs gap-report-oracle -t 'fixed seed 20261105: an edit sequence through the daemon$' || true + echo "wall $(( $(date +%s) - start )) s" + node -e ' + const fs = require("fs") + const rows = fs.readFileSync(process.argv[1], "utf8").trim().split("\n").map((l) => JSON.parse(l)) + const total = rows.reduce((s, r) => s + r.ms, 0) + console.log("runs logged: " + rows.length + ", total ms " + total) + console.log("killed by the driver timeout: " + rows.filter((r) => r.killedByTimeout).length) + for (const r of rows.filter((r) => r.ms > 20000)) console.log("long run: " + JSON.stringify(r)) + const by = {} + for (const r of rows) { const k = r.via + " status " + r.status + " signal " + r.signal; by[k] = (by[k] ?? 0) + 1 } + console.log(JSON.stringify(by, null, 1)) + ' "$GAP_RUN_LOG" windows-gap-cli: name: Windows gap CLI on a fixture runs-on: windows-latest diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 7c98382..670a716 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -479,6 +479,7 @@ */ import { spawn, spawnSync } from 'node:child_process' import { + appendFileSync, cpSync, existsSync, mkdirSync, @@ -491,7 +492,7 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, relative, resolve } from 'node:path' +import { basename, dirname, join, relative, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import fc from 'fast-check' import { socketOf, stateDirOf } from './daemon.mjs' @@ -653,11 +654,20 @@ const configArgs = (plan) => ({ * Writes the fixture's files and runs the CLI on it. A file of an earlier * state that this state lacks is removed. */ +// Per-run log (GAP_RUN_LOG, a file the driver appends to): each CLI run's +// start and end, its status or signal, and whether the driver's kill timeout +// ended it. Test-side only; the CLI never sees it. +const runLog = process.env.GAP_RUN_LOG +const logRun = (entry) => { + if (runLog !== undefined) appendFileSync(runLog, `${JSON.stringify(entry)}\n`) +} + function runCli(dir, files, how = {}) { writeFixture(dir, files) const before = new Set(filesUnder(dir)) const { args, out } = cliArgs(dir, files, how) if (how.logBlocked) mkdirSync(join(out, `stream.log`), { recursive: true }) + const started = Date.now() const child = spawnSync(process.execPath, args, { cwd: repo, encoding: `utf8`, @@ -665,6 +675,15 @@ function runCli(dir, files, how = {}) { maxBuffer: 64 * 1024 * 1024, env: cleanEnv(), }) + logRun({ + via: `runCli`, + dir: basename(dir), + ms: Date.now() - started, + status: child.status, + signal: child.signal, + killedByTimeout: child.error?.code === `ETIMEDOUT`, + daemon: how.daemon === true, + }) const run = readRun( dir, out, @@ -1075,10 +1094,23 @@ async function runClisAtOnce(dir, files, hows) { child.stderr.on(`data`, (chunk) => { stderr += chunk }) + const started = Date.now() + let timedOut = false const status = await new Promise((resolvePromise) => { - const timer = setTimeout(() => child.kill(), 300_000) + const timer = setTimeout(() => { + timedOut = true + child.kill() + }, 300_000) child.on(`close`, (code) => { clearTimeout(timer) + logRun({ + via: `runClisAtOnce`, + dir: basename(dir), + ms: Date.now() - started, + status: code, + killedByTimeout: timedOut, + daemon: how.daemon === true, + }) resolvePromise(code) }) }) From 0b4297f4f87fb45f5ecdb65495efdb6979751226 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 08:55:26 -0700 Subject: [PATCH 2/5] fix(oracle-gaps): supervise the check on win32; remove the unsupervised path On win32 the check runs as a supervised child too: the child is not detached, the supervisor watches its exit and writes the done line, and a stop ends the child's tree with taskkill /T /F (signalTree). An out-of-memory abort or a crash on win32 therefore ends with one done error line on both streams and exit 3, as on POSIX (it wrote none before: a silent failure). The in-process unsupervised path (its stop line, its startLog, and the ORACLE_GAPS_TEST_STOP handler outside the supervisor) is removed, since every check is supervised now. The abort witness is the CLI's own hook, ORACLE_GAPS_TEST_ABORT_AT=check, which aborts the check's process after the session has begun (where a V8 out-of-memory abort would strike) instead of a test's kill in a worker thread: process.abort() on win32, a SIGABRT to itself on POSIX (process.abort prints a native backtrace, which is no line of the stream). The fixture's test that killed its own process is removed from the model. win32's done line names the exit code, since no signal can end a win32 process. Law: a check run ends with one done line on both streams and an exit code the supervisor names (register P10; #74). Reach: the 'every outcome class' history's abort run, on both platforms. RED: the test-side commit's Windows run (2b11e2b), where an abort gives no done line. GREEN (POSIX, local): 'every outcome class' and 'an edit sequence through the daemon' pass (fixed seed 20261105). Mutant: no supervisor on win32 (the abort run has no done line). Limits: taskkill /T reaches the tree only while the child lives (a grandchild orphaned earlier is not reached; a Job Object would be the fix); the win32 abort line names the exit code, not a signal. Co-authored-by: Isaac --- .../oracle-gaps/gap-report-model-oracle.mjs | 15 +--- .../gap-report-oracle.property.test.mjs | 16 ++-- packages/oracles/oracle-gaps/index.mjs | 90 ++++++++++--------- 3 files changed, 58 insertions(+), 63 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs index 1f83580..9e6188e 100644 --- a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs +++ b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs @@ -4257,8 +4257,7 @@ function baselineOf(plan) { // baseline too. const own = plan.tests.filter((t) => t.file === file).length + - (file === `a` && plan.consoleOutput ? 1 : 0) + - (file === `a` && plan.aborts ? 1 : 0) + (file === `a` && plan.consoleOutput ? 1 : 0) const broken = failing.find((f) => f.file === file) // The failing owner's suite holds two tests and a third of its name. const extra = broken?.test === undefined ? 0 : 3 @@ -4892,16 +4891,8 @@ function oracleTestFile(plan, file) { `})`, `test('fixed seed 999', () => {})`, ) - // A test that ends the run's process by SIGABRT when the driver asks - // (`GAP_ABORT`), as a V8 out-of-memory abort does: in worker threads its - // process is the run's, and `process.abort()` is unavailable there. It - // runs and passes in every other run. - if (file === `a` && plan.aborts) - out.push( - `test('aborts the process when asked', () => {`, - ` if (process.env.GAP_ABORT === '1') process.kill(process.pid, 'SIGABRT')`, - `})`, - ) + // The abort of an "aborts" run comes from the CLI's own check process + // (ORACLE_GAPS_TEST_ABORT_AT), so no test of file a is needed for it. // A file with no tests still loads, so the CLI can read what it owns. if ( !plan.tests.some((t) => t.file === file) && diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 670a716..9f8fa17 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -749,11 +749,10 @@ async function stoppedRun( const atSpawn = when === `spawn` const after = when === `after` const warm = when === `warm` - // With `abort`, no signal comes: a test of file a aborts the run's - // process (`GAP_ABORT`), in worker threads, so the abort ends the process - // that runs the check, as a V8 out-of-memory abort does. The run then - // ends with one error line per stream that names how its process ended, - // and exits 3 (the coordinator's decision of 2026-11-12 on P10). + // With `abort`, no signal comes: the check's process aborts in its main + // thread (ORACLE_GAPS_TEST_ABORT_AT, as a V8 out-of-memory abort does). The + // run then ends with one error line per stream that names how its process + // ended, and exits 3 (the coordinator's decision of 2026-11-12 on P10). const abort = when === `abort` writeFixture(dir, files) const before = new Set(filesUnder(dir)) @@ -784,7 +783,7 @@ async function stoppedRun( ORACLE_GAPS_TEST_WARM_PROBE: join(pids, `warming`), } : abort - ? { ...cleanEnv(), GAP_ABORT: `1` } + ? { ...cleanEnv(), ORACLE_GAPS_TEST_ABORT_AT: `check` } : { ...cleanEnv(), GAP_STOP_DIR: pids }), }, stdio: [`ignore`, `pipe`, `pipe`], @@ -2790,7 +2789,10 @@ function check(plan, observations, expected) { value.when === `after` ? `oracle-gaps: done completed` : value.when === `abort` - ? `oracle-gaps: done error, the run's process exited with SIGABRT` + ? process.platform === `win32` + ? // win32 has no signal a process can die of: the exit code names it. + `oracle-gaps: done error, the run's process exited with code ` + : `oracle-gaps: done error, the run's process exited with SIGABRT` : `oracle-gaps: done stopped (${value.signal})` if (value.spun && value.when === `warm` && value.refused) return violation( diff --git a/packages/oracles/oracle-gaps/index.mjs b/packages/oracles/oracle-gaps/index.mjs index 75828f6..c0b0c70 100755 --- a/packages/oracles/oracle-gaps/index.mjs +++ b/packages/oracles/oracle-gaps/index.mjs @@ -117,7 +117,7 @@ // Vitest worker joins, and every process a test starts unless it starts it // detached (in a session of its own, as a browser driver can), and waits // for it. Help, --stop-daemon, and arguments that refuse start no process, -// so they end in the CLI's own process, without a second Node start. Windows has no process groups, so there the run is not supervised. +// so they end in the CLI's own process, without a second Node start. On win32, which has no process groups, the supervisor ends the child's tree with taskkill. // SIGINT or SIGTERM is sent on to the whole group; a process that has not // ended 2 s later is killed, and so is any process left in the group when // the run ends. The run kills its group itself if the CLI dies first. @@ -125,7 +125,7 @@ // CLI's modules load (review of #57, P1): a signal during the loads is kept // and acted on once the stream log's note is written, so a stop as the CLI starts still // leaves the note and one done line. Only Node's own start is before them. -import { spawn } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { appendFileSync, existsSync, @@ -367,7 +367,7 @@ function superviseRun(out) { const finish = () => { if (finishing) return finishing = true - if (child) signalGroup(child.pid, `SIGKILL`) + if (child) signalTree(child.pid, `SIGKILL`) let decided = false const decide = () => { if (decided) return @@ -396,11 +396,13 @@ function superviseRun(out) { if (stopped) return stopped = signal if (child === undefined) return finish() - signalGroup(child.pid, signal) + // On win32 a stop after the child wrote its ending is ignored, as on POSIX, + // where the child ignores the signal itself. + if (!ending || process.platform !== `win32`) signalTree(child.pid, signal) const deadline = Date.now() + STOP_GRACE_MS const poll = setInterval(() => { if ( - (ended === undefined || signalGroup(child.pid, 0)) && + (ended === undefined || signalTree(child.pid, 0)) && Date.now() < deadline ) return @@ -432,7 +434,9 @@ function superviseRun(out) { out, ], { - detached: true, + // win32 has no process groups: the child is not detached there, and its + // tree is ended by signalTree. + detached: process.platform !== `win32`, stdio: [`inherit`, `inherit`, `pipe`, `ipc`], env: { ...process.env, ORACLE_GAPS_GROUP: String(process.pid) }, }, @@ -459,13 +463,27 @@ function superviseRun(out) { } /** Signals every process of group `group`; false when none is left. */ -function signalGroup(group, signal) { - try { - process.kill(-group, signal) - return true - } catch { - return false +/** + * Signals the process tree of `pid`: its process group on POSIX, where the + * run's workers are members. win32 has no groups and no signals a Node process + * can receive, so there the tree is ended with taskkill; signal 0 asks whether + * the process runs. + */ +function signalTree(pid, signal) { + if (process.platform !== `win32`) { + try { + process.kill(-pid, signal) + return true + } catch { + return false + } } + if (signal === 0) return alive(pid) + return ( + spawnSync(`taskkill`, [`/T`, `/F`, `/PID`, String(pid)], { + stdio: `ignore`, + }).status === 0 + ) } /** @@ -474,7 +492,7 @@ function signalGroup(group, signal) { */ function watchSupervisor(supervisor) { setInterval(() => { - if (process.ppid !== supervisor) process.kill(-process.pid, `SIGKILL`) + if (!alive(supervisor)) signalTree(process.pid, `SIGKILL`) }, 1000).unref() } @@ -497,6 +515,16 @@ async function checkCold(options) { const { openSession } = await import(`./check.mjs`) const { memoryStore } = await import(`./evidence.mjs`) const session = await openSession(options.pkg) + // A test-only abort (ORACLE_GAPS_TEST_ABORT_AT=check, inert unless set), after + // the session has begun, where a V8 out-of-memory abort would strike; the + // supervisor reports how the process ended. + if (process.env.ORACLE_GAPS_TEST_ABORT_AT === `check`) { + // process.abort() prints a native backtrace on POSIX, which is no line of + // the stream (law 15); a SIGABRT to itself ends the process the same way + // without it. win32 has no such signal, so there it is process.abort(). + if (process.platform === `win32`) process.abort() + else process.kill(process.pid, `SIGABRT`) + } try { return await session.check(options, memoryStore(), (line) => writeStream(`${line}\n`), @@ -541,25 +569,13 @@ function endWith(error, out) { // compared with the URL of the path it was started as. const main = import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href -// Windows has no process groups: there the run is the CLI's own process. -const supervised = process.platform !== `win32` const supervisor = process.env.ORACLE_GAPS_GROUP -// Set once the note is written (the unsupervised run has started). -let runStarted = false -/** Ends an unsupervised run with its stopped line on both streams (#58). */ -function stopNow(signal) { - const line = `oracle-gaps: done stopped (${signal})\n` - process.stdout.write(line) - writeStream(line) - exitWhenFlushed(128 + constants.signals[signal]) -} - async function run() { // The supervisor: it claims the output directory, writes the note, and runs // the child. A refusal of the claim or the log ends it through endWith (the // single path for every refusal of a check). - if (supervised && supervisor === undefined) { + if (supervisor === undefined) { const checking = asksForCheck(process.argv.slice(2)) if (checking !== undefined) { try { @@ -577,25 +593,16 @@ async function run() { // is no refusal, and the supervisor writes the stopped line. Once the run // writes its ending, a stop no longer cuts it off. // The all-or-nothing stop law (#57 and #58): a stop after the note writes one - // done stopped line on each stream; a supervised child's stop is the - // supervisor's line; a stop before the note waits for it (the early stop). + // done stopped line on each stream, and the child's stop is the supervisor's + // line. const onStop = (signal) => { if (writingEnding) return - if (!supervised) return runStarted && stopNow(signal) process.exit(128 + constants.signals[signal]) } process.on(`SIGINT`, onStop) process.on(`SIGTERM`, onStop) - // A test-only stop for unsupervised runs (ORACLE_GAPS_TEST_STOP, #58). - if (process.env.ORACLE_GAPS_TEST_STOP !== undefined && !supervised) { - const file = process.env.ORACLE_GAPS_TEST_STOP - setInterval(() => { - if (existsSync(file)) onStop(`SIGINT`) - }, 50).unref() - } - // A child of a supervisor that was stopped before its own handlers ran exits - // now (the supervisor writes the line). An unsupervised run keeps the stop - // and takes it once its note is written, below. + // A child that was stopped before its own handlers ran exits now (the + // supervisor writes the line). if (supervisor !== undefined && early !== undefined) process.exit(128 + constants.signals[early]) // The run's options, once its arguments parse. @@ -611,11 +618,6 @@ async function run() { const { stopDaemon } = await import(`./daemon.mjs`) await stopDaemon(options.pkg) } else { - // Windows has no supervisor: this process opens the log and runs the - // check. A child of a supervisor writes its stderr alone. - if (supervisor === undefined) startLog(options.out) - runStarted = true - if (early !== undefined) return stopNow(early) const summary = await (options.daemon ? checkThroughDaemon : checkCold)( options, ) From 9626f00c076a55e08a2487afadc03a71316182ea Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 09:46:30 -0700 Subject: [PATCH 3/5] fix(oracle-gaps): the stream log's note names it in forward slashes on win32 The first win32 run of the supervised check (run 43612786521) failed the greppable-stream law: the note named the log as out\stream.log, where the Windows paths rule spells it out/stream.log. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/index.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/oracles/oracle-gaps/index.mjs b/packages/oracles/oracle-gaps/index.mjs index c0b0c70..cb6b0c6 100755 --- a/packages/oracles/oracle-gaps/index.mjs +++ b/packages/oracles/oracle-gaps/index.mjs @@ -139,6 +139,7 @@ import { import { constants } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' +import { posix } from './paths.mjs' let early const keepEarly = (signal) => { @@ -322,7 +323,8 @@ function startLog(out) { throw cannotWrite(path, error) } streamLog = path - const note = `oracle-gaps: note log ${path}\n` + // The log is named in forward slashes on every platform (the Windows paths rule). + const note = `oracle-gaps: note log ${posix(path)}\n` process.stdout.write(note) writeStream(note) } From 678b643427fe096f4823d9e95a6e294b2afdc7e8 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 10:35:35 -0700 Subject: [PATCH 4/5] fix(oracle-gaps): the expected stream-log note names the output directory in forward slashes Co-authored-by: Isaac --- .../oracles/oracle-gaps/gap-report-oracle.property.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 9f8fa17..5b86d76 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -2911,7 +2911,9 @@ function streamBreach(checkpoint, run, expected, plan) { } /** The line that names a run's stream log. */ -const logNoteOf = (run) => `oracle-gaps: note log ${run.outDir}/stream.log` +// The note names the log in forward slashes (the Windows paths rule). +const logNoteOf = (run) => + `oracle-gaps: note log ${run.outDir.replaceAll(`\\`, `/`)}/stream.log` /** * What law 15 judges of every run, on both streams, whatever its outcome: From eecb8c03035ba0aebdf4fd5503a2a0b67f291a88 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 10:57:24 -0700 Subject: [PATCH 5/5] test(oracle-gaps): a stop on win32 goes through the test stop hook, observed as SIGINT The driver killed the CLI child directly on win32, where kill is TerminateProcess: the child is the supervisor, which then wrote no done line (RED: the warm stop in run 43612786521, 'stderr has 0 done lines and ends with the note'). A stop there now goes through ORACLE_GAPS_TEST_STOP, which the supervisor takes as a SIGINT, and the run is observed as SIGINT. The SIGTERM state is required on POSIX only: win32 has no SIGTERM a process can receive (its kill is TerminateProcess); the reason is in Limits. Scratch names: namedScratch names each path in every spelling the CLI can print it in, derived with realPathOf (the CLI's own rule for its paths), not a list of hand-written variants (the RED is the raw path in run 43617446527). GREEN (POSIX, local): 'every outcome class' and 'an edit sequence through the daemon' pass (fixed seed 20261105). Windows: pending. Co-authored-by: Isaac --- .../gap-report-oracle.property.test.mjs | 54 +++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 5b86d76..e5d36d0 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -497,6 +497,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import fc from 'fast-check' import { socketOf, stateDirOf } from './daemon.mjs' import { oracleProperty, pass, violation } from '../src/oracle-property.ts' +import { realPathOf } from './paths.mjs' import { SOURCE, @@ -757,6 +758,12 @@ async function stoppedRun( writeFixture(dir, files) const before = new Set(filesUnder(dir)) const pids = mkdtempSync(join(realpathSync(tmpdir()), `gap-stop-`)) + // win32 cannot deliver a signal to a Node process: kill ends the process at once, + // with no done line (the supervisor is the process killed). There the stop is + // the CLI's test stop hook (the file it names, as a SIGINT), so the run is + // observed as SIGINT. + const win = process.platform === `win32` + const stopFile = `${pids}-stop` const { args, out: given } = cliArgs(dir, files, { ...how, outName: `out-stop`, @@ -769,6 +776,7 @@ async function stoppedRun( const child = spawn(process.execPath, args, { cwd: repo, env: { + ...(win ? { ORACLE_GAPS_TEST_STOP: stopFile } : {}), ...(atSpawn ? { ORACLE_GAPS_TEST_STARTED: startedPath } : {}), // The constructed witness pauses after the marker, so the signal it sends // on seeing the marker lands inside the pause (review F2). @@ -845,7 +853,10 @@ async function stoppedRun( ) await sleep(1) else if (atSpawn) await sleep(delay) - if (!abort) child.kill(signal) + if (!abort) { + if (win) writeFileSync(stopFile, ``) + else child.kill(signal) + } const ended = await Promise.race([exit, sleep(30_000).then(() => false)]) await sleep(1000) const alive = readdirSync(pids) @@ -869,6 +880,7 @@ async function stoppedRun( } if (!ended) child.kill(`SIGKILL`) rmSync(pids, { recursive: true, force: true }) + rmSync(stopFile, { force: true }) const out = given ?? `${stdout}` @@ -881,7 +893,8 @@ async function stoppedRun( ...(given === undefined ? [[out, ``]] : []), ] const result = { - signal, + // The signal the CLI took: the test stop hook's SIGINT on win32. + signal: win ? `SIGINT` : signal, // Whether the CLI wrote its started marker before the signal (outcomes a and b). started: atSpawn ? existsSync(startedPath) : undefined, stdoutLength: stdout.length, @@ -1129,10 +1142,23 @@ async function runClisAtOnce(dir, files, hows) { // when it fails to load, so the id is named too. function namedScratch(text, names) { let named = text - const longestFirst = names + // Every spelling of each name, by the CLI's own rule (realPathOf, which the + // CLI names its paths with): the driver's path, and the native realpath in + // forward slashes. A path that is gone has only the driver's spelling. + const spellings = names .filter(([path]) => path !== undefined) - .sort(([a], [b]) => b.length - a.length) - for (const [path, name] of longestFirst) + .flatMap(([path, name]) => { + let real + try { + real = realPathOf(path) + } catch { + real = undefined + } + return [path, real] + .filter((spelling) => spelling !== undefined) + .map((spelling) => [spelling, name]) + }) + for (const [path, name] of spellings.sort(([a], [b]) => b.length - a.length)) named = named .replaceAll(pathToFileURL(path).href, name) .replaceAll(path, name) @@ -1975,7 +2001,7 @@ async function driver(plan, observe) { states.add(`a run whose process aborted`) continue } - states.add(`a run stopped by ${stop.signal} while a worker spun`) + states.add(`a run stopped by ${stopped.signal} while a worker spun`) states.add( `a stopped run whose workers are ${stop.pool === `forks` ? `child processes` : `threads`}`, ) @@ -3579,7 +3605,6 @@ const required = [ `a hang in one owner and a survivor in another timed out`, `confirmations past a mutant's budget timed out`, `a heartbeat named a busy mutant`, - `a run stopped by SIGTERM while a worker spun`, `a run stopped by SIGINT while a worker spun`, `a stop signal after the completed line`, `a stop signal while the config warms`, @@ -3733,7 +3758,16 @@ const separate = [ `edit lazy changed the cold result`, `a lazy edit changed the cold result after a batch was cut off`, ] -const witnessed = required.filter((s) => !separate.includes(s)) +// A stop by SIGTERM exists only where Node can deliver it: win32 has no SIGTERM a +// process can receive (its kill is TerminateProcess), so the stop law is judged +// there by SIGINT through the test stop hook. +const posixStops = + process.platform === `win32` + ? [] + : [`a run stopped by SIGTERM while a worker spun`] +const witnessed = [...required, ...posixStops].filter( + (s) => !separate.includes(s), +) // A constructed history builds from a generated base whose first function is // owned by file a (one owned function, not a whole file), so a random @@ -3764,7 +3798,7 @@ oracleProperty({ law: { statement: `The gap report enumerates the promised operator mutants, leaves out exactly the probe-only code, discovers every oracle that owns a function's call tree and refuses broken ownership, judges each mutant only by its owners' tests on their registered routes and counts only law verdicts on reproducible routes as kills, which outrank a run that never ends, anchors each mutant by its function and normalized statement, lists every gap and unowned mutant once with consistent counts, keeps keys stable under unrelated edits, reports through one daemon per package what a cold run reports at every state of a sequence of edits, and bounds the runs that confirm a mutant's cut-off copies by one budget per mutant and names each busy mutant in a heartbeat while it runs, leaves nothing in the package and writes nothing where Vitest's include could find it while it runs, leaves no process running once it is stopped, runs the package as Vitest does from any install path and any --source spelling: one instance of each workspace dependency, no refusal over an unbuildable devDependency, child processes for a native addon anywhere in the tree, a capture that a test's builtin spies cannot see, and a refusal of test.projects; leads its report with every test file's baseline, blocks only the mutants a failing oracle file owns and judges the rest, prints every line of its stream with the prefix oracle-gaps: and its kind, a first line on both streams that names the stream log in the output directory, which holds the stderr's lines in order, one decisive baseline line before any mutant, and a done line last, and every run that does not complete ends, cold or through the daemon, with a refusal that exits 2, prints a refused: block and one fix: directive with no stack trace, and writes the refusal to the run's output directory.`, authority: `The maintainer's decisions in design runs v11, v14, and v19 (2026-11-04 and 2026-11-05), not in this package's records, including that a kill by reach agreement alone is a gap and that an incremental run through the daemon equals a cold one (v19.3); the project's oracle guide on quiet exclusions; the oracle rubric's outcome kinds (ORC-006); the CLI's documented operator list in schemata.mjs; the maintainer's dispositions G1, G2, G4, G5, G6, and G13 of the framework papercut ledger (2026-11-11) and the v20 freeze's namespacing, one-loop, and provisional-report decisions (2026-11-10); the maintainer's request for a greppable stream (2026-11-12, relayed by the coordinator with its decisions of that day: the same prefixes on stdout and stderr, a done line for a stopped run, and no --max-gaps); the maintainer's decision of 2026-11-12 that the CLI writes its stream to the output directory's stream.log and names it in its first line.`, - limits: `Law 12 judges refusals only: no legal history reaches an internal error, so its shape (exit 3, the stack in error.log) is not judged; argument refusals are the static oracle's; the check's rejection of a stack trace, a missing fix, and a wrong exit code is calibrated in the static oracle, through the same refusalBreach, and here only a refused run reported as complete is calibrated, because each calibration here runs real CLI histories. Hangs only as an owner test that awaits a promise that never settles, under a 1 s first budget and a 20 s full limit, and a slow run only as two witnesses whose tests wait where the output changed, 3 s in one that then judges and 10 s in two one-test owners of one that does not; busy loops only on instrumented code (cut by the call budget), no slow runs past the full limit that would end, hangs in ordinary or nondeterministic tests, setup errors other than a throw at module load, loops, multi-line conditions, two functions on one line, or await, new, and void statements; nested functions, object-literal methods, accessors, and class-field arrows only where no test calls them (their ownership is the static oracle's); two source files of one package, with no re-exports, namespace imports, or calls into another package; --only only in a stopped run and a partial run of one function's mutants, and no --sample or --diff-base; the baseline's failures only as one failing owner test that throws an error and a setup file that throws, so a baseline law violation's law and counterexample lines are the classify oracle's; a derived class's file is instrumented and its sites are judged as unowned, but no test imports or constructs the class, so its no-mutant and super-region behavior is the static oracle's (in-process), not this oracle's; package configs only as vite.config or vitest.config with one of the extensions .ts, .mts, .cts, .js, .mjs, and .cjs (not every pair), as config/gap-vitest with the same extension through --config, or none, as an object or a sync or async function, with no plugins of their own and test.projects only as a refusal, and only under the package's own package.json; a top-level await only in an ES module config and require only in a CommonJS one, as Vitest itself loads no other; a --config outside the package is refused by design, through the rule for paths outside the package (args.mjs), and no history generates one; contexts only one, four, or eleven, and process warnings judged only as MaxListenersExceededWarning on the CLI's own stderr (a daemon's process warnings go to its log); a context that fails to open gives back its listener allowance, which no observation shows (a kept allowance only raises the limit, so no warning follows), so that accounting is fixed by inspection; no probe-bound states, so reach agreement's killing of probe mutants is not checked; no gap answers or gate; law 9 judges the witness module's outcome classes along one constructed edit sequence only (a test edit, a helper edit, and a revert), and the budget witness's along one constructed second check with nothing changed, because a generated history with the witness module or the budget witness takes no steps (maintainer decision, 2026-11-11, and the runtime review's P1-a, 2026-11-12; this oracle owns the gap); law 9's edits come only between checks, so a file saved during a check is not generated (the runner invalidates a known input that changed while a run ran and does not store that run, which only inspection fixes; a witness needs a step that edits a file between a unit's start and its end), and a module that a run first loads and that changes during that same run is stored under its later hash; law 9's edits leave out files read through fs, other environment variables, new files that change how an unchanged import resolves, Vite config and installed-package changes, and concurrent checks other than two that start the daemon; cut-off charging judged only under one budget; native addons only as a binding.gyp or a prebuilt .node file, not as optional platform packages; daemon races (two CLIs reading one error file, a CLI hearing closing from the failing daemon it started, a reset connection on macOS) fixed by inspection; the runner's copies of the test files exist only in memory, and its harness and Vitest configs sit in the package's .oracle-gaps-, which the next run removes once a killed run has left it (judged after two stopped runs, not after a SIGKILL); a default --out is judged by the static oracle, and these runs name --out in the package; the namespace is checked after each run and, in a stopped run, while the run runs; a slow kill in a later owner, after earlier owners' confirmations spent the budget, becomes a timeout (owner order; not generated); the Vitest 5 path of the copies' pragma read and of the runner is verified from Vitest 5's source only, since the Vitest 5 CI job runs no gap oracle; a batch whose run reports an error outside any test reruns each copy alone (unitsBatch, runAlone) with its first budget, and a copy cut there is kept as a timeout with no confirmation, a defect older than this oracle's budget work that no history reaches (no history has an error outside any test beside a slow copy), recorded here with that trace for the maintainer; stale-session removal is scoped to this host's session directories (a host file), and is not judged across pid namespaces; the stop law covers SIGTERM and SIGINT sent to the CLI, not a SIGKILL of the CLI (its group then ends at the next check of its parent, by inspection) or a signal sent to the daemon, and its witness is one test that spins synchronously and starts one child process; the mutant budget is witnessed as two slow owners whose confirmations pass the --mutant-limit floor, not through its multiple of the owners' unmutated time, and authored mutants have no confirmations to budget; the witness w_slowpair is a wall-clock window, since the CLI's budget has no controlled clock and a fixture cannot see the time the CLI measures for its file: with one-test owner files p and q of measured unmutated time t each (a file first in its context pays the instrumented transform too), a wait W of 10 s, a 20 s --hang-limit and a 15 s --mutant-limit, it tells a budget from none only while (a) its first attempt is cut, W > 1 s + 3t, (b) its two confirmations do not fit the budget, 2W > max(15 s, 10 x 2t), and (c) each would fit its own full limit, W < 20 s + 3t; (b) is the binding one, so it holds while the two files' time stays under about 2 s together (about 1 s each, some 20 times the authoring machine's idle and 5 to 10 times its loaded measurements), and under one budget a run's overhead must stay under 10 s + 3t; CI runners measure 1.5 to 2.3 times the idle machine; beyond that w_slowpair would read not-judged; a heartbeat is owed only where a slow test waits 3 s against a 2 s interval, twice the first budget, so that no copy cut at its first budget beats, and each beat is observed as its mutant and file, not how long or how often it beat; a config only in the package's parent directory is generated one level up, in a temporary directory with no package.json (so its .js or .ts name is CommonJS), as a plain config, one that throws, one with no default export, or one that defines test.projects; a config two or more levels up, or a parent config beside a package config, is not generated, and the order in one directory follows Vitest's configFiles list by inspection; a package with no config lives in a temporary directory, where no parent has a config; a counterexample shrinks through at most six histories, so it can be larger than the smallest that fails; module-level data only as four \`const\` declarations of the main source (an object literal, an array that holds a function, a boolean, and an object of two methods), read in place, with no data imported from another file, destructured, reassigned, or declared as an enum or a default export; the work law only through shortcut witnesses (an early return before a counting loop with a bound twin of ratio 2, and a push beside one that calls String with a bound twin of ratio 1), the early return in tests that reach the clock by each watched path and in tests that wait a turn, a generated test's bound of 1 derived from its plan (which no generated function's mutant reached in the 36 fixed-seed units), and one warm-cache witness whose selection reproduces observations but not work; work is the capture's count of site evaluations per test, so code with no site (a plain assignment, a for...of pass, a builtin's own work) counts nothing and a test's histories are summed, and a test whose work varies between the unmutated runs while its observations agree is not generated.`, + limits: `On win32 Node cannot deliver SIGTERM (its kill is TerminateProcess, which ends the process with no done line), so the stop states that name SIGTERM are required on POSIX only, and a stop there goes through the CLI's test stop hook and is observed as SIGINT; every SIGINT state stays required on win32. Law 12 judges refusals only: no legal history reaches an internal error, so its shape (exit 3, the stack in error.log) is not judged; argument refusals are the static oracle's; the check's rejection of a stack trace, a missing fix, and a wrong exit code is calibrated in the static oracle, through the same refusalBreach, and here only a refused run reported as complete is calibrated, because each calibration here runs real CLI histories. Hangs only as an owner test that awaits a promise that never settles, under a 1 s first budget and a 20 s full limit, and a slow run only as two witnesses whose tests wait where the output changed, 3 s in one that then judges and 10 s in two one-test owners of one that does not; busy loops only on instrumented code (cut by the call budget), no slow runs past the full limit that would end, hangs in ordinary or nondeterministic tests, setup errors other than a throw at module load, loops, multi-line conditions, two functions on one line, or await, new, and void statements; nested functions, object-literal methods, accessors, and class-field arrows only where no test calls them (their ownership is the static oracle's); two source files of one package, with no re-exports, namespace imports, or calls into another package; --only only in a stopped run and a partial run of one function's mutants, and no --sample or --diff-base; the baseline's failures only as one failing owner test that throws an error and a setup file that throws, so a baseline law violation's law and counterexample lines are the classify oracle's; a derived class's file is instrumented and its sites are judged as unowned, but no test imports or constructs the class, so its no-mutant and super-region behavior is the static oracle's (in-process), not this oracle's; package configs only as vite.config or vitest.config with one of the extensions .ts, .mts, .cts, .js, .mjs, and .cjs (not every pair), as config/gap-vitest with the same extension through --config, or none, as an object or a sync or async function, with no plugins of their own and test.projects only as a refusal, and only under the package's own package.json; a top-level await only in an ES module config and require only in a CommonJS one, as Vitest itself loads no other; a --config outside the package is refused by design, through the rule for paths outside the package (args.mjs), and no history generates one; contexts only one, four, or eleven, and process warnings judged only as MaxListenersExceededWarning on the CLI's own stderr (a daemon's process warnings go to its log); a context that fails to open gives back its listener allowance, which no observation shows (a kept allowance only raises the limit, so no warning follows), so that accounting is fixed by inspection; no probe-bound states, so reach agreement's killing of probe mutants is not checked; no gap answers or gate; law 9 judges the witness module's outcome classes along one constructed edit sequence only (a test edit, a helper edit, and a revert), and the budget witness's along one constructed second check with nothing changed, because a generated history with the witness module or the budget witness takes no steps (maintainer decision, 2026-11-11, and the runtime review's P1-a, 2026-11-12; this oracle owns the gap); law 9's edits come only between checks, so a file saved during a check is not generated (the runner invalidates a known input that changed while a run ran and does not store that run, which only inspection fixes; a witness needs a step that edits a file between a unit's start and its end), and a module that a run first loads and that changes during that same run is stored under its later hash; law 9's edits leave out files read through fs, other environment variables, new files that change how an unchanged import resolves, Vite config and installed-package changes, and concurrent checks other than two that start the daemon; cut-off charging judged only under one budget; native addons only as a binding.gyp or a prebuilt .node file, not as optional platform packages; daemon races (two CLIs reading one error file, a CLI hearing closing from the failing daemon it started, a reset connection on macOS) fixed by inspection; the runner's copies of the test files exist only in memory, and its harness and Vitest configs sit in the package's .oracle-gaps-, which the next run removes once a killed run has left it (judged after two stopped runs, not after a SIGKILL); a default --out is judged by the static oracle, and these runs name --out in the package; the namespace is checked after each run and, in a stopped run, while the run runs; a slow kill in a later owner, after earlier owners' confirmations spent the budget, becomes a timeout (owner order; not generated); the Vitest 5 path of the copies' pragma read and of the runner is verified from Vitest 5's source only, since the Vitest 5 CI job runs no gap oracle; a batch whose run reports an error outside any test reruns each copy alone (unitsBatch, runAlone) with its first budget, and a copy cut there is kept as a timeout with no confirmation, a defect older than this oracle's budget work that no history reaches (no history has an error outside any test beside a slow copy), recorded here with that trace for the maintainer; stale-session removal is scoped to this host's session directories (a host file), and is not judged across pid namespaces; the stop law covers SIGTERM and SIGINT sent to the CLI, not a SIGKILL of the CLI (its group then ends at the next check of its parent, by inspection) or a signal sent to the daemon, and its witness is one test that spins synchronously and starts one child process; the mutant budget is witnessed as two slow owners whose confirmations pass the --mutant-limit floor, not through its multiple of the owners' unmutated time, and authored mutants have no confirmations to budget; the witness w_slowpair is a wall-clock window, since the CLI's budget has no controlled clock and a fixture cannot see the time the CLI measures for its file: with one-test owner files p and q of measured unmutated time t each (a file first in its context pays the instrumented transform too), a wait W of 10 s, a 20 s --hang-limit and a 15 s --mutant-limit, it tells a budget from none only while (a) its first attempt is cut, W > 1 s + 3t, (b) its two confirmations do not fit the budget, 2W > max(15 s, 10 x 2t), and (c) each would fit its own full limit, W < 20 s + 3t; (b) is the binding one, so it holds while the two files' time stays under about 2 s together (about 1 s each, some 20 times the authoring machine's idle and 5 to 10 times its loaded measurements), and under one budget a run's overhead must stay under 10 s + 3t; CI runners measure 1.5 to 2.3 times the idle machine; beyond that w_slowpair would read not-judged; a heartbeat is owed only where a slow test waits 3 s against a 2 s interval, twice the first budget, so that no copy cut at its first budget beats, and each beat is observed as its mutant and file, not how long or how often it beat; a config only in the package's parent directory is generated one level up, in a temporary directory with no package.json (so its .js or .ts name is CommonJS), as a plain config, one that throws, one with no default export, or one that defines test.projects; a config two or more levels up, or a parent config beside a package config, is not generated, and the order in one directory follows Vitest's configFiles list by inspection; a package with no config lives in a temporary directory, where no parent has a config; a counterexample shrinks through at most six histories, so it can be larger than the smallest that fails; module-level data only as four \`const\` declarations of the main source (an object literal, an array that holds a function, a boolean, and an object of two methods), read in place, with no data imported from another file, destructured, reassigned, or declared as an enum or a default export; the work law only through shortcut witnesses (an early return before a counting loop with a bound twin of ratio 2, and a push beside one that calls String with a bound twin of ratio 1), the early return in tests that reach the clock by each watched path and in tests that wait a turn, a generated test's bound of 1 derived from its plan (which no generated function's mutant reached in the 36 fixed-seed units), and one warm-cache witness whose selection reproduces observations but not work; work is the capture's count of site evaluations per test, so code with no site (a plain assignment, a for...of pass, a builtin's own work) counts nothing and a test's histories are summed, and a test whose work varies between the unmutated runs while its observations agree is not generated.`, }, histories: { // A failing history shrinks through at most six smaller ones. Each runs @@ -3774,7 +3808,7 @@ oracleProperty({ // verdict its first failure holds. The counterexample is the smallest // of those seven histories, its law the first failure's. arbitrary: fc.limitShrink(histories, 6), - states: required, + states: [...required, ...posixStops], measured: [ ...staticStates, `extra work in a generated function`,