diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index a1139a1..1a63f92 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1256,10 +1256,132 @@ export default async (env) => { } } finally { for (const copy of copies) globalThis.__oracleGapsCopies.delete(copy) + retireCopies(context, copies) rmSync(capture, { force: true }) } } + /** + * 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 { 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, idMap, or pathsSet in its state, so a check cannot retire its copies`, + ) + const gone = new Set(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 + } + + // 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) + 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 viteEnvironments(context.getRootProject().vite)) + 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, + modulesRemoved, + })}\n`, + ) + } + async function check(options, store, log = console.error) { const authored = options.mutants ? loadAuthored(options.mutants) : [] seedVariable = `${options.envPrefix}_SEED` 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.