rea/tests/boundary/javascript/javascriptSourceAnalysisWor...

281 lines
10 KiB
TypeScript

import { createHash } from "node:crypto";
import { expect, it } from "vitest";
import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js";
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import { javaScriptWorkerMemoryLimitBytes } from "../../../src/process/JavaScriptWorkerMemoryLimit.js";
import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js";
const source = (path: string, value: string): JavaScriptArtifactFile => ({
path,
sha256: createHash("sha256").update(value).digest("hex"),
container_sha256: "1".repeat(64),
bytes: Buffer.byteLength(value),
inventory_artifact_id: "fixture-source",
kind: "javascript",
unpacked: false,
text: { included: true, value },
});
/** The Job Object limit the worker requests for its 128 MiB heap budget, if any. */
const processLimit = javaScriptWorkerMemoryLimitBytes(128);
it("reuses one real owned analyzer while retaining distinct source identities and exported returns", async () => {
const launches: number[] = [];
const worker = new JavaScriptAnalysisWorker(
{ heapMb: 128, timeoutMs: 30_000 },
async (request) => {
const spawned = await spawnOwnedProviderProcess(request);
const pid = spawned.process.pid;
if (pid === undefined)
throw new Error("Owned analyzer has no observed PID");
launches.push(pid);
return spawned;
},
);
try {
const first = source(
"first.mjs",
"export function create() { return { observed: 7 }; }",
);
const second = source(
"second.mjs",
"export function next() { return { observed: 8 }; }",
);
for (const [index, file] of [first, second].entries()) {
const result = await worker.analyze(file, 100);
if (!result.ok) throw result.error.error;
expect(result.value.javascript.parse_status).toBe("complete");
expect(
result.value.module?.callables[0]?.returnSites[0]?.value,
).toMatchObject({
status: "object",
properties: [
{ name: "observed", value: { status: "literal", value: index + 7 } },
],
});
expect(
result.value.projection?.nodes.every(
({ identity }) => identity.artifact_sha256 === file.sha256,
),
).toBe(true);
expect(
result.value.projection?.nodes.some(
({ identity }) => identity.module_path === file.path,
),
).toBe(true);
}
expect(launches).toHaveLength(1);
} finally {
await worker.close();
}
});
it("reports an actual startup deadline and closes its owned resources before subsequent analysis", async () => {
const file = source("app.mjs", "export const observed = 7;");
const expired = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 1 });
try {
const result = await expired.analyze(file, 100);
if (result.ok)
throw new Error(
"A one-millisecond fresh process deadline unexpectedly completed",
);
expect(result.error.error).toMatchObject({
_tag: "AnalysisTimeoutError",
timeoutMs: 1,
});
} finally {
await expired.close();
}
const next = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 });
try {
const result = await next.analyze(file, 100);
if (!result.ok) throw result.error.error;
expect(result.value.javascript.parse_status).toBe("complete");
} finally {
await next.close();
}
});
it("preserves a failed owned cleanup retry when the next caller cancels before acquisition", async () => {
const controller = new AbortController();
let releaseAllowed = false;
let cleanupAttempts = 0;
const worker = new JavaScriptAnalysisWorker(
{ heapMb: 128, timeoutMs: 30_000 },
async (request) => {
const launched = await spawnOwnedProviderProcess(request);
return {
...launched,
async cleanup() {
cleanupAttempts += 1;
if (!releaseAllowed) {
if (cleanupAttempts > 1) controller.abort();
throw new Error("Fixture owned cleanup is unavailable");
}
return launched.cleanup === undefined
? cleanupOwnedProcessGroup(launched.ownership)
: launched.cleanup();
},
};
},
);
const next = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 });
try {
const file = source("app.mjs", "export const observed = 7;");
const first = await worker.analyze(file, 100);
if (!first.ok) throw first.error.error;
const closing = worker.close();
await expect(closing).rejects.toBeInstanceOf(ProviderCleanupError);
await expect(closing).rejects.toMatchObject({
cause: { message: "Fixture owned cleanup is unavailable" },
});
const interrupted = await next.analyze(file, 100, controller.signal);
if (interrupted.ok)
throw new Error("Unreleased ownership must block subsequent analysis");
expect(controller.signal.aborted).toBe(true);
expect(projectAnalysisError(interrupted.error.error)).toMatchObject({
code: "cleanup_incomplete",
});
releaseAllowed = true;
await worker.close();
const recovered = await next.analyze(file, 100);
if (!recovered.ok) throw recovered.error.error;
expect(recovered.value.javascript.parse_status).toBe("complete");
} finally {
releaseAllowed = true;
await worker.close();
await next.close();
}
});
// The handshake mirrors the real server: only Node.js reports an enforced V8 heap limit.
const resourceFailureWorker = (heapLimit: string) =>
new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 }, (request) =>
spawnOwnedProviderProcess({
...request,
arguments: [
"-e",
`const heap = ${heapLimit};
process.stdout.write(JSON.stringify({ kind: "ready", heap_limit_bytes: heap }) + "\\n");
require("node:readline").createInterface({ input: process.stdin }).once("line", (line) => {
const { id } = JSON.parse(line);
process.stdout.write(JSON.stringify({ kind: "failure", id, reason: "resource", resource: "memory", limits: null, message: "fixture" }) + "\\n");
});`,
],
}),
);
it("reports the worker heap limit only when the runtime enforces V8 heap flags", async () => {
const worker = resourceFailureWorker(
'process.versions.bun === undefined ? require("node:v8").getHeapStatistics().heap_size_limit : null',
);
try {
const result = await worker.analyze(
source("app.mjs", "export const observed = 7;"),
100,
);
if (result.ok) throw new Error("expected a reported resource failure");
expect(result.error.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
reportedLimits:
process.versions.bun === undefined
? {
worker_old_space_mb: 128,
worker_heap_limit_bytes: expect.any(Number),
worker_timeout_ms: 30_000,
}
: {
worker_heap_limit_bytes: null,
...(processLimit === null
? {}
: { worker_process_memory_limit_bytes: processLimit }),
worker_timeout_ms: 30_000,
},
});
} finally {
await worker.close();
}
});
it("rejects a worker heap report that contradicts the launching runtime", async () => {
const worker = resourceFailureWorker(
"process.versions.bun === undefined ? null : 1",
);
try {
const result = await worker.analyze(
source("app.mjs", "export const observed = 7;"),
100,
);
if (result.ok) throw new Error("expected a handshake failure");
expect(result.error.error._tag).toBe("AnalysisOutputError");
} finally {
await worker.close();
}
});
// The launcher runs a real child without a job limit, so any artifact can run
// it, and its handle reports the notification an owned Windows job records
// before exit. Bun exits 3 (panic) or 1 (exception) at a reached job limit.
it.each([true, false])(
"classifies a worker exit by the job memory notification (reached: %s)",
async (reached) => {
const worker = new JavaScriptAnalysisWorker(
{ heapMb: 128, timeoutMs: 30_000 },
async ({ memoryLimitBytes, ...request }) => {
expect(memoryLimitBytes).toBe(processLimit ?? undefined);
const spawned = await spawnOwnedProviderProcess({
...request,
arguments: [
"-e",
`const heap = process.versions.bun === undefined ? require("node:v8").getHeapStatistics().heap_size_limit : null;
process.stdout.write(JSON.stringify({ kind: "ready", heap_limit_bytes: heap }) + "\\n");
require("node:readline").createInterface({ input: process.stdin }).once("line", () => {
process.stderr.write("panic: out of memory");
process.exit(3);
});`,
],
});
let exited = false;
spawned.process.once("exit", () => {
exited = true;
});
Object.defineProperty(spawned.process, "memoryLimitReached", {
get: () => reached && exited,
});
return spawned;
},
);
try {
const result = await worker.analyze(
source("app.mjs", "export const observed = 7;"),
100,
);
if (result.ok) throw new Error("expected the worker to fail");
expect(result.error.error).toMatchObject(
reached
? {
_tag: "AnalysisResourceConstraintError",
resource: "memory",
reason: expect.stringContaining("process memory limit"),
reportedLimits:
processLimit === null
? expect.not.objectContaining({
worker_process_memory_limit_bytes: expect.anything(),
})
: { worker_process_memory_limit_bytes: processLimit },
capturedOutput: { stderr: "panic: out of memory" },
}
: {
_tag: "ProviderAdapterError",
diagnostics: { exit_code: 3 },
},
);
} finally {
await worker.close();
}
},
);