diff --git a/src/execution-world/candidate.ts b/src/execution-world/candidate.ts index fd3def5..ec6fbe7 100644 --- a/src/execution-world/candidate.ts +++ b/src/execution-world/candidate.ts @@ -36,33 +36,37 @@ export async function loadCheckedInstallationCandidate( if (!rootPattern.test(root) || !(await lstat(root)).isDirectory()) throw Error("Candidate must be an immutable Nix output directory"); const scope = (resources.budget ?? candidateMetadataBudget).acquire(resources.owner ?? root); - let releaseValidation = () => {}; try { + // Validate the duplicated source contract by digest, then discard its tree + // before allocating the substantially larger checked execution plan. + const workspaceDigest = await (async () => { + const input = await readJson(root, "candidate.json", scope); + try { + const { executionContracts: _legacyContracts, ...workspace } = input.value; + return hexDigest(workspace); + } finally { + input.releaseTree(); + } + })(); // Sequential reads avoid partially rejected Promise.all() work allocating // after the failed candidate's reservation has been released. const values = []; for (const file of [ - "candidate.json", "execution-plan.json", "execution-artifacts.json", "source.json", "entry.json", "kernel.json", "queries.json", - "graph-execution.json", ]) { const parsed = await readJson(root, file, scope); values.push(parsed.value); - // candidate.json duplicates the checked plan for validation. Nothing in - // the installed candidate retains this input tree after validation. - if (file === "candidate.json") releaseValidation = parsed.releaseTree; } - const [candidate, plan, artifacts, source, entryRequest, kernel, queries, graph] = values; - const { executionContracts: _legacyContracts, ...workspace } = candidate; + const [plan, artifacts, source, entryRequest, kernel, queries] = values; if ( plan?.definition?.codec !== "camino-execution-plan-v1" || hexDigest(plan.definition) !== plan.digest || - hexDigest(workspace) !== hexDigest(plan.definition.workspace) + workspaceDigest !== hexDigest(plan.definition.workspace) ) throw new ExecutionPlanError( "CANDIDATE_PLAN_MISMATCH", @@ -113,14 +117,24 @@ export async function loadCheckedInstallationCandidate( "Stored interpreter input must match the checked build and plan", ); const expectedGraph = graphExecutionRepresentation(plan, installation, kernel, queries); - if (hexDigest(graph) !== hexDigest(expectedGraph) || artifacts.execution !== hexDigest(graph)) - throw new ExecutionPlanError( - "CANDIDATE_EXECUTION_MISMATCH", - root, - "Stored graph execution differs from checked inputs", - ); + // The stored graph embeds the same kernel/query programs. Validate it in a + // short-lived scope and retain the representation that shares those trees. + await (async () => { + const input = await readJson(root, "graph-execution.json", scope); + try { + const digest = hexDigest(input.value); + if (digest !== hexDigest(expectedGraph) || artifacts.execution !== digest) + throw new ExecutionPlanError( + "CANDIDATE_EXECUTION_MISMATCH", + root, + "Stored graph execution differs from checked inputs", + ); + } finally { + input.releaseTree(); + } + })(); const provenance = { - workspaceRevision: workspace.id as string, + workspaceRevision: plan.definition.workspace.id as string, source: { repository: source.repository as string, commit: source.commit as string }, generator: artifacts.generator as string, packages: (plan as ExecutionPlan).definition.packages.map((pkg) => ({ @@ -142,9 +156,9 @@ export async function loadCheckedInstallationCandidate( return { reference, workspace: plan.definition.workspace.workspaceId, - world: hexDigest(graph), + world: hexDigest(expectedGraph), plan: kernel.plan as string, - graphExecution: graph, + graphExecution: expectedGraph, provenance, entry, kernel, @@ -161,8 +175,6 @@ export async function loadCheckedInstallationCandidate( } catch (error) { scope.release(); throw error; - } finally { - releaseValidation(); } } diff --git a/src/execution-world/metadata.ts b/src/execution-world/metadata.ts index 66bc5fa..4736498 100644 --- a/src/execution-world/metadata.ts +++ b/src/execution-world/metadata.ts @@ -9,7 +9,11 @@ export class CandidateMetadataBudget { private bytes = 0; private scopes = 0; private readonly owners = new Map(); - constructor(limits = { bytes: 512 * 1024 * 1024, ownerBytes: 256 * 1024 * 1024, candidates: 128 }) { + // A cutover holds both the serving and proposed installation plus transient + // parsing space. The 57-repository reader retains ~133 MiB per installation; + // a 256 MiB owner ceiling cannot admit its ordinary atomic replacement. + // Keep the aggregate ceiling independent and leave room for parsing overlap. + constructor(limits = { bytes: 512 * 1024 * 1024, ownerBytes: 384 * 1024 * 1024, candidates: 128 }) { for (const value of Object.values(limits)) if (!Number.isSafeInteger(value) || value < 1) throw Error("INVALID_CANDIDATE_METADATA_BUDGET"); if (limits.ownerBytes > limits.bytes) throw Error("INVALID_CANDIDATE_METADATA_BUDGET"); diff --git a/test/candidate-metadata.test.ts b/test/candidate-metadata.test.ts index 67e96f9..6039c47 100644 --- a/test/candidate-metadata.test.ts +++ b/test/candidate-metadata.test.ts @@ -4,6 +4,62 @@ import { mkdtemp, writeFile, symlink, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { CandidateMetadataBudget, readCandidateMetadata } from "../src/execution-world/metadata.js"; +import { loadCheckedInstallationCandidate } from "../src/execution-world/candidate.js"; + +test( + "a real checked application can stage its replacement within the default metadata budget", + { + skip: !process.env.QX_CHECKED_CANDIDATE, + }, + async () => { + const root = process.env.QX_CHECKED_CANDIDATE!; + const budget = new CandidateMetadataBudget(); + const first = await loadCheckedInstallationCandidate(root, {}, { budget, owner: "reader" }); + try { + const retained = budget.snapshot().bytes; + const second = await loadCheckedInstallationCandidate(root, {}, { budget, owner: "reader" }); + try { + assert.equal(budget.snapshot().bytes, retained * 2); + assert.deepEqual(second.reference, first.reference, "Compaction preserves exact checked identity"); + assert.equal(second.graphExecution.kernel, second.kernel, "Graph representation shares the validated program"); + assert.equal(second.graphExecution.queries, second.queries); + // A deliberately tighter pool admits the serving installation but must + // reject replacement parsing without leaking a candidate or tree lease. + const tight = new CandidateMetadataBudget({ + bytes: retained + 1, + ownerBytes: retained + 1, + candidates: 2, + }); + const blocker = tight.acquire("reader"); + blocker.charge(retained); + try { + await assert.rejects( + loadCheckedInstallationCandidate(root, {}, { budget: tight, owner: "reader" }), + /CANDIDATE_METADATA_LIMIT/, + ); + assert.equal(tight.snapshot().bytes, retained); + assert.equal(tight.snapshot().candidates, 1); + } finally { + blocker.release(); + } + const other = budget.acquire("other"); + try { + assert.throws(() => other.charge(budget.limits.bytes), /CANDIDATE_METADATA_LIMIT/); + assert.equal(budget.snapshot().bytes, retained * 2, "Rejected admission must not leak charges"); + } finally { + other.release(); + } + } finally { + second.metadata.release(); + } + assert.equal(budget.snapshot().bytes, retained, "The serving candidate survives failed or completed staging"); + } finally { + first.metadata.release(); + } + assert.equal(budget.snapshot().bytes, 0); + assert.equal(budget.snapshot().candidates, 0); + }, +); test("concurrent candidate reservations enforce aggregate and owner limits and release exactly once", () => { const budget = new CandidateMetadataBudget({ bytes: 1000, ownerBytes: 600, candidates: 3 });