mirror of
https://github.com/morluto/rea
synced 2026-10-29 07:17:42 -04:00
8b74f30bf9
* fix(javascript): stop double-expanding application projection estimates estimateJavaScriptStaticApplicationProjection multiplied the per-record metadata allowance (2 KB plus the path) by the 8x expansion that it also applies to checkpoint bytes. A projected record serializes to about 0.46 KB, so the allowance alone already covers retention; expanded again, the charge was about 25x the serialized application relationships. The charge accumulates in the parent's shared result budget. On a synthetic 2.8 MB Vite-style build (88 files, one 456 KB chunk), the first 10 files used 2.0 GB of a 2.08 GB budget while the parent heap held about 300 MiB. The other 78 files lost their application relationships and were offered a 0-byte semantic budget. On a 201-file build the charges exceeded the budget at file 93 and the analysis failed with resource_constraint, leaving the remaining sources without static facts. Keep the 8x expansion for checkpoint and repeated bytes and count the per-record allowance once. The same builds now refuse 14 and 102 projections instead of 78 and a failed run; peak RSS stays at 1.2 GB and 2.0 GB under a 4.2 GB parent heap limit. workerFailureComposition simulated 64 KiB of parent result budget, which the corrected charge for its one-finding checkpoint fits. Lower that headroom to 32 KiB so the case still exercises a refused projection. * test(javascript): cover application projections near the shared parent budget The estimate test compares one file's charge with its serialized graph, but no test put several sources against one parent result budget. Replay one recorded static checkpoint for five sources against an exact budget: sources 1-3 fit, source 4 misses by half a source and keeps its static facts with the reported required and budget bytes, and the last source fits again once no checkpoint reserve is held back. A worker that charges the projection twice, or not at all, passes the existing tests and fails this one. * test(javascript): cover a malformed source before a valid one under the shared budget The testing guide asks representation-expansion and capacity changes for a large malformed input followed by a valid input, and for escaped or expanded representations. Under an exact parent budget, a 3 MiB checkpoint cut off inside a string comes first and is rejected as unreadable output with no static facts. The valid source after it keeps its application relationships; it fits only if the rejected bytes took no charge. A worker that charges the static checkpoint on receipt, before parsing, passes the existing tests and fails this one. The estimate ratio test also runs with a nested source path and storage keys holding quotes, backslashes, a tab and multibyte text, which expand in serialized JSON; the charge stays within 3x to 8x of the serialized application graph (5.3x).
205 lines
8.4 KiB
TypeScript
205 lines
8.4 KiB
TypeScript
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();
|
|
}
|
|
});
|