2026-10-29 14:02:35 +08:00
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
|
import { expect, it, vi } from "vitest";
|
2026-11-02 05:16:16 -08:00
|
|
|
import { z } from "zod";
|
2026-10-29 14:02:35 +08:00
|
|
|
|
|
|
|
|
const heap = vi.hoisted(() => ({
|
2026-11-02 05:16:16 -08:00
|
|
|
usedBytes: 1024 * 1024,
|
2026-10-29 14:02:35 +08:00
|
|
|
remainingBytes: 200 * 1024 * 1024,
|
|
|
|
|
readings: [] as number[],
|
|
|
|
|
}));
|
|
|
|
|
vi.mock("node:v8", async (load) => {
|
|
|
|
|
const actual = await load<typeof import("node:v8")>();
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
getHeapStatistics: () => {
|
|
|
|
|
const current = actual.getHeapStatistics();
|
|
|
|
|
return {
|
|
|
|
|
...current,
|
2026-11-02 05:16:16 -08:00
|
|
|
used_heap_size: heap.usedBytes,
|
2026-10-29 14:02:35 +08:00
|
|
|
heap_size_limit:
|
2026-11-02 05:16:16 -08:00
|
|
|
heap.usedBytes + (heap.readings.shift() ?? heap.remainingBytes),
|
2026-10-29 14:02:35 +08:00
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Bound the parent-side maximum checkpoint reservation for this controlled
|
|
|
|
|
// test. The child still emits actual, digest-verified complete checkpoints.
|
|
|
|
|
vi.mock(
|
|
|
|
|
"../../../src/domain/javascript/javascriptAnalysisResourceControls.js",
|
|
|
|
|
async (load) => {
|
|
|
|
|
const actual =
|
|
|
|
|
await load<
|
|
|
|
|
typeof import("../../../src/domain/javascript/javascriptAnalysisResourceControls.js")
|
|
|
|
|
>();
|
|
|
|
|
return { ...actual, JAVASCRIPT_STATIC_FACT_BYTES: 256 * 1024 };
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js";
|
|
|
|
|
import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js";
|
|
|
|
|
import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js";
|
2026-11-02 05:16:16 -08:00
|
|
|
const sourceText = (text: string) => ({
|
|
|
|
|
sha256: createHash("sha256").update(text).digest("hex"),
|
|
|
|
|
bytes: Buffer.byteLength(text),
|
|
|
|
|
text: { included: true as const, value: text },
|
|
|
|
|
});
|
|
|
|
|
const file = (path: string, count: number): JavaScriptArtifactFile => ({
|
|
|
|
|
path,
|
|
|
|
|
container_sha256: "a".repeat(64),
|
|
|
|
|
inventory_artifact_id: path,
|
|
|
|
|
kind: "javascript",
|
|
|
|
|
unpacked: false,
|
|
|
|
|
...sourceText(
|
|
|
|
|
Array.from(
|
|
|
|
|
{ length: count },
|
|
|
|
|
(_, i) => `localStorage.setItem('k${i}', 'v');`,
|
|
|
|
|
).join("\n"),
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
const expectCapacityRemains = (
|
|
|
|
|
result: Awaited<ReturnType<JavaScriptAnalysisWorker["analyze"]>>,
|
|
|
|
|
): void => {
|
|
|
|
|
if (
|
|
|
|
|
!result.ok &&
|
|
|
|
|
result.error.error instanceof AnalysisResourceConstraintError
|
|
|
|
|
)
|
|
|
|
|
expect(
|
|
|
|
|
result.error.error.reportedLimits?.parent_result_budget_exhausted,
|
|
|
|
|
).not.toBe(true);
|
|
|
|
|
};
|
|
|
|
|
const functions = (count: number): JavaScriptArtifactFile => {
|
2026-10-29 14:02:35 +08:00
|
|
|
const text = Array.from(
|
|
|
|
|
{ length: count },
|
2026-11-02 05:16:16 -08:00
|
|
|
(_, i) => `export function f${i}(a) { const b = a + ${i}; return b; }`,
|
2026-10-29 14:02:35 +08:00
|
|
|
).join("\n");
|
2026-11-02 05:16:16 -08:00
|
|
|
return { ...file("functions.js", 0), ...sourceText(text) };
|
2026-10-29 14:02:35 +08:00
|
|
|
};
|
2026-11-02 05:16:16 -08:00
|
|
|
// The mocked heap leaves a 4 MiB result budget and 6 MiB of static capacity.
|
|
|
|
|
// A 50-line source reserves about 1.1 MiB of relationships; each later 500-line
|
|
|
|
|
// source is charged about 0.5 MiB, so reclamation starts at the eleventh.
|
|
|
|
|
const SOURCES_UNTIL_RECLAMATION = 12;
|
2026-10-29 14:02:35 +08:00
|
|
|
it("uses one live heap observation for revocation and static admission", async () => {
|
|
|
|
|
const run = async (changeHeapAt?: number) => {
|
|
|
|
|
const worker = new JavaScriptAnalysisWorker(
|
|
|
|
|
{ heapMb: 512, timeoutMs: 30_000 },
|
|
|
|
|
undefined,
|
2026-11-02 05:16:16 -08:00
|
|
|
SOURCES_UNTIL_RECLAMATION,
|
2026-10-29 14:02:35 +08:00
|
|
|
);
|
|
|
|
|
const results = [];
|
|
|
|
|
try {
|
2026-11-02 05:16:16 -08:00
|
|
|
for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++) {
|
2026-10-29 14:02:35 +08:00
|
|
|
const index = i;
|
|
|
|
|
results.push(
|
|
|
|
|
await worker.analyze(
|
|
|
|
|
file(`${i}.js`, i === 0 ? 50 : 500),
|
|
|
|
|
{ nodes: 1, unknowns: 1 },
|
|
|
|
|
undefined,
|
|
|
|
|
async (phase) => {
|
|
|
|
|
if (phase === "static" && index === changeHeapAt)
|
|
|
|
|
heap.readings = [200, 200, 192, 192].map(
|
|
|
|
|
(mib) => mib * 1024 * 1024,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
heap.readings = [];
|
|
|
|
|
}
|
|
|
|
|
return results;
|
|
|
|
|
} finally {
|
|
|
|
|
heap.readings = [];
|
|
|
|
|
await worker.close();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const control = await run();
|
|
|
|
|
const at = control.findIndex((result) =>
|
|
|
|
|
(result.ok ? result.value : result.error).revokedStaticProjections?.some(
|
|
|
|
|
({ sourceIndex }) => sourceIndex === 0,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(at).toBeGreaterThan(0);
|
|
|
|
|
const changed = await run(at);
|
|
|
|
|
const observed = changed[at];
|
|
|
|
|
if (observed === undefined) throw new Error("Missing changed observation");
|
|
|
|
|
const detail = observed.ok ? observed.value : observed.error;
|
|
|
|
|
expect(
|
|
|
|
|
detail.revokedStaticProjections?.map(({ sourceIndex }) => sourceIndex),
|
|
|
|
|
).toContain(0);
|
|
|
|
|
if (
|
|
|
|
|
!observed.ok &&
|
|
|
|
|
observed.error.error instanceof AnalysisResourceConstraintError
|
|
|
|
|
)
|
|
|
|
|
expect(
|
|
|
|
|
observed.error.error.reportedLimits?.parent_result_budget_exhausted,
|
|
|
|
|
).not.toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-02 05:16:16 -08:00
|
|
|
it("reclaims prior unmaterialized projections only when a complete static checkpoint would otherwise exhaust the static capacity", async () => {
|
2026-10-29 14:02:35 +08:00
|
|
|
const worker = new JavaScriptAnalysisWorker(
|
|
|
|
|
{ heapMb: 512, timeoutMs: 30_000 },
|
|
|
|
|
undefined,
|
2026-11-02 05:16:16 -08:00
|
|
|
SOURCES_UNTIL_RECLAMATION,
|
2026-10-29 14:02:35 +08:00
|
|
|
);
|
|
|
|
|
const results = [];
|
|
|
|
|
try {
|
2026-11-02 05:16:16 -08:00
|
|
|
for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++)
|
2026-10-29 14:02:35 +08:00
|
|
|
results.push(
|
|
|
|
|
await worker.analyze(file(`${i}.js`, i === 0 ? 50 : 500), {
|
|
|
|
|
nodes: 1,
|
|
|
|
|
unknowns: 1,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const details = results.map((r) => (r.ok ? r.value : r.error));
|
|
|
|
|
expect(
|
|
|
|
|
details.every((d) => d.javascript?.parse_status === "complete"),
|
|
|
|
|
).toBe(true);
|
|
|
|
|
expect(
|
|
|
|
|
details.some(
|
|
|
|
|
(d) =>
|
|
|
|
|
"error" in d &&
|
|
|
|
|
d.error instanceof AnalysisResourceConstraintError &&
|
|
|
|
|
d.error.reportedLimits?.parent_result_budget_exhausted === true,
|
|
|
|
|
),
|
|
|
|
|
).toBe(false);
|
|
|
|
|
expect(
|
|
|
|
|
details
|
|
|
|
|
.flatMap((d) => d.revokedStaticProjections ?? [])
|
|
|
|
|
.map((d) => d.sourceIndex),
|
|
|
|
|
).toContain(0);
|
|
|
|
|
} finally {
|
|
|
|
|
await worker.close();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps earlier projections when reclaiming them cannot admit the checkpoint", async () => {
|
|
|
|
|
const worker = new JavaScriptAnalysisWorker(
|
|
|
|
|
{ heapMb: 512, timeoutMs: 30_000 },
|
|
|
|
|
undefined,
|
2026-11-02 05:16:16 -08:00
|
|
|
6,
|
2026-10-29 14:02:35 +08:00
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
const results = [];
|
2026-11-02 05:16:16 -08:00
|
|
|
for (let i = 0; i < 6; i++)
|
2026-10-29 14:02:35 +08:00
|
|
|
results.push(
|
2026-11-02 05:16:16 -08:00
|
|
|
await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1400), {
|
2026-10-29 14:02:35 +08:00
|
|
|
nodes: 1,
|
|
|
|
|
unknowns: 1,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const details = results.map((result) =>
|
|
|
|
|
result.ok ? result.value : result.error,
|
|
|
|
|
);
|
|
|
|
|
expect(results[0]?.ok).toBe(true);
|
|
|
|
|
expect(details[0]?.applicationProjectionFailure).toBeUndefined();
|
|
|
|
|
expect(
|
|
|
|
|
details.flatMap((detail) => detail.revokedStaticProjections ?? []),
|
|
|
|
|
).toEqual([]);
|
|
|
|
|
expect(results.at(-1)?.ok).toBe(false);
|
|
|
|
|
const last = results.at(-1);
|
|
|
|
|
if (last === undefined || last.ok)
|
|
|
|
|
throw new Error("Expected capacity refusal");
|
|
|
|
|
expect(last.error.error).toMatchObject({
|
2026-11-02 05:16:16 -08:00
|
|
|
reportedLimits: {
|
|
|
|
|
parent_result_budget_bytes: 4 * 1024 * 1024,
|
|
|
|
|
parent_static_capacity_bytes: 6 * 1024 * 1024,
|
|
|
|
|
parent_result_budget_exhausted: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const {
|
|
|
|
|
static_retention_estimate_bytes: required,
|
|
|
|
|
parent_available_static_capacity_bytes: available,
|
|
|
|
|
} = z
|
|
|
|
|
.object({
|
|
|
|
|
static_retention_estimate_bytes: z.number(),
|
|
|
|
|
parent_available_static_capacity_bytes: z.number(),
|
|
|
|
|
})
|
|
|
|
|
.parse(
|
|
|
|
|
last.error.error instanceof AnalysisResourceConstraintError
|
|
|
|
|
? last.error.error.reportedLimits
|
|
|
|
|
: null,
|
|
|
|
|
);
|
|
|
|
|
expect(required).toBeGreaterThan(available);
|
|
|
|
|
} finally {
|
|
|
|
|
await worker.close();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("retains static facts past the result budget, keeping earlier relationships while the static capacity lasts", async () => {
|
|
|
|
|
const sources = 9;
|
|
|
|
|
const worker = new JavaScriptAnalysisWorker(
|
|
|
|
|
{ heapMb: 512, timeoutMs: 30_000 },
|
|
|
|
|
undefined,
|
|
|
|
|
sources,
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
const details = [];
|
|
|
|
|
for (let i = 0; i < sources; i++) {
|
|
|
|
|
const result = await worker.analyze(file(`${i}.js`, i === 0 ? 50 : 500), {
|
|
|
|
|
nodes: 1,
|
|
|
|
|
unknowns: 1,
|
|
|
|
|
});
|
|
|
|
|
expectCapacityRemains(result);
|
|
|
|
|
details.push(result.ok ? result.value : result.error);
|
|
|
|
|
}
|
|
|
|
|
expect(details.map((detail) => detail.javascript?.storage.length)).toEqual(
|
|
|
|
|
details.map((_, i) => (i === 0 ? 50 : 500)),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
details.flatMap((detail) => detail.revokedStaticProjections ?? []),
|
|
|
|
|
).toEqual([]);
|
|
|
|
|
// Relationships that do not fit the budget are refused, explicitly.
|
|
|
|
|
expect(details[0]?.applicationProjectionFailure).toBeUndefined();
|
|
|
|
|
for (const detail of details.slice(1))
|
|
|
|
|
expect(detail.applicationProjectionFailure).toMatchObject({
|
|
|
|
|
reason: expect.stringContaining("exceed the parent's result budget"),
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
await worker.close();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("retains static facts for later sources after semantic results use the result budget", async () => {
|
|
|
|
|
const sources = 4;
|
|
|
|
|
const worker = new JavaScriptAnalysisWorker(
|
|
|
|
|
{ heapMb: 512, timeoutMs: 30_000 },
|
|
|
|
|
undefined,
|
|
|
|
|
sources,
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
// Semantic results are already adopted, so nothing can be reclaimed from them.
|
|
|
|
|
const first = await worker.analyze(functions(10), {
|
|
|
|
|
nodes: 20_000,
|
|
|
|
|
unknowns: 20_000,
|
2026-10-29 14:02:35 +08:00
|
|
|
});
|
2026-11-02 05:16:16 -08:00
|
|
|
if (!first.ok) throw first.error.error;
|
|
|
|
|
expect(first.value.module?.callables).toHaveLength(10);
|
|
|
|
|
expect(first.value.projection).not.toBeNull();
|
|
|
|
|
const later = [];
|
|
|
|
|
for (let i = 1; i < sources; i++) {
|
|
|
|
|
const result = await worker.analyze(file(`${i}.js`, 1000), {
|
|
|
|
|
nodes: 20_000,
|
|
|
|
|
unknowns: 20_000,
|
|
|
|
|
});
|
|
|
|
|
expectCapacityRemains(result);
|
|
|
|
|
later.push(result.ok ? result.value : result.error);
|
|
|
|
|
}
|
|
|
|
|
expect(later.map((detail) => detail.javascript?.storage.length)).toEqual(
|
|
|
|
|
later.map(() => 1000),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
later.flatMap((detail) => detail.revokedStaticProjections ?? []),
|
|
|
|
|
).toEqual([]);
|
2026-10-29 14:02:35 +08:00
|
|
|
} finally {
|
|
|
|
|
await worker.close();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-02 05:16:16 -08:00
|
|
|
it("stays within the result budget when the live heap has no room for another copy", async () => {
|
|
|
|
|
// Each later checkpoint is charged about 1 MiB, against 8 MiB of live room.
|
|
|
|
|
heap.usedBytes = 7.5 * 1024 * 1024;
|
|
|
|
|
const sources = 9;
|
|
|
|
|
const worker = new JavaScriptAnalysisWorker(
|
|
|
|
|
{ heapMb: 512, timeoutMs: 30_000 },
|
|
|
|
|
undefined,
|
|
|
|
|
sources,
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
const results = [];
|
|
|
|
|
for (let i = 0; i < sources; i++)
|
|
|
|
|
results.push(
|
|
|
|
|
await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1000), {
|
|
|
|
|
nodes: 1,
|
|
|
|
|
unknowns: 1,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const stopped = results
|
|
|
|
|
.flatMap((result) => (result.ok ? [] : [result.error.error]))
|
|
|
|
|
.find(
|
|
|
|
|
(error) =>
|
|
|
|
|
error instanceof AnalysisResourceConstraintError &&
|
|
|
|
|
error.reportedLimits?.parent_result_budget_exhausted === true,
|
|
|
|
|
);
|
|
|
|
|
expect(stopped).toMatchObject({
|
|
|
|
|
reportedLimits: {
|
|
|
|
|
parent_result_budget_bytes: 4 * 1024 * 1024,
|
|
|
|
|
parent_static_capacity_bytes: 4 * 1024 * 1024,
|
|
|
|
|
parent_used_heap_bytes: 7.5 * 1024 * 1024,
|
|
|
|
|
parent_result_budget_exhausted: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
heap.usedBytes = 1024 * 1024;
|
|
|
|
|
await worker.close();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-29 14:02:35 +08:00
|
|
|
it("resets projection reservations and source indices when an idle worker is reused", async () => {
|
|
|
|
|
const worker = new JavaScriptAnalysisWorker(
|
|
|
|
|
{ heapMb: 512, timeoutMs: 30_000 },
|
|
|
|
|
undefined,
|
|
|
|
|
1,
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
const first = await worker.analyze(file("previous.js", 10), {
|
|
|
|
|
nodes: 1,
|
|
|
|
|
unknowns: 1,
|
|
|
|
|
});
|
|
|
|
|
expect(first.ok).toBe(true);
|
2026-11-02 05:16:16 -08:00
|
|
|
expect(worker.prepareForAnotherAnalysis(SOURCES_UNTIL_RECLAMATION)).toBe(
|
|
|
|
|
true,
|
|
|
|
|
);
|
2026-10-29 14:02:35 +08:00
|
|
|
const results = [];
|
2026-11-02 05:16:16 -08:00
|
|
|
for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++)
|
2026-10-29 14:02:35 +08:00
|
|
|
results.push(
|
|
|
|
|
await worker.analyze(file(`new-${i}.js`, i === 0 ? 50 : 500), {
|
|
|
|
|
nodes: 1,
|
|
|
|
|
unknowns: 1,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const revocations = results.flatMap(
|
|
|
|
|
(r) => (r.ok ? r.value : r.error).revokedStaticProjections ?? [],
|
|
|
|
|
);
|
|
|
|
|
expect(revocations.map((entry) => entry.sourceIndex)).toEqual([0]);
|
|
|
|
|
expect(revocations[0]?.error.message).toContain("new-0.js");
|
|
|
|
|
expect(revocations[0]?.error.message).not.toContain("previous.js");
|
|
|
|
|
} finally {
|
|
|
|
|
await worker.close();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not discard earlier projections when live heap, not reservations, blocks admission", async () => {
|
|
|
|
|
const worker = new JavaScriptAnalysisWorker(
|
|
|
|
|
{ heapMb: 512, timeoutMs: 30_000 },
|
|
|
|
|
undefined,
|
|
|
|
|
2,
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
const first = await worker.analyze(file("first.js", 10), {
|
|
|
|
|
nodes: 1,
|
|
|
|
|
unknowns: 1,
|
|
|
|
|
});
|
|
|
|
|
expect(first.ok).toBe(true);
|
|
|
|
|
const second = await worker.analyze(
|
|
|
|
|
file("second.js", 1000),
|
|
|
|
|
{ nodes: 1, unknowns: 1 },
|
|
|
|
|
undefined,
|
|
|
|
|
async (phase) => {
|
|
|
|
|
if (phase === "static") heap.remainingBytes = 192 * 1024 * 1024;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
expect(second.ok).toBe(false);
|
|
|
|
|
if (second.ok) throw new Error("Expected live heap refusal");
|
|
|
|
|
expect(second.error.error).toMatchObject({
|
|
|
|
|
reportedLimits: { parent_result_budget_exhausted: true },
|
|
|
|
|
});
|
|
|
|
|
expect(second.error.revokedStaticProjections).toBeUndefined();
|
|
|
|
|
} finally {
|
|
|
|
|
heap.remainingBytes = 200 * 1024 * 1024;
|
|
|
|
|
await worker.close();
|
|
|
|
|
}
|
|
|
|
|
});
|