Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions packages/oracles/oracle-gaps/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
38 changes: 35 additions & 3 deletions packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }
}

/**
Expand Down Expand Up @@ -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) })
Expand Down Expand Up @@ -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.
Expand Down
Loading