From 3924eb61967385fdbe65ebfbfca050ea2e8ed0b6 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 10:33:20 -0700 Subject: [PATCH 1/3] fix(oracle-gaps): retire a batch's copies from Vitest's state A check runs each mutant in a new copy of its test file. Vitest's state kept every file and test task it ever ran (filesMap by path, idMap by task id), so a check held every test of every copy: about 377k test cases on a four-file suite, which exhausted a 2 GB heap. Once a batch's units are plain data, its copies leave the state: their stale idMap entries go first (shape-guarded), then clearFiles replaces each path's file entry. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index a1139a1..502c17f 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1256,10 +1256,33 @@ export default async (env) => { } } finally { for (const copy of copies) globalThis.__oracleGapsCopies.delete(copy) + retireCopies(context, copies) rmSync(capture, { force: true }) } } + /** + * Drops a batch's copies from Vitest's state once their units are plain + * data. Each copy is a new file, and the state keeps every file and test + * task it ever ran: `filesMap` by path and `idMap` by task id, so a check + * holds every test of every copy (about 377k test cases in the four-file + * repro, which runs out of a 2 GB heap). `clearFiles` (Vitest 4.1 and 5.1 + * alike) replaces each path's file entry, but it leaves the copies' test + * tasks in `idMap`, so those go first. The map shapes are guarded: a + * Vitest that changes them throws here rather than leaking quietly. + */ + function retireCopies(context, copies) { + const state = context.state + if (!(state?.filesMap instanceof Map) || !(state?.idMap instanceof Map)) + throw new Error( + `oracle-gaps: this Vitest keeps no filesMap or idMap in its state, so a check cannot retire its copies`, + ) + const gone = new Set(copies) + for (const [id, task] of state.idMap) + if (gone.has(task.file?.filepath ?? task.filepath)) state.idMap.delete(id) + state.clearFiles(context.getRootProject(), copies) + } + async function check(options, store, log = console.error) { const authored = options.mutants ? loadAuthored(options.mutants) : [] seedVariable = `${options.envPrefix}_SEED` From 46dfd3e39ab21d64cfc9b3990b6940ad644204ec Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 11:31:59 -0700 Subject: [PATCH 2/3] test(oracle-gaps): bounded-retention law after each batch The gap CLI's e2e driver sets ORACLE_GAPS_TEST_RETENTION on each run and records one line per batch: the count of Vitest state entries (filesMap, pathsSet, idMap) and Vite module nodes that still name the batch's retired copies. The law: each count is 0 after retirement. This commit adds the hook and the law, not the fix, so CI records the RED on the parent code. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 32 ++++++++++++++++ .../gap-report-oracle.property.test.mjs | 38 +++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index 502c17f..0583929 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1281,6 +1281,38 @@ export default async (env) => { for (const [id, task] of state.idMap) if (gone.has(task.file?.filepath ?? task.filepath)) state.idMap.delete(id) state.clearFiles(context.getRootProject(), copies) + retiredEntries(context, copies) + } + + // The test hook (ORACLE_GAPS_TEST_RETENTION, a file path): after a batch's + // copies are retired, one line counts the state entries and module nodes + // that still name a retired copy. The e2e oracle's bounded-retention law + // reads it: each count is 0. + function retiredEntries(context, copies) { + const file = process.env.ORACLE_GAPS_TEST_RETENTION + if (!file) return + const gone = new Set(copies) + const named = (path) => gone.has(path) + const { filesMap, idMap, pathsSet } = context.state + let tasks = 0 + for (const task of idMap.values()) + if (named(task.file?.filepath ?? task.filepath)) tasks += 1 + let nodes = 0 + for (const environment of Object.values( + context.getRootProject().vite?.environments ?? {}, + )) + for (const copy of gone) + nodes += environment.moduleGraph?.fileToModulesMap.get(copy)?.size ?? 0 + appendFileSync( + file, + `${JSON.stringify({ + copies: copies.length, + filesMap: [...filesMap.keys()].filter(named).length, + pathsSet: [...pathsSet].filter(named).length, + idMap: tasks, + moduleNodes: nodes, + })}\n`, + ) } async function check(options, store, log = console.error) { 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 86cc8cc..5fe7342 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -647,13 +647,25 @@ function runCli(dir, files, how = {}) { writeFixture(dir, files) const before = new Set(filesUnder(dir)) const { args, out } = cliArgs(dir, files, how) + // The retention hook (ORACLE_GAPS_TEST_RETENTION): one line per batch, + // written beside the run's own temporary files, not in the package. + const hook = join( + mkdtempSync(join(tmpdir(), `gap-retention-`)), + `retention.jsonl`, + ) const child = spawnSync(process.execPath, args, { cwd: repo, encoding: `utf8`, timeout: 300_000, maxBuffer: 64 * 1024 * 1024, - env: cleanEnv(), + env: { ...cleanEnv(), ORACLE_GAPS_TEST_RETENTION: hook }, }) + const retention = existsSync(hook) + ? readFileSync(hook, `utf8`) + .split(`\n`) + .filter(Boolean) + .map((line) => JSON.parse(line)) + : [] const run = readRun( dir, out, @@ -667,7 +679,7 @@ function runCli(dir, files, how = {}) { .split(`\n`) .find((line) => line.startsWith(` replay: `)) ?.slice(` replay: `.length) - return { ...run, strayWrites: strays(dir, before), replay } + return { ...run, strayWrites: strays(dir, before), replay, retention } } /** @@ -1600,10 +1612,11 @@ async function driver(plan, observe) { cli: plan.cliPath === `spaced` ? copy : here, ...configArgs(plan), } - const { replay, ...cli } = runCli(dir, files, how) + const { replay, retention, ...cli } = runCli(dir, files, how) // How many units were computed or reused says how the result was // produced, not what it is, so it is not observed. observe(`cli`, { ...cli, evidence: undefined }) + observe(`retention`, retention) // A failing owner's replay, run as the author would run it (law 3). if (plan.fault === `brokenOwner`) observe(`replay`, { command: replay, ran: replayed(dir, replay) }) @@ -2170,6 +2183,25 @@ function check(plan, observations, expected) { ) const stream = streamBreach(`cli`, cli, expected, plan) if (stream) return stream + // Bounded retention (the register's C entry): once a batch's copies are + // retired, Vitest's filesMap, pathsSet, and idMap and Vite's module graph + // name none of them, so each count the hook reports is 0. + const retention = observations.find( + (o) => o.checkpoint === `retention`, + )?.value + const held = (retention ?? []).find( + (line) => line.filesMap + line.pathsSet + line.idMap + line.moduleNodes > 0, + ) + if (held) + return violation( + `retention`, + `a batch left its retired copies in Vitest or Vite state: ${JSON.stringify(held)}`, + `bounded retention`, + { + actual: held, + expected: { filesMap: 0, pathsSet: 0, idMap: 0, moduleNodes: 0 }, + }, + ) // Through the daemon, each check streams the same kinds of line, and a // completed one ends with its done line (law 15). The state of each step // differs, so its verdict and counts are judged by law 9's comparison. From 84a1085eb1505d4de5b2c62b1804a0798d0057a9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 12:04:57 -0700 Subject: [PATCH 3/3] fix(oracle-gaps): retire a copy's Vitest and Vite state, not only its file entry Vitest's clearFiles replaces a copy's file entry, and leaves its path in pathsSet, one placeholder task in idMap, and Vite's module node and transform for the copy. The retention law (bounded retention, after each batch) fails on 46dfd3e in CI: each count is nonzero. retireCopies now removes the copy's filesMap key, pathsSet entry, and idMap tasks. retireModules invalidates each copy's module through Vite's API and removes it from every environment's graph maps. Both guard the shapes they touch and throw if one changes, so a Vitest or Vite that changes them fails loudly rather than leaking. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 115 +++++++++++++++++++------ 1 file changed, 91 insertions(+), 24 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index 0583929..1a63f92 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1262,33 +1262,101 @@ export default async (env) => { } /** - * Drops a batch's copies from Vitest's state once their units are plain - * data. Each copy is a new file, and the state keeps every file and test - * task it ever ran: `filesMap` by path and `idMap` by task id, so a check - * holds every test of every copy (about 377k test cases in the four-file - * repro, which runs out of a 2 GB heap). `clearFiles` (Vitest 4.1 and 5.1 - * alike) replaces each path's file entry, but it leaves the copies' test - * tasks in `idMap`, so those go first. The map shapes are guarded: a - * Vitest that changes them throws here rather than leaking quietly. + * Removes a batch's copies from Vitest's state and from Vite's module + * graphs once their units are plain data (the register's C entry: the gap + * CLI keeps no Vitest or Vite state for a mutant copy after the copy's + * results are plain data). Each copy is a new file. Vitest keeps its path + * in `filesMap` and `pathsSet`, and the file and test tasks it ran in + * `idMap`, and Vite keeps a module node, with its transform, per copy. + * Vitest's `clearFiles` replaces the path's file entry only, and leaves + * `pathsSet` and the test tasks, so it is not used: each entry is removed. + * Every map touched is guarded, and a Vitest or Vite whose shape changes + * throws here rather than leaking quietly. During a run, Vitest holds the + * run's copies until it ends, so the in-run peak scales with the batch's + * share of copies, not with the suite. */ function retireCopies(context, copies) { - const state = context.state - if (!(state?.filesMap instanceof Map) || !(state?.idMap instanceof Map)) + const { filesMap, idMap, pathsSet } = context.state ?? {} + if ( + !(filesMap instanceof Map) || + !(idMap instanceof Map) || + !(pathsSet instanceof Set) + ) throw new Error( - `oracle-gaps: this Vitest keeps no filesMap or idMap in its state, so a check cannot retire its copies`, + `oracle-gaps: this Vitest keeps no filesMap, idMap, or pathsSet in its state, so a check cannot retire its copies`, ) const gone = new Set(copies) - for (const [id, task] of state.idMap) - if (gone.has(task.file?.filepath ?? task.filepath)) state.idMap.delete(id) - state.clearFiles(context.getRootProject(), copies) - retiredEntries(context, copies) + for (const [id, task] of idMap) + if (gone.has(task.file?.filepath ?? task.filepath)) idMap.delete(id) + for (const copy of copies) { + filesMap.delete(copy) + pathsSet.delete(copy) + } + const modulesRemoved = retireModules(context.getRootProject().vite, gone) + retiredEntries(context, copies, modulesRemoved) + } + + /** + * Removes the copies' module nodes from every environment's Vite graph. + * Each node is invalidated through Vite's API (which drops its transform), + * then removed from the graph's id, url, etag, and file maps, and from its + * neighbours' import sets. The graph's map shapes are guarded. + */ + function retireModules(server, gone) { + const environments = viteEnvironments(server) + let removed = 0 + for (const environment of environments) { + const graph = environment.moduleGraph + const { + urlToModuleMap, + idToModuleMap, + etagToModuleMap, + fileToModulesMap, + } = graph ?? {} + if ( + !(urlToModuleMap instanceof Map) || + !(idToModuleMap instanceof Map) || + !(etagToModuleMap instanceof Map) || + !(fileToModulesMap instanceof Map) || + typeof graph.invalidateModule !== `function` + ) + throw new Error( + `oracle-gaps: this Vite's module graph has no id, url, etag, or file maps, so a check cannot retire its copies`, + ) + for (const copy of gone) { + for (const mod of fileToModulesMap.get(copy) ?? []) { + removed += 1 + graph.invalidateModule(mod) + for (const imported of mod.importedModules) + imported.importers.delete(mod) + for (const importer of mod.importers) + importer.importedModules.delete(mod) + idToModuleMap.delete(mod.id) + urlToModuleMap.delete(mod.url) + if (mod.etag !== undefined) etagToModuleMap.delete(mod.etag) + } + fileToModulesMap.delete(copy) + } + } + return removed } - // The test hook (ORACLE_GAPS_TEST_RETENTION, a file path): after a batch's - // copies are retired, one line counts the state entries and module nodes - // that still name a retired copy. The e2e oracle's bounded-retention law - // reads it: each count is 0. - function retiredEntries(context, copies) { + // Vite's environments, each with its module graph. A Vite that exposes none + // would make the module step a silent no-op, so it throws here. + function viteEnvironments(server) { + const environments = Object.values(server?.environments ?? {}) + if (environments.length === 0 || environments.some((e) => !e?.moduleGraph)) + throw new Error( + `oracle-gaps: this Vitest exposes no Vite environments with module graphs, so a check cannot retire its copies' module nodes`, + ) + return environments + } + + // The test hook (ORACLE_GAPS_TEST_RETENTION, a file path): after each + // batch's copies are retired, one line counts the state entries and module + // nodes that still name a retired copy. The retention law reads it: each + // count is 0. + function retiredEntries(context, copies, modulesRemoved) { const file = process.env.ORACLE_GAPS_TEST_RETENTION if (!file) return const gone = new Set(copies) @@ -1298,11 +1366,9 @@ export default async (env) => { for (const task of idMap.values()) if (named(task.file?.filepath ?? task.filepath)) tasks += 1 let nodes = 0 - for (const environment of Object.values( - context.getRootProject().vite?.environments ?? {}, - )) + for (const environment of viteEnvironments(context.getRootProject().vite)) for (const copy of gone) - nodes += environment.moduleGraph?.fileToModulesMap.get(copy)?.size ?? 0 + nodes += environment.moduleGraph.fileToModulesMap.get(copy)?.size ?? 0 appendFileSync( file, `${JSON.stringify({ @@ -1311,6 +1377,7 @@ export default async (env) => { pathsSet: [...pathsSet].filter(named).length, idMap: tasks, moduleNodes: nodes, + modulesRemoved, })}\n`, ) }