Files
rea/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts
T
a0214e0e0e fix(javascript): name heap and semantic ceilings (#6022)
* fix(javascript): name heap and semantic ceilings

Refs #5910. Heap exhaustion names max_heap_mb with its effective value,
range, and next-analysis worker restart. Semantic ceiling coverage names
the four max_semantic_* arguments. Wakaru and transfer ceilings say this
release has no runtime override.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): distinguish adjustable ceilings from fixed limits

The effective transfer file ceiling and the parent result budget follow
the CLI/MCP process heap, so recovery names NODE_OPTIONS instead of
denying an override. A provenance report over 8 MiB keeps the fixed
allowance guidance, and the Wakaru deadline says an unchanged retry may
hit it again.

Refs #5910

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): keep Bun OOM and checkpoint heap controls distinct

Bun heap-exhaustion exits no longer claim the ignored max_heap_mb V8
old-space limit. Checkpoint decode names the parent heap and Node
NODE_OPTIONS, the same control as the parent result budget.

Refs #5910

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): name semantic ceilings without claiming both were reached

Truncated semantic retention names the total and per-file controls as
effective ceilings. Callers only know that retention was truncated, so
the limitation no longer says both ceilings were reached.

Refs #5910

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): name only the semantic ceiling that stopped retention

Semantic ceiling limitations claimed both the application total and the
per-file control whenever either one truncated the graph. Record which
offered budget stopped node and unknown retention, and name only those
controls. Coverage limits still list both effective values.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): require a value in the NODE_OPTIONS heap example

A bare --max-old-space-size is an illegal V8 flag and prevents Node from
starting. Parent-heap and transfer-ceiling recovery now share
--max-old-space-size=<MiB>, and the Kuna heap remediation uses the same
complete form.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): limit the old-space placeholder to NODE_OPTIONS recovery

The review covers the CLI/MCP NODE_OPTIONS example and the transfer-ceiling
copy. The Kuna heap-headroom sentence stays unchanged.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 02:44:14 +08:00

209 lines
7.6 KiB
TypeScript

import type { JavaScriptSemanticProjectionCapacity } from "../../domain/javascript/javascriptSemanticFileProjection.js";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { getHeapStatistics } from "node:v8";
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
import { javaScriptRuntime } from "../../domain/runtimeVersion.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptModuleSemanticIr } from "../../domain/javascript/javascriptModuleSemanticIr.js";
import type { JavaScriptSemanticFileProjection } from "../../domain/javascript/javascriptSemanticFileProjection.js";
import {
javaScriptTransferByteBudget,
writeJavaScriptAnalysisTransfer,
} from "./JavaScriptAnalysisTransfer.js";
import { javaScriptWorkerStaticAnalysisSchema } from "./JavaScriptAnalysisWorkerSchemas.js";
import {
JAVASCRIPT_ANALYSIS_WORKER_DEFAULTS as DEFAULTS,
javaScriptCheckpointCeilingRemediation,
javaScriptWorkerRequestSchema,
javaScriptWorkerAdmissionSchema,
javaScriptWorkerMessageSchema,
type JavaScriptWorkerMessage,
} from "./JavaScriptAnalysisWorkerProtocol.js";
/** Trusted composition callback; this provider owns framing and temporary I/O. */
export interface JavaScriptSourceWorkerAnalyzer {
(
file: JavaScriptArtifactFile,
capacity: JavaScriptSemanticProjectionCapacity,
callbacks: {
readonly phase: (
phase: "parse" | "static" | "semantic" | "projection",
) => Promise<void>;
readonly checkpoint: (facts: JavaScriptStaticAnalysis) => Promise<number>;
},
): Promise<{
readonly module: JavaScriptModuleSemanticIr;
readonly projection: JavaScriptSemanticFileProjection;
} | null>;
}
const send = async (input: JavaScriptWorkerMessage): Promise<void> => {
const value = javaScriptWorkerMessageSchema.parse(input);
const frame = `${JSON.stringify(value)}\n`;
if (Buffer.byteLength(frame) > DEFAULTS.protocolBytes)
throw new RangeError(
"JavaScript worker control frame exceeds its byte budget",
);
await new Promise<void>((resolve, reject) => {
process.stdout.write(frame, (error) =>
error === null || error === undefined ? resolve() : reject(error),
);
});
};
/** Serve sequential inert-source requests from a parent-owned private runtime root. */
export const serveJavaScriptAnalysisWorker = async (
analyze: JavaScriptSourceWorkerAnalyzer,
): Promise<void> => {
const root = process.cwd();
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
const requests = input[Symbol.asyncIterator]();
await send({
kind: "ready",
heap_limit_bytes:
javaScriptRuntime(process.versions).id === "node"
? getHeapStatistics().heap_size_limit
: null,
});
try {
for (;;) {
const next = await requests.next();
if (next.done) break;
const line = next.value;
if (Buffer.byteLength(line) > DEFAULTS.protocolBytes)
throw new RangeError(
"JavaScript worker request exceeds its byte budget",
);
const raw: unknown = JSON.parse(line);
const request = javaScriptWorkerRequestSchema.parse(raw);
// Function scope releases source AST/IR/results before accepting another job.
await analyzeJob(root, request, analyze, requests);
if (
process.memoryUsage().heapUsed >
getHeapStatistics().heap_size_limit / 2
)
global.gc?.();
}
} finally {
input.close();
}
};
const analyzeJob = async (
root: string,
request: ReturnType<typeof javaScriptWorkerRequestSchema.parse>,
analyze: JavaScriptSourceWorkerAnalyzer,
requests: AsyncIterator<string>,
): Promise<void> => {
try {
let resultBudgetBytes = 0;
const source = await readFile(join(root, `${request.id}.source`), "utf8");
if (
createHash("sha256").update(source).digest("hex") !== request.text_sha256
)
throw new TypeError(
"JavaScript worker source text digest does not match its request",
);
const result = await analyze(
{
...request.file,
text: { included: true, value: source },
},
{ nodes: request.node_budget, unknowns: request.unknown_budget },
{
phase: async (phase) => send({ kind: "phase", id: request.id, phase }),
checkpoint: async (facts) => {
const value = javaScriptWorkerStaticAnalysisSchema.parse(facts);
const text = JSON.stringify(value);
const bytes = Buffer.byteLength(text);
if (bytes > DEFAULTS.checkpointBytes)
throw new AnalysisResourceConstraintError(
"analyze_javascript_application",
"transport",
`JavaScript static checkpoint exceeds its ${String(DEFAULTS.checkpointBytes)}-byte complete-fact ceiling. This release has no runtime override.`,
{
checkpoint_bytes: bytes,
checkpoint_limit_bytes: DEFAULTS.checkpointBytes,
},
{
remediationAction: javaScriptCheckpointCeilingRemediation(
DEFAULTS.checkpointBytes,
),
},
);
await writeFile(join(root, `${request.id}.static.json`), text, {
flag: "wx",
mode: 0o600,
});
await send({
kind: "static",
id: request.id,
bytes,
sha256: createHash("sha256").update(text).digest("hex"),
});
const next = await requests.next();
if (
next.done ||
Buffer.byteLength(next.value) > DEFAULTS.protocolBytes
)
throw new TypeError(
"JavaScript worker admission frame is missing or oversized",
);
const raw: unknown = JSON.parse(next.value);
const admission = javaScriptWorkerAdmissionSchema.parse(raw);
if (admission.id !== request.id)
throw new TypeError(
"JavaScript worker admission names a different source job",
);
resultBudgetBytes = admission.semantic_result_budget_bytes;
return resultBudgetBytes;
},
},
);
await send({ kind: "phase", id: request.id, phase: "transfer" });
const descriptor =
result === null
? null
: await writeJavaScriptAnalysisTransfer(
join(root, `${request.id}.records`),
result.module,
result.projection,
javaScriptTransferByteBudget(resultBudgetBytes),
);
await send({ kind: "complete", id: request.id, descriptor });
} catch (cause: unknown) {
await send({
kind: "failure",
id: request.id,
reason:
cause instanceof AnalysisResourceConstraintError ||
cause instanceof RangeError
? "resource"
: cause instanceof TypeError
? "output"
: "io",
resource:
cause instanceof AnalysisResourceConstraintError
? cause.resource
: cause instanceof RangeError
? "memory"
: null,
limits:
cause instanceof AnalysisResourceConstraintError
? cause.reportedLimits
: null,
// The parent owns resource-error formatting; send its canonical reason.
message:
cause instanceof AnalysisResourceConstraintError
? cause.reason
: cause instanceof Error
? cause.message
: String(cause),
});
}
};