Files
rea/tests/boundary/javascript/javascriptApplicationProjectionSharedBudget.test.ts

204 lines
8.4 KiB
TypeScript
Raw Permalink Normal View History

import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, expect, it, vi } from "vitest";
const MIB = 1024 * 1024;
const TRANSFER_RESERVE = 24 * 8 * MIB;
const parentHeap = vi.hoisted(() => ({ remainingBytes: 0 }));
// Give the parent an exact result budget: (remaining heap - transfer
// reserve) / 2. The analyzer is a real separately owned process that replays
// one recorded static checkpoint per source.
vi.mock("node:v8", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:v8")>();
return {
...actual,
getHeapStatistics: () => {
const observed = actual.getHeapStatistics();
return {
...observed,
heap_size_limit: observed.used_heap_size + parentHeap.remainingBytes,
};
},
};
});
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { estimateJavaScriptStaticApplicationProjection } from "../../../src/domain/javascript/javascriptApplicationProjectionResources.js";
import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js";
import { analyzeJavaScriptStaticSource } from "../../../src/domain/javascript/javascriptStaticAnalysis.js";
import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js";
import { javaScriptWorkerStaticAnalysisSchema } from "../../../src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
afterAll(() => {
vi.doUnmock("node:v8");
vi.resetModules();
});
// Matches STATIC_CHECKPOINT_RETENTION_EXPANSION and the per-source checkpoint
// reserve (8 MiB checkpoint ceiling at that expansion) in the worker.
const STATIC_EXPANSION = 6;
const CHECKPOINT_RESERVE = 8 * MIB * STATIC_EXPANSION;
const text = Array.from(
{ length: 12 },
(_, index) =>
`export function f${String(index)}(a){` +
`localStorage.setItem("pref.${String(index % 3)}",String(a));` +
`return fetch("/rpc/p${String(index % 4)}",{method:"POST",body:a});}`,
).join("\n");
const sourceFile = (path: string): JavaScriptArtifactFile => ({
path,
sha256: createHash("sha256").update(text).digest("hex"),
container_sha256: "1".repeat(64),
bytes: Buffer.byteLength(text),
inventory_artifact_id: `fixture-${path}`,
kind: "javascript",
unpacked: false,
text: { included: true, value: text },
});
const ready =
'process.stdout.write(JSON.stringify({kind:"ready",heap_limit_bytes:require("node:v8").getHeapStatistics().heap_size_limit})+"\\n");';
// Replay a recorded checkpoint per source path (the default otherwise) and
// complete without a semantic transfer, so only static and
// application-projection charges reach the shared budget.
const replayingAnalyzer =
(checkpointPath: string, byPath: Readonly<Record<string, string>> = {}) =>
async (request: Parameters<typeof spawnOwnedProviderProcess>[0]) =>
spawnOwnedProviderProcess({
...request,
arguments: [
"-e",
`${ready} const fs=require("node:fs"); const {createHash}=require("node:crypto"); const byPath=${JSON.stringify(byPath)}; require("node:readline").createInterface({input:process.stdin}).on("line", line => { const request=JSON.parse(line); if(request.file) {const checkpoint=fs.readFileSync(byPath[request.file.path] ?? ${JSON.stringify(checkpointPath)}); fs.writeFileSync(request.id+".static.json",checkpoint); process.stdout.write(JSON.stringify({kind:"static",id:request.id,bytes:checkpoint.length,sha256:createHash("sha256").update(checkpoint).digest("hex")})+"\\n");} else process.stdout.write(JSON.stringify({kind:"complete",id:request.id,descriptor:null})+"\\n"); });`,
],
});
it("admits application relationships up to the shared parent budget and keeps static facts beyond it", async () => {
const facts = javaScriptWorkerStaticAnalysisSchema.parse(
analyzeJavaScriptStaticSource(text),
);
const checkpoint = JSON.stringify(facts);
const checkpointBytes = Buffer.byteLength(checkpoint);
const paths = ["a.mjs", "b.mjs", "c.mjs", "d.mjs", "e.mjs"];
const staticCharge = checkpointBytes * STATIC_EXPANSION;
const projection = estimateJavaScriptStaticApplicationProjection(
facts,
checkpointBytes,
"a.mjs",
);
// Sources 1-3 fit exactly; source 4 misses by half a source, while its
// static facts still fit. The last source has no checkpoint reserve left
// to hold back, so it fits again.
const budget =
CHECKPOINT_RESERVE +
3 * (staticCharge + projection) +
Math.floor((staticCharge + projection) / 2);
parentHeap.remainingBytes = TRANSFER_RESERVE + 2 * budget;
const root = await createTestTempDirectory("rea-shared-projection-budget-");
const checkpointPath = join(root, "checkpoint.json");
await writeFile(checkpointPath, checkpoint);
const worker = new JavaScriptAnalysisWorker(
{ heapMb: 128, timeoutMs: 30_000 },
replayingAnalyzer(checkpointPath),
paths.length,
);
try {
const outcomes = [];
for (const path of paths) {
const result = await worker.analyze(sourceFile(path), {
nodes: 100,
unknowns: 100,
});
if (!result.ok) throw result.error.error;
outcomes.push(result.value);
}
expect(projection).toBeGreaterThan(staticCharge);
expect(outcomes.map(({ javascript }) => javascript.storage.length)).toEqual(
paths.map(() => facts.storage.length),
);
expect(
outcomes.map(
({ applicationProjectionFailure }) =>
applicationProjectionFailure !== undefined,
),
).toEqual([false, false, false, true, false]);
expect(outcomes[3]?.applicationProjectionFailure).toMatchObject({
_tag: "AnalysisResourceConstraintError",
reason: expect.stringContaining(
"Expanded application relationships for d.mjs exceed the parent's result budget",
),
reportedLimits: {
parent_result_budget_bytes: budget,
application_projection_required_bytes: projection,
},
});
} finally {
await worker.close();
}
});
it("charges nothing for a large rejected checkpoint before a valid source", async () => {
const facts = javaScriptWorkerStaticAnalysisSchema.parse(
analyzeJavaScriptStaticSource(text),
);
const checkpoint = JSON.stringify(facts);
const checkpointBytes = Buffer.byteLength(checkpoint);
// A 3 MiB checkpoint cut off inside a string: within the checkpoint ceiling
// and the parent's decode headroom, but not JSON.
const malformed = `${checkpoint.slice(0, -1)},"padding":"${"x".repeat(3 * MIB)}`;
const malformedCharge = Buffer.byteLength(malformed) * STATIC_EXPANSION;
const staticCharge = checkpointBytes * STATIC_EXPANSION;
const projection = estimateJavaScriptStaticApplicationProjection(
facts,
checkpointBytes,
"b.mjs",
);
// The valid last source fits with half of the rejected checkpoint's static
// charge to spare, so any charge kept for rejected bytes refuses it.
const budget = staticCharge + projection + Math.floor(malformedCharge / 2);
expect(malformedCharge).toBeGreaterThan(2 * (staticCharge + projection));
parentHeap.remainingBytes = TRANSFER_RESERVE + 2 * budget;
const root = await createTestTempDirectory("rea-shared-budget-rejected-");
const checkpointPath = join(root, "checkpoint.json");
const malformedPath = join(root, "malformed.json");
await writeFile(checkpointPath, checkpoint);
await writeFile(malformedPath, malformed);
const worker = new JavaScriptAnalysisWorker(
{ heapMb: 128, timeoutMs: 30_000 },
replayingAnalyzer(checkpointPath, { "a.mjs": malformedPath }),
2,
);
try {
const rejected = await worker.analyze(sourceFile("a.mjs"), {
nodes: 100,
unknowns: 100,
});
if (rejected.ok)
throw new Error("Expected the malformed checkpoint to fail");
expect(rejected.error.javascript).toBeNull();
expect(rejected.error.error).toMatchObject({
_tag: "AnalysisOutputError",
});
expect(projectAnalysisError(rejected.error.error).code).toBe(
"unreadable_output",
);
const valid = await worker.analyze(sourceFile("b.mjs"), {
nodes: 100,
unknowns: 100,
});
if (!valid.ok) throw valid.error.error;
expect(valid.value.javascript.storage).toEqual(facts.storage);
expect(valid.value.applicationProjectionFailure).toBeUndefined();
} finally {
await worker.close();
}
});