diff --git a/src/execution-world/candidate.ts b/src/execution-world/candidate.ts index 090cd57..fd3def5 100644 --- a/src/execution-world/candidate.ts +++ b/src/execution-world/candidate.ts @@ -10,10 +10,16 @@ import { type CandidateMetadataScope, } from "./metadata.js"; const rootPattern = /^\/nix\/store\/[a-z0-9]{32}-[^/\s]+$/; -async function readJson(root: string, file: string, scope: CandidateMetadataScope): Promise { +async function readJson(root: string, file: string, scope: CandidateMetadataScope) { const input = await readCandidateMetadata(join(root, file), scope, 32 * 1024 * 1024); try { - return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(input.bytes)); + return { + value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(input.bytes)), + releaseTree: input.releaseTree, + }; + } catch (error) { + input.releaseTree(); + throw error; } finally { input.releaseInput(); } @@ -30,6 +36,7 @@ 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 { // Sequential reads avoid partially rejected Promise.all() work allocating // after the failed candidate's reservation has been released. @@ -43,8 +50,13 @@ export async function loadCheckedInstallationCandidate( "kernel.json", "queries.json", "graph-execution.json", - ]) - values.push(await readJson(root, file, scope)); + ]) { + 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; if ( @@ -149,6 +161,8 @@ 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 a7c0664..66bc5fa 100644 --- a/src/execution-world/metadata.ts +++ b/src/execution-world/metadata.ts @@ -104,7 +104,7 @@ export async function readCandidateMetadata( path: string, scope: CandidateMetadataScope, limit: number, -): Promise<{ bytes: Uint8Array; releaseInput: () => void }> { +): Promise<{ bytes: Uint8Array; releaseInput: () => void; releaseTree: () => void }> { const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); let releaseInput: (() => void) | undefined; try { @@ -123,8 +123,8 @@ export async function readCandidateMetadata( if (offset !== stat.size) throw Error("CANDIDATE_METADATA_CHANGED"); const content = bytes.subarray(0, offset); // No JSON.parse/decoded tree exists until its shape-based charge succeeds. - scope.charge(jsonAllocation(content)); - return { bytes: content, releaseInput }; + const releaseTree = scope.reserve(jsonAllocation(content)); + return { bytes: content, releaseInput, releaseTree }; } catch (error) { releaseInput?.(); throw error; diff --git a/test/candidate-metadata.test.ts b/test/candidate-metadata.test.ts index b8871f1..67e96f9 100644 --- a/test/candidate-metadata.test.ts +++ b/test/candidate-metadata.test.ts @@ -48,6 +48,9 @@ test("metadata files reserve parse/index space before allocation and refuse over await assert.rejects(readCandidateMetadata(join(root, "linked"), scope, 100), { code: "ELOOP" }); await assert.rejects(readCandidateMetadata(root, scope, 100), /CANDIDATE_METADATA_FILE_LIMIT/); assert.equal(scope.bytes, first); + input.releaseTree(); + input.releaseTree(); + assert.equal(scope.bytes, 0, "Discarded validation trees can release independently of the candidate"); } finally { scope.release(); await rm(root, { recursive: true, force: true });