mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 07:46:28 +00:00
* fix(javascript): apply package type scope to application sources Application analysis parsed .js sources without their package scope, so an unbound require in a type-module package became a resolved CommonJS dependency. The parent now observes each source's nearest same-container package.json, stopping at node_modules, and the worker parses with that package type as reference imports already do. Fixes #7323 * fix(javascript): keep package type unknown behind unavailable manifests The package-type lookup indexed only readable files, so a nearer package.json whose bytes were unavailable was skipped and an ancestor's "type" applied to sources it does not own. Index unavailable manifests by container and directory too, and stop the walk with "unknown" when one is the nearest owner, as the Next app-context lookup already does. * fix(javascript): keep Electron preloads outside module package scope Electron's preload loader ignores a package's "type": "module" and gives a .js preload `require`; ESM preloads need the .mjs suffix. Applying the package type to every .js source marked such a preload as an ES module, so its `require("electron")` edges and IPC pairing disappeared. A source that requires an Electron module now keeps the context REA inferred before package scope was applied. Node-loaded sources in a module scope still drop unbound `require`. * fix(javascript): admit Electron script loaders only on renderer evidence The previous commit dropped a module package type whenever a source required an Electron module. A Node-loaded ES module that requires Electron, or one whose Electron call goes through a local `require`, then regained the false require edges the package scope removes. The parser applies the package type unconditionally again. The worker re-reads a package-module script that requires an Electron module once outside the package scope. The source keeps that context only when the static pass there directly observes a contextBridge exposure or a renderer IPC operation, which only Electron's renderer-side loaders provide. * fix(javascript): check Electron renderer evidence without a second static passb54845a6bran the full static pass on an unscoped parse of every package-module script that requires Electron, then discarded it when no renderer operation turned up, so such main-process sources paid for two full passes. A candidate must now also name contextBridge or ipcRenderer, which any renderer evidence needs. The evidence check runs only the static pass's binding and Electron identity preamble with its exposure and IPC descriptors. The full static pass runs once, on the chosen parse. * fix(javascript): confirm Electron renderer evidence with the static pass The focused evidence check from4a86bca9balso counted renderer calls in bundler factory defaults that the bundler convention never runs, which the static pass skips. Such a default switched a type-module file to script context, so its unrelated unbound requires became imports again. The focused check now only screens. It follows the static pass's own traversal, so a negative result still rules evidence out. A positive result is confirmed by the static pass's direct findings on the unscoped parse, and those findings become the file's facts when they hold. * fix(javascript): confirm renderer evidence before wrapper substitutionf71236400confirmed a screened source from the static pass's final contextBridge and IPC findings, without wrapper substitutions. The wrapper pass replaces a directly observed exposure inside a helper with a substituted finding, so such a preload lost its imports again. Before its wrapper pass, the static pass now records whether its source traversal directly observed a contextBridge exposure or a renderer IPC operation, and observedElectronRendererCall exposes that. The traversal still skips bundler factory defaults that never run, and the reported findings are unchanged. * fix(javascript): resolve declared preload loaders before source analysis * fix(javascript): leave preload discovery failures to source analysis A source that failed during preload discovery threw out of the whole application analysis, so one oversized source or a per-source timeout in a module package returned no Evidence. Skip a source that exceeds its worker limits and end discovery on any other failure except cancellation; source analysis then meets the same source, records its failure and keeps partial Evidence as it does without discovery. * fix(javascript): report preload discovery gaps in Evidence limitations When preload discovery skipped a source or stopped early, source analysis could still read that source, so the result looked complete while a .js preload declared there kept its package scope. Return each discovery gap with the discovered paths and add it to the analysis limitations, which both complete and partial Evidence carry.
1418 lines
53 KiB
TypeScript
1418 lines
53 KiB
TypeScript
import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js";
|
|
import { safeParseUtf8Json } from "../../domain/safeJson.js";
|
|
import { analysisSettingLimit } from "../../domain/analysisSettingDefinitions.js";
|
|
import { WorkerFrameSignal } from "../../process/WorkerFrameSignal.js";
|
|
import type {
|
|
JavaScriptSemanticProjectionCapacity,
|
|
JavaScriptSemanticFileProjection,
|
|
} from "../../domain/javascript/javascriptSemanticFileProjection.js";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { readFile, rm, stat } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { writeJavaScriptAnalysisSourceTransfer } from "./JavaScriptAnalysisSourceTransfer.js";
|
|
import { StringDecoder } from "node:string_decoder";
|
|
import { fileURLToPath } from "node:url";
|
|
import { getHeapStatistics } from "node:v8";
|
|
import { z } from "zod";
|
|
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
|
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
|
import { estimateJavaScriptStaticApplicationProjection } from "../../domain/javascript/javascriptApplicationProjectionResources.js";
|
|
import type { JavaScriptModuleSemanticIr } from "../../domain/javascript/javascriptModuleSemanticIr.js";
|
|
import type {
|
|
JavaScriptAnalyzedSource,
|
|
JavaScriptSourceAnalysisFailure,
|
|
JavaScriptSourceAnalysisLimits,
|
|
JavaScriptRevokedStaticProjection,
|
|
} from "../../domain/javascript/javascriptSourceAnalysis.js";
|
|
import type { JavaScriptPackageType } from "../../domain/javascript/javascriptSourceParser.js";
|
|
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisTimeoutError,
|
|
AnalysisOutputError,
|
|
AnalysisResourceConstraintError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
|
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
|
import { ok, err, type Result } from "../../domain/result.js";
|
|
import { javaScriptRuntime } from "../../domain/runtimeVersion.js";
|
|
import {
|
|
RuntimeControlUnavailableError,
|
|
runtimeControlCapabilityError,
|
|
} from "../../process/RuntimeControlUnavailableError.js";
|
|
import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js";
|
|
import {
|
|
spawnOwnedProviderProcess,
|
|
type ProviderProcessDiagnostic,
|
|
type ProviderProcessSupervisor,
|
|
} from "../../process/ProviderProcess.js";
|
|
import {
|
|
OwnedWorkerRuntime,
|
|
OwnedWorkerRuntimeCleanupError,
|
|
WorkerCleanupRegistry,
|
|
processWorkerCleanup,
|
|
} from "../../process/OwnedWorkerRuntime.js";
|
|
import { prepareProcessOwnershipInspection } from "../../process/ProcessOwnershipObservation.js";
|
|
import {
|
|
ProviderStartupDeadline,
|
|
waitForAbortableDelay,
|
|
} from "../../process/ProviderDeadline.js";
|
|
import {
|
|
readJavaScriptAnalysisTransfer,
|
|
JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES,
|
|
javaScriptForwardedTransferRemediation,
|
|
javaScriptTransferRetentionBytes,
|
|
} from "./JavaScriptAnalysisTransfer.js";
|
|
import {
|
|
JAVASCRIPT_PARENT_CHECKPOINT_HEAP_REMEDIATION,
|
|
JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION,
|
|
JAVASCRIPT_WORKER_HEAP_MIB_MAXIMUM,
|
|
JAVASCRIPT_WORKER_HEAP_MIB_MINIMUM,
|
|
javaScriptWorkerHeapExhaustionRecovery,
|
|
javaScriptWorkerHeapRemediation,
|
|
} from "../../domain/javascript/javascriptAnalysisResourceControls.js";
|
|
import { javaScriptWorkerStaticAnalysisSchema } from "./JavaScriptAnalysisWorkerSchemas.js";
|
|
import {
|
|
JAVASCRIPT_ANALYSIS_WORKER_DEFAULTS as DEFAULTS,
|
|
javaScriptCheckpointCeilingRemediation,
|
|
javaScriptWorkerMessageSchema,
|
|
javaScriptWorkerRequestSchema,
|
|
javaScriptWorkerAdmissionSchema,
|
|
type JavaScriptWorkerMessage,
|
|
} from "./JavaScriptAnalysisWorkerProtocol.js";
|
|
|
|
const OPERATION = "analyze_javascript_application";
|
|
const PROVIDER = "javascript-static-analysis";
|
|
const MIB = 1024 * 1024;
|
|
const STATIC_CHECKPOINT_RETENTION_EXPANSION = 6;
|
|
/**
|
|
* Share of the parent's free heap that static checkpoints may fill once the
|
|
* result budget is used. Unlike the live heap, the charges also cover application
|
|
* relationships that are reserved but not yet materialized.
|
|
*/
|
|
const STATIC_CAPACITY_SHARE = 0.75;
|
|
/** The worker runs on this executable; Bun accepts but ignores V8 heap flags. */
|
|
const WORKER_HEAP_FLAGS_ENFORCED =
|
|
javaScriptRuntime(process.versions).id === "node";
|
|
|
|
type WorkerPrimaryFailure =
|
|
| { readonly kind: "memory" }
|
|
| { readonly kind: "output"; readonly reason: string }
|
|
| { readonly kind: "input"; readonly cause: Error }
|
|
| {
|
|
readonly kind: "protocol";
|
|
readonly id: string;
|
|
readonly phase: string;
|
|
readonly response: Extract<JavaScriptWorkerMessage, { kind: "failure" }>;
|
|
}
|
|
| {
|
|
readonly kind: "exit";
|
|
readonly code: number | null;
|
|
readonly signal: NodeJS.Signals | null;
|
|
readonly heapExhaustion: boolean;
|
|
};
|
|
|
|
/** One sequential owned worker, retained between source files and closed after analysis. */
|
|
export class JavaScriptAnalysisWorker {
|
|
readonly #runId = `rea-javascript-analysis-${randomUUID()}`;
|
|
readonly #runtime: OwnedWorkerRuntime;
|
|
#ready = false;
|
|
#heapLimitBytes = 0;
|
|
#frames: JavaScriptWorkerMessage[] = [];
|
|
readonly #frameSignal = new WorkerFrameSignal();
|
|
#pending = "";
|
|
#decoder = new StringDecoder("utf8");
|
|
#primaryFailure: WorkerPrimaryFailure | undefined;
|
|
#activeJobId: string | undefined;
|
|
#activeJobPhase = "startup";
|
|
#staticResponseId: string | undefined;
|
|
#terminalResponseId: string | undefined;
|
|
#phase = "startup";
|
|
#lastFailure: AnalysisError | undefined;
|
|
#estimatedRetainedBytes = 0;
|
|
#staticProjectionReservations: {
|
|
sourceIndex: number;
|
|
path: string;
|
|
bytes: number;
|
|
}[] = [];
|
|
#revokedStaticProjections: JavaScriptRevokedStaticProjection[] = [];
|
|
#nextSourceIndex = 0;
|
|
#applicationProjectionFailure: AnalysisResourceConstraintError | undefined;
|
|
#parentCapacityExhaustion: Readonly<Record<string, number>> | undefined;
|
|
#sourcesRemaining: number | undefined;
|
|
#deadline: ProviderStartupDeadline | undefined;
|
|
#resultBudgetBytes = 0;
|
|
#staticCapacityBytes = 0;
|
|
/** Null when the runtime enforces the V8 heap flags instead. */
|
|
readonly #processMemoryLimitBytes: number | null;
|
|
readonly #onStdout = (chunk: Buffer | string): void => {
|
|
if (this.#deadline?.interruption !== undefined) return;
|
|
this.#observeMemoryLimit();
|
|
try {
|
|
this.#pending +=
|
|
typeof chunk === "string" ? chunk : this.#decoder.write(chunk);
|
|
let newline: number;
|
|
while ((newline = this.#pending.indexOf("\n")) >= 0) {
|
|
const line = this.#pending.slice(0, newline);
|
|
this.#pending = this.#pending.slice(newline + 1);
|
|
if (
|
|
Buffer.byteLength(line) > DEFAULTS.protocolBytes ||
|
|
this.#frames.length >= 16
|
|
) {
|
|
this.#latchOutputFailure(
|
|
"JavaScript worker exceeded its control-frame budget",
|
|
);
|
|
return;
|
|
}
|
|
const raw: unknown = JSON.parse(line);
|
|
const frame = javaScriptWorkerMessageSchema.parse(raw);
|
|
this.#frames.push(frame);
|
|
this.#observeJobFrame(frame);
|
|
this.#frameSignal.wake("frame");
|
|
}
|
|
if (Buffer.byteLength(this.#pending) > DEFAULTS.protocolBytes)
|
|
this.#latchOutputFailure(
|
|
"JavaScript worker control frame is unterminated or oversized",
|
|
);
|
|
} catch (cause: unknown) {
|
|
this.#latchOutputFailure(message(cause));
|
|
}
|
|
};
|
|
|
|
readonly #workerCleanup: WorkerCleanupRegistry;
|
|
|
|
constructor(
|
|
readonly limits: JavaScriptSourceAnalysisLimits = DEFAULTS,
|
|
readonly launcher: typeof spawnOwnedProviderProcess = spawnOwnedProviderProcess,
|
|
sourceCount?: number,
|
|
workerCleanup: WorkerCleanupRegistry = processWorkerCleanup,
|
|
) {
|
|
this.#workerCleanup = workerCleanup;
|
|
this.#runtime = new OwnedWorkerRuntime({
|
|
rootPrefix: "rea-javascript-analysis-",
|
|
launcher,
|
|
cleanupRegistry: workerCleanup,
|
|
supervisorOptions: {
|
|
captureStdout: false,
|
|
maxDiagnosticBytes: 256 * 1024,
|
|
onDiagnostic: (event) => this.#observeProcessDiagnostic(event),
|
|
},
|
|
onInputFailure: (cause) => this.#latchInputFailure(cause),
|
|
});
|
|
if (
|
|
!Number.isSafeInteger(limits.heapMb) ||
|
|
limits.heapMb < JAVASCRIPT_WORKER_HEAP_MIB_MINIMUM ||
|
|
limits.heapMb > JAVASCRIPT_WORKER_HEAP_MIB_MAXIMUM ||
|
|
!Number.isSafeInteger(limits.timeoutMs) ||
|
|
limits.timeoutMs <= 0 ||
|
|
limits.timeoutMs > 2_147_483_647
|
|
)
|
|
throw new RangeError(
|
|
"JavaScript worker requires valid heap and execution limits",
|
|
);
|
|
if (
|
|
sourceCount !== undefined &&
|
|
(!Number.isSafeInteger(sourceCount) || sourceCount < 0)
|
|
)
|
|
throw new RangeError(
|
|
"JavaScript worker source count must be a nonnegative safe integer",
|
|
);
|
|
this.#sourcesRemaining = sourceCount;
|
|
this.#processMemoryLimitBytes = javaScriptWorkerMemoryLimitBytes(
|
|
limits.heapMb,
|
|
);
|
|
this.#refreshResultBudget();
|
|
}
|
|
|
|
/**
|
|
* Whether this idle process can analyze another application. A failed,
|
|
* cancelled, or still-open job must not be reused.
|
|
*/
|
|
canReuse(): boolean {
|
|
return (
|
|
this.#ready &&
|
|
this.#activeJobId === undefined &&
|
|
this.#primaryFailure === undefined &&
|
|
this.#lastFailure === undefined &&
|
|
this.#frames.length === 0 &&
|
|
this.#pending === "" &&
|
|
this.#runtime.inputFailure === undefined &&
|
|
!this.#runtime.hasPendingCleanup &&
|
|
this.#runtime.supervisor !== undefined
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reset per-analysis admission for one idle process. The worker heap limit
|
|
* stays the one selected at launch; callers must not reuse across limits.
|
|
*/
|
|
prepareForAnotherAnalysis(sourceCount: number): boolean {
|
|
if (!this.canReuse()) return false;
|
|
if (!Number.isSafeInteger(sourceCount) || sourceCount < 0)
|
|
throw new RangeError(
|
|
"JavaScript worker source count must be a nonnegative safe integer",
|
|
);
|
|
this.#sourcesRemaining = sourceCount;
|
|
this.#estimatedRetainedBytes = 0;
|
|
this.#staticProjectionReservations = [];
|
|
this.#revokedStaticProjections = [];
|
|
this.#nextSourceIndex = 0;
|
|
this.#parentCapacityExhaustion = undefined;
|
|
this.#applicationProjectionFailure = undefined;
|
|
this.#refreshResultBudget();
|
|
return true;
|
|
}
|
|
|
|
/** Keep the limit and usage in one V8 observation for every admission path. */
|
|
#remainingParentHeapBytes(): number {
|
|
const { heap_size_limit, used_heap_size } = getHeapStatistics();
|
|
return heap_size_limit - used_heap_size;
|
|
}
|
|
|
|
/**
|
|
* Reserve host-side control, schema/Evidence work, and a maximum record's
|
|
* transient copies. Everything retained is charged against what is left.
|
|
*
|
|
* Semantic and application results stop at half of it. Static checkpoints
|
|
* may go on to a larger share while the live heap has room for them, so a
|
|
* charge for earlier results cannot cost a later source its static facts.
|
|
*/
|
|
#refreshResultBudget(): void {
|
|
const capacity = Math.max(
|
|
0,
|
|
this.#remainingParentHeapBytes() -
|
|
24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES,
|
|
);
|
|
this.#resultBudgetBytes = Math.floor(capacity / 2);
|
|
this.#staticCapacityBytes = Math.floor(capacity * STATIC_CAPACITY_SHARE);
|
|
}
|
|
|
|
/** Analyze inert source; hard worker failure does not terminate this process. */
|
|
async analyze(
|
|
file: JavaScriptArtifactFile,
|
|
capacity: JavaScriptSemanticProjectionCapacity,
|
|
signal?: AbortSignal,
|
|
onPhase?: (phase: string) => Promise<void>,
|
|
packageType: JavaScriptPackageType = "unknown",
|
|
purpose: "analysis" | "preload-discovery" = "analysis",
|
|
): Promise<
|
|
Result<JavaScriptAnalyzedSource, JavaScriptSourceAnalysisFailure>
|
|
> {
|
|
let javascript: JavaScriptStaticAnalysis | null = null;
|
|
let module: JavaScriptModuleSemanticIr | null = null;
|
|
let projection: JavaScriptSemanticFileProjection | null = null;
|
|
const id = randomUUID();
|
|
const sourceIndex = this.#nextSourceIndex++;
|
|
this.#applicationProjectionFailure = undefined;
|
|
this.#parentCapacityExhaustion = undefined;
|
|
this.#revokedStaticProjections = [];
|
|
if (this.#sourcesRemaining !== undefined)
|
|
this.#sourcesRemaining = Math.max(0, this.#sourcesRemaining - 1);
|
|
let deadline: ProviderStartupDeadline | undefined;
|
|
let result: Result<
|
|
JavaScriptAnalyzedSource,
|
|
JavaScriptSourceAnalysisFailure
|
|
>;
|
|
try {
|
|
if (signal?.aborted === true)
|
|
throw new AnalysisCancelledError(OPERATION, { cause: signal.reason });
|
|
if (!file.text.included || file.kind !== "javascript")
|
|
throw new TypeError(
|
|
"JavaScript analysis worker requires included JavaScript text",
|
|
);
|
|
// Host ownership preparation is outside the parser execution deadline.
|
|
await prepareProcessOwnershipInspection(signal);
|
|
await this.#workerCleanup.retry();
|
|
deadline = new ProviderStartupDeadline(this.limits.timeoutMs, signal);
|
|
this.#deadline = deadline;
|
|
await this.#start(deadline);
|
|
const rootPath = this.#runtime.rootPath;
|
|
const supervisor = this.#runtime.supervisor;
|
|
if (rootPath === undefined || supervisor === undefined)
|
|
throw new TypeError("JavaScript worker resources were not acquired");
|
|
if (this.#frames.length !== 0 || this.#pending !== "")
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
"JavaScript worker has unsolicited or incomplete control frames",
|
|
);
|
|
supervisor.resetOutput();
|
|
this.#phase = "source-transfer";
|
|
await writeJavaScriptAnalysisSourceTransfer(
|
|
join(rootPath, `${id}.source`),
|
|
file.text.value,
|
|
{ signal: deadline.signal },
|
|
);
|
|
const { text: _text, ...metadata } = file;
|
|
let offeredResultBudget = 0;
|
|
const request = javaScriptWorkerRequestSchema.parse({
|
|
id,
|
|
node_budget: capacity.nodes,
|
|
unknown_budget: capacity.unknowns,
|
|
text_sha256: createHash("sha256").update(file.text.value).digest("hex"),
|
|
file: metadata,
|
|
package_type: packageType,
|
|
purpose,
|
|
});
|
|
const frame = `${JSON.stringify(request)}\n`;
|
|
if (Buffer.byteLength(frame) > DEFAULTS.protocolBytes)
|
|
throw new AnalysisResourceConstraintError(
|
|
OPERATION,
|
|
"transport",
|
|
`Source identity for ${file.path} exceeds the ${String(DEFAULTS.protocolBytes)}-byte worker request ceiling. This release has no runtime override.`,
|
|
{ worker_request_bytes: DEFAULTS.protocolBytes },
|
|
{
|
|
remediationAction: `This release has no runtime override for the ${String(DEFAULTS.protocolBytes)}-byte JavaScript worker request ceiling. Raising max_heap_mb does not raise it. Repeating the unchanged request fails the same way.`,
|
|
},
|
|
);
|
|
const stdin = supervisor.launch.process.stdin;
|
|
if (stdin === undefined || stdin === null)
|
|
throw new TypeError("JavaScript worker has no writable request stream");
|
|
this.#activeJobId = id;
|
|
this.#activeJobPhase = this.#phase;
|
|
this.#staticResponseId = undefined;
|
|
this.#terminalResponseId = undefined;
|
|
await new Promise<void>((resolve, reject) =>
|
|
stdin.write(frame, (error) =>
|
|
error === null || error === undefined ? resolve() : reject(error),
|
|
),
|
|
);
|
|
for (;;) {
|
|
const response = await this.#next(deadline, file.path);
|
|
// A ready frame is outside this job. A frame for another job is not
|
|
// this request's result; keep waiting so a later caller abort is
|
|
// still cancellation.
|
|
if (response.kind === "ready")
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
"JavaScript worker response names a different source job",
|
|
);
|
|
if (response.id !== id) continue;
|
|
if (response.kind === "phase") {
|
|
this.#phase = response.phase;
|
|
await onPhase?.(response.phase);
|
|
continue;
|
|
}
|
|
if (response.kind === "static") {
|
|
if (javascript !== null)
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
"JavaScript worker emitted a duplicate static checkpoint",
|
|
);
|
|
javascript = await this.#readCheckpoint(
|
|
id,
|
|
file.path,
|
|
sourceIndex,
|
|
response,
|
|
);
|
|
const exhausted = this.#parentCapacityFailure(file.path);
|
|
if (exhausted !== undefined) throw exhausted;
|
|
offeredResultBudget =
|
|
this.#applicationProjectionFailure !== undefined &&
|
|
this.#sourcesRemaining !== undefined &&
|
|
this.#sourcesRemaining > 0
|
|
? 0
|
|
: this.#remainingResultBudget();
|
|
const admission = javaScriptWorkerAdmissionSchema.parse({
|
|
id,
|
|
semantic_result_budget_bytes: offeredResultBudget,
|
|
});
|
|
await new Promise<void>((resolve, reject) =>
|
|
stdin.write(`${JSON.stringify(admission)}\n`, (error) =>
|
|
error === null || error === undefined ? resolve() : reject(error),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
if (response.kind === "failure") {
|
|
if (
|
|
this.#primaryFailure?.kind === "protocol" &&
|
|
this.#primaryFailure.id === response.id
|
|
)
|
|
this.#primaryFailure = undefined;
|
|
if (response.reason === "resource") {
|
|
result = err({
|
|
javascript,
|
|
module,
|
|
projection,
|
|
error: this.#protocolFailure(file.path, response, this.#phase),
|
|
});
|
|
break;
|
|
}
|
|
throw this.#protocolFailure(file.path, response, this.#phase);
|
|
}
|
|
if (javascript === null)
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
"JavaScript worker completed before a static checkpoint",
|
|
);
|
|
if (response.descriptor === null) {
|
|
result = ok({ javascript, module: null, projection: null });
|
|
break;
|
|
}
|
|
const descriptor = response.descriptor;
|
|
// JSON bytes alone omit object/array overhead and the later graph/Evidence
|
|
// sealing/serialization copies. Reserve a conservative expansion budget.
|
|
const moduleEstimate = javaScriptTransferRetentionBytes(
|
|
descriptor.module_bytes,
|
|
descriptor.module_records,
|
|
);
|
|
const graphEstimate = javaScriptTransferRetentionBytes(
|
|
descriptor.graph_bytes,
|
|
descriptor.graph_records,
|
|
);
|
|
const remaining = Math.min(
|
|
offeredResultBudget,
|
|
this.#remainingResultBudget(),
|
|
);
|
|
if (moduleEstimate > remaining) {
|
|
result = err({
|
|
javascript,
|
|
module: null,
|
|
projection: null,
|
|
error: new AnalysisResourceConstraintError(
|
|
OPERATION,
|
|
"memory",
|
|
`Completed module facts for ${file.path} exceed the parent's remaining result budget; static facts were retained`,
|
|
{
|
|
...this.#limits(),
|
|
parent_result_budget_bytes: this.#resultBudgetBytes,
|
|
parent_available_result_budget_bytes: remaining,
|
|
module_transfer_bytes: descriptor.module_bytes,
|
|
module_retention_estimate_bytes: moduleEstimate,
|
|
},
|
|
{
|
|
remediationAction: `Use the retained static facts or analyze fewer source files. ${JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION}`,
|
|
},
|
|
),
|
|
});
|
|
break;
|
|
}
|
|
const retainGraph = moduleEstimate + graphEstimate <= remaining;
|
|
this.#phase = "result-admission";
|
|
const decoded = await readJavaScriptAnalysisTransfer(
|
|
join(rootPath, `${id}.records`),
|
|
descriptor,
|
|
file,
|
|
deadline.signal,
|
|
retainGraph ? capacity : null,
|
|
);
|
|
module = decoded.module;
|
|
projection = decoded.projection;
|
|
this.#estimatedRetainedBytes +=
|
|
moduleEstimate + (retainGraph ? graphEstimate : 0);
|
|
result = retainGraph
|
|
? ok({ javascript, module, projection: decoded.projection })
|
|
: err({
|
|
javascript,
|
|
module,
|
|
projection,
|
|
error: new AnalysisResourceConstraintError(
|
|
OPERATION,
|
|
"memory",
|
|
`Semantic graph for ${file.path} exceeds the parent's remaining result budget; static and module facts were retained`,
|
|
{
|
|
...this.#limits(),
|
|
parent_result_budget_bytes: this.#resultBudgetBytes,
|
|
parent_available_result_budget_bytes: remaining,
|
|
module_retention_estimate_bytes: moduleEstimate,
|
|
graph_retention_estimate_bytes: graphEstimate,
|
|
graph_transfer_bytes: descriptor.graph_bytes,
|
|
graph_records: descriptor.graph_records,
|
|
},
|
|
{
|
|
remediationAction: `Use the retained static and module facts or analyze fewer source files. ${JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION}`,
|
|
},
|
|
),
|
|
});
|
|
break;
|
|
}
|
|
// An allocation failure may surface as a protocol failure or be caught
|
|
// inside the worker; any reached limit makes this file's result partial.
|
|
this.#observeMemoryLimit();
|
|
if (this.#primaryFailure?.kind === "memory")
|
|
throw this.#projectPrimaryFailure(file.path);
|
|
} catch (cause: unknown) {
|
|
const error = this.#failure(cause, file.path, deadline, signal);
|
|
if (
|
|
this.#primaryFailure !== undefined &&
|
|
deadline?.interruption === "cancelled"
|
|
)
|
|
error.retainSecondaryFailure(
|
|
new AnalysisCancelledError(OPERATION, {
|
|
cause: deadline.signal.reason,
|
|
capturedOutput: this.#captured(),
|
|
}),
|
|
);
|
|
this.#lastFailure = error;
|
|
// Complete checkpoints remain readable after a child crash or cancellation.
|
|
const cleanup = await this.#stop(error);
|
|
if (
|
|
cleanup === undefined &&
|
|
javascript === null &&
|
|
this.#runtime.rootPath !== undefined
|
|
) {
|
|
try {
|
|
javascript = await this.#readCheckpoint(id, file.path, sourceIndex);
|
|
} catch (checkpointCause: unknown) {
|
|
void checkpointCause;
|
|
}
|
|
}
|
|
result = err({ error: cleanup ?? error, javascript, module, projection });
|
|
} finally {
|
|
deadline?.dispose();
|
|
if (this.#deadline === deadline) this.#deadline = undefined;
|
|
if (this.#activeJobId === id) this.#activeJobId = undefined;
|
|
}
|
|
if (result.ok) this.#lastFailure = undefined;
|
|
else if (!(result.error.error instanceof ProviderCleanupError))
|
|
this.#lastFailure = result.error.error;
|
|
// Child stop must be verified before temporary files are released. On
|
|
// cleanup uncertainty, close retains this owner for a later retry.
|
|
const rootPath = this.#runtime.rootPath;
|
|
if (rootPath !== undefined && !this.#runtime.hasPendingCleanup) {
|
|
try {
|
|
for (const suffix of ["source", "static.json", "records"])
|
|
await rm(join(rootPath, `${id}.${suffix}`), { force: true });
|
|
} catch (cause: unknown) {
|
|
this.#runtime.retainForCleanup();
|
|
const previous = result.ok ? undefined : result.error.error;
|
|
const primary = signal?.aborted
|
|
? analysisErrorWithCancellation(
|
|
previous,
|
|
new AnalysisCancelledError(OPERATION, {
|
|
cause: signal.reason,
|
|
capturedOutput: this.#captured(),
|
|
}),
|
|
)
|
|
: previous;
|
|
const error = this.#cleanupError(message(cause), primary);
|
|
this.#runtime.bindCleanupFailure(error);
|
|
result = err({
|
|
javascript,
|
|
module,
|
|
projection,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
if (signal?.aborted === true) {
|
|
const error = analysisErrorWithCancellation(
|
|
result.ok ? undefined : result.error.error,
|
|
new AnalysisCancelledError(OPERATION, {
|
|
cause: signal.reason,
|
|
capturedOutput: this.#captured(),
|
|
}),
|
|
);
|
|
if (result.ok) this.#lastFailure = error;
|
|
result = err({ error, javascript, module, projection });
|
|
}
|
|
if (
|
|
this.#applicationProjectionFailure !== undefined ||
|
|
this.#revokedStaticProjections.length > 0
|
|
)
|
|
return result.ok
|
|
? ok({
|
|
...result.value,
|
|
...(this.#applicationProjectionFailure === undefined
|
|
? {}
|
|
: {
|
|
applicationProjectionFailure:
|
|
this.#applicationProjectionFailure,
|
|
}),
|
|
...(this.#revokedStaticProjections.length === 0
|
|
? {}
|
|
: { revokedStaticProjections: this.#revokedStaticProjections }),
|
|
})
|
|
: err({
|
|
...result.error,
|
|
...(this.#applicationProjectionFailure === undefined
|
|
? {}
|
|
: {
|
|
applicationProjectionFailure:
|
|
this.#applicationProjectionFailure,
|
|
}),
|
|
...(this.#revokedStaticProjections.length === 0
|
|
? {}
|
|
: { revokedStaticProjections: this.#revokedStaticProjections }),
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/** Verify process cleanup, then release the private runtime; failed close is retryable. */
|
|
async close(): Promise<void> {
|
|
try {
|
|
await this.#runtime.close();
|
|
this.#ready = false;
|
|
this.#resetState();
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof OwnedWorkerRuntimeCleanupError) {
|
|
const error = this.#cleanupError(
|
|
cause.message,
|
|
this.#lastFailure,
|
|
cause.cleanupResources,
|
|
cause.processId,
|
|
);
|
|
cause.bindCleanupFailure(error);
|
|
throw error;
|
|
}
|
|
throw cause;
|
|
}
|
|
}
|
|
|
|
async #start(deadline: ProviderStartupDeadline): Promise<void> {
|
|
if (this.#runtime.supervisor !== undefined && this.#ready) return;
|
|
this.#phase = "startup";
|
|
this.#heapLimitBytes = 0;
|
|
const environment: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
UV_THREADPOOL_SIZE: "1",
|
|
};
|
|
delete environment.NODE_OPTIONS;
|
|
await this.#runtime.start(
|
|
{
|
|
command: process.execPath,
|
|
arguments: [
|
|
`--max-old-space-size=${String(this.limits.heapMb)}`,
|
|
"--max-semi-space-size=8",
|
|
"--v8-pool-size=1",
|
|
"--expose-gc",
|
|
"--stack_size=2048",
|
|
fileURLToPath(import.meta.resolve("#javascript-analysis-worker")),
|
|
],
|
|
runId: `${this.#runId}-${randomUUID()}`,
|
|
hostEnvironment: environment,
|
|
stdin: "pipe",
|
|
...(this.#processMemoryLimitBytes === null
|
|
? {}
|
|
: { memoryLimitBytes: this.#processMemoryLimitBytes }),
|
|
signal: deadline.signal,
|
|
},
|
|
async () => {
|
|
this.#runtime.onStdout(this.#onStdout);
|
|
const ready = await this.#next(deadline, "worker startup");
|
|
if (ready.kind !== "ready")
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
"JavaScript worker emitted facts before its startup handshake",
|
|
);
|
|
if ((ready.heap_limit_bytes === null) === WORKER_HEAP_FLAGS_ENFORCED)
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
"JavaScript worker heap limit does not match the runtime that launched it",
|
|
);
|
|
this.#heapLimitBytes = ready.heap_limit_bytes ?? 0;
|
|
this.#ready = true;
|
|
},
|
|
);
|
|
}
|
|
|
|
async #next(
|
|
deadline: ProviderStartupDeadline,
|
|
path: string,
|
|
): Promise<JavaScriptWorkerMessage> {
|
|
for (;;) {
|
|
if (deadline.interruption !== undefined) {
|
|
const failure = this.#interruptionFailure(path, deadline);
|
|
throw failure;
|
|
}
|
|
this.#observeMemoryLimit();
|
|
const inputFailure = this.#runtime.inputFailure;
|
|
if (inputFailure !== undefined) this.#latchInputFailure(inputFailure);
|
|
if (
|
|
this.#primaryFailure !== undefined &&
|
|
this.#primaryFailure.kind !== "exit" &&
|
|
this.#primaryFailure.kind !== "protocol"
|
|
)
|
|
throw this.#projectPrimaryFailure(path);
|
|
const frame = this.#frames.shift();
|
|
if (frame !== undefined) return frame;
|
|
const supervisor = this.#runtime.supervisor;
|
|
const snapshot = supervisor?.snapshot();
|
|
if (
|
|
supervisor !== undefined &&
|
|
snapshot !== undefined &&
|
|
(snapshot.exitCode !== undefined || snapshot.signal !== undefined)
|
|
) {
|
|
// Exit does not complete the protocol: descendants may still hold its pipes
|
|
// or write a final response. The operation deadline bounds this wait.
|
|
const outcome = await this.#waitForFrameOrOutputClose(
|
|
supervisor,
|
|
deadline,
|
|
);
|
|
if (outcome === "interrupted") {
|
|
const completedFrame = this.#frames.shift();
|
|
if (completedFrame !== undefined) return completedFrame;
|
|
throw this.#interruptionFailure(path, deadline);
|
|
}
|
|
if (outcome === "failure") {
|
|
const bufferedFrame = this.#frames.shift();
|
|
if (bufferedFrame !== undefined) return bufferedFrame;
|
|
throw this.#selectedWorkerFailure(path);
|
|
}
|
|
const finalFrame = this.#frames.shift();
|
|
if (finalFrame !== undefined) return finalFrame;
|
|
if (outcome === "frame") continue;
|
|
throw this.#selectedWorkerFailure(path);
|
|
}
|
|
await waitForAbortableDelay(10, deadline.signal);
|
|
}
|
|
}
|
|
|
|
async #readCheckpoint(
|
|
id: string,
|
|
sourcePath: string,
|
|
sourceIndex: number,
|
|
expected?: { readonly bytes: number; readonly sha256: string },
|
|
): Promise<JavaScriptStaticAnalysis> {
|
|
const rootPath = this.#runtime.rootPath;
|
|
if (rootPath === undefined)
|
|
throw new TypeError("JavaScript checkpoint has no owned runtime");
|
|
const path = join(rootPath, `${id}.static.json`);
|
|
const bytes = (await stat(path)).size;
|
|
if (
|
|
bytes > DEFAULTS.checkpointBytes ||
|
|
(expected !== undefined && expected.bytes !== bytes)
|
|
)
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
"JavaScript static checkpoint byte count exceeds or differs from its declared budget",
|
|
);
|
|
if (bytes * 16 + 4 * MIB > this.#remainingParentHeapBytes() - 64 * MIB)
|
|
throw new AnalysisResourceConstraintError(
|
|
OPERATION,
|
|
"memory",
|
|
"The parent's remaining heap cannot safely decode the completed JavaScript static checkpoint",
|
|
{ ...this.#limits(), checkpoint_bytes: bytes },
|
|
{
|
|
remediationAction: JAVASCRIPT_PARENT_CHECKPOINT_HEAP_REMEDIATION,
|
|
},
|
|
);
|
|
const checkpoint = await readFile(path);
|
|
if (
|
|
expected !== undefined &&
|
|
createHash("sha256").update(checkpoint).digest("hex") !== expected.sha256
|
|
)
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
"JavaScript static checkpoint digest does not match",
|
|
);
|
|
const decoded = safeParseUtf8Json(checkpoint);
|
|
if (!decoded.ok) throw decoded.cause;
|
|
const facts = javaScriptWorkerStaticAnalysisSchema.parse(decoded.value);
|
|
const retainedBytes = bytes * STATIC_CHECKPOINT_RETENTION_EXPANSION;
|
|
// Application relationships are materialized only after all source files
|
|
// have been examined. Reclaim their pending reservations to keep room for
|
|
// later static observations, without relaxing the actual retained bound.
|
|
const { heap_size_limit, used_heap_size } = getHeapStatistics();
|
|
const liveAvailable =
|
|
heap_size_limit -
|
|
used_heap_size -
|
|
24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES;
|
|
// Past the result budget, the live heap must also keep room for another
|
|
// copy of what it holds, which final graph and Evidence work needs. The
|
|
// limit read here counts young-generation capacity that retained objects
|
|
// cannot use, so being merely below it does not protect the parent.
|
|
const staticCeiling =
|
|
retainedBytes + used_heap_size <= liveAvailable
|
|
? this.#staticCapacityBytes
|
|
: this.#resultBudgetBytes;
|
|
const needed =
|
|
retainedBytes - (staticCeiling - this.#estimatedRetainedBytes);
|
|
if (needed > 0 && retainedBytes <= liveAvailable) {
|
|
// Plan all revocations first so an insufficient set leaves the earlier
|
|
// relationships intact rather than sacrificing them for a failed read.
|
|
let start = this.#staticProjectionReservations.length;
|
|
let reclaimable = 0;
|
|
while (start > 0 && reclaimable < needed) {
|
|
start--;
|
|
reclaimable += this.#staticProjectionReservations[start]!.bytes;
|
|
}
|
|
if (reclaimable >= needed) {
|
|
const revoked = this.#staticProjectionReservations.splice(start);
|
|
for (const reservation of revoked) {
|
|
this.#estimatedRetainedBytes -= reservation.bytes;
|
|
this.#revokedStaticProjections.push({
|
|
sourceIndex: reservation.sourceIndex,
|
|
error: new AnalysisResourceConstraintError(
|
|
OPERATION,
|
|
"memory",
|
|
`Expanded application relationships for ${reservation.path} were omitted to retain static facts for later sources`,
|
|
{ parent_result_budget_bytes: this.#resultBudgetBytes },
|
|
{
|
|
remediationAction: `Read the retained static facts or analyze fewer source files. ${JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION}`,
|
|
},
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// Use the same live-heap observation for the reclamation decision and
|
|
// admission so a second read cannot reject after revocations commit.
|
|
const remaining = Math.max(
|
|
0,
|
|
Math.min(staticCeiling - this.#estimatedRetainedBytes, liveAvailable),
|
|
);
|
|
this.#parentCapacityExhaustion =
|
|
retainedBytes > remaining
|
|
? {
|
|
parent_static_capacity_bytes: staticCeiling,
|
|
parent_available_static_capacity_bytes: remaining,
|
|
parent_used_heap_bytes: used_heap_size,
|
|
static_retention_estimate_bytes: retainedBytes,
|
|
}
|
|
: undefined;
|
|
this.#estimatedRetainedBytes += retainedBytes;
|
|
const expandedBytes = estimateJavaScriptStaticApplicationProjection(
|
|
facts,
|
|
bytes,
|
|
sourcePath,
|
|
);
|
|
const availableResultBudget = this.#remainingResultBudget();
|
|
if (expandedBytes <= availableResultBudget) {
|
|
this.#estimatedRetainedBytes += expandedBytes;
|
|
this.#staticProjectionReservations.push({
|
|
sourceIndex,
|
|
path: sourcePath,
|
|
bytes: expandedBytes,
|
|
});
|
|
} else
|
|
this.#applicationProjectionFailure = new AnalysisResourceConstraintError(
|
|
OPERATION,
|
|
"memory",
|
|
`Expanded application relationships for ${sourcePath} exceed the parent's result budget; complete static facts were retained on the file node`,
|
|
{
|
|
parent_result_budget_bytes: this.#resultBudgetBytes,
|
|
parent_available_result_budget_bytes: availableResultBudget,
|
|
application_projection_required_bytes: expandedBytes,
|
|
},
|
|
{
|
|
remediationAction: `Read the complete static facts on the file node or analyze fewer source files. Expanded application relationships exceed the parent result budget. ${JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION}`,
|
|
},
|
|
);
|
|
return facts;
|
|
}
|
|
|
|
#parentCapacityFailure(
|
|
path: string,
|
|
): AnalysisResourceConstraintError | undefined {
|
|
if (this.#parentCapacityExhaustion === undefined) return undefined;
|
|
return new AnalysisResourceConstraintError(
|
|
OPERATION,
|
|
"memory",
|
|
`The parent's result capacity was exhausted after retaining static facts for ${path}; remaining sources were not analyzed`,
|
|
{
|
|
...this.#limits(),
|
|
parent_result_budget_bytes: this.#resultBudgetBytes,
|
|
...this.#parentCapacityExhaustion,
|
|
parent_result_budget_exhausted: true,
|
|
},
|
|
{
|
|
remediationAction: JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION,
|
|
},
|
|
);
|
|
}
|
|
|
|
#limits() {
|
|
return {
|
|
...(WORKER_HEAP_FLAGS_ENFORCED
|
|
? {
|
|
worker_old_space_mb: this.limits.heapMb,
|
|
...(this.#heapLimitBytes === 0
|
|
? {}
|
|
: { worker_heap_limit_bytes: this.#heapLimitBytes }),
|
|
}
|
|
: // Bun enforces no worker heap limit; report it as unavailable.
|
|
{ worker_heap_limit_bytes: null }),
|
|
...(this.#processMemoryLimitBytes === null
|
|
? {}
|
|
: {
|
|
worker_process_memory_limit_bytes: this.#processMemoryLimitBytes,
|
|
}),
|
|
worker_timeout_ms: this.limits.timeoutMs,
|
|
};
|
|
}
|
|
#memoryLimitReached(): boolean {
|
|
return this.#runtime.supervisor?.launch.process.memoryLimitReached === true;
|
|
}
|
|
#memoryFailure(path: string): AnalysisResourceConstraintError {
|
|
return new AnalysisResourceConstraintError(
|
|
OPERATION,
|
|
"memory",
|
|
`Owned JavaScript worker reached its ${this.#processMemoryLimitBytes === null ? "" : `${String(this.#processMemoryLimitBytes)}-byte `}process memory limit while analyzing ${path} during ${this.#phase}. That limit is max_heap_mb ${String(this.limits.heapMb)} MiB plus a 512 MiB Bun runtime reserve`,
|
|
this.#limits(),
|
|
{
|
|
capturedOutput: this.#captured(),
|
|
limit: analysisSettingLimit(
|
|
"javascript.max_heap_mb",
|
|
this.limits.heapMb,
|
|
undefined,
|
|
),
|
|
remediationAction: javaScriptWorkerHeapRemediation(this.limits.heapMb),
|
|
},
|
|
);
|
|
}
|
|
#remainingResultBudget(): number {
|
|
// One sequential worker produces one next checkpoint at a time. Reserve
|
|
// its maximum protocol size at the same retained expansion used on receipt;
|
|
// earlier checkpoints are already charged to estimatedRetainedBytes.
|
|
// Reserving every unexamined source's maximum would reject useful results
|
|
// merely because many small files remain. Actual retained growth and live
|
|
// heap capacity are checked again for every checkpoint and transfer.
|
|
const checkpointReserve =
|
|
DEFAULTS.checkpointBytes * STATIC_CHECKPOINT_RETENTION_EXPANSION;
|
|
const staticReserve =
|
|
this.#sourcesRemaining !== undefined && this.#sourcesRemaining > 0
|
|
? checkpointReserve
|
|
: 0;
|
|
return Math.max(0, this.#remainingTotalResultBudget() - staticReserve);
|
|
}
|
|
#remainingTotalResultBudget(): number {
|
|
return Math.max(
|
|
0,
|
|
Math.min(
|
|
this.#resultBudgetBytes - this.#estimatedRetainedBytes,
|
|
this.#remainingParentHeapBytes() -
|
|
24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES,
|
|
),
|
|
);
|
|
}
|
|
#captured() {
|
|
const snapshot = this.#runtime.supervisor?.snapshot();
|
|
const supervisor = this.#runtime.supervisor;
|
|
return {
|
|
stdout: "",
|
|
stderr: snapshot?.stderr.text ?? "",
|
|
truncated:
|
|
snapshot?.diagnosticTruncated === true ||
|
|
snapshot?.outputIncomplete === true ||
|
|
(supervisor !== undefined && !supervisor.observedOutputClose()),
|
|
stderr_bytes: snapshot?.stderr.observedBytes ?? 0,
|
|
exit_code: snapshot?.exitCode ?? null,
|
|
signal: snapshot?.signal ?? null,
|
|
};
|
|
}
|
|
#observeProcessDiagnostic(event: ProviderProcessDiagnostic): void {
|
|
if (this.#deadline?.interruption !== undefined) return;
|
|
this.#observeMemoryLimit();
|
|
if (event.type === "output-close") {
|
|
if (
|
|
this.#deadline !== undefined &&
|
|
(this.#activeJobId === undefined ||
|
|
this.#terminalResponseId !== this.#activeJobId)
|
|
)
|
|
this.#latchOutputFailure(
|
|
"JavaScript worker closed output before the operation received its terminal response",
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
event.type === "output" &&
|
|
event.stream === "stderr" &&
|
|
this.#primaryFailure?.kind === "exit" &&
|
|
/heap out of memory|heap limit|allocation failed/iu.test(
|
|
this.#runtime.supervisor?.snapshot().stderr.text ?? "",
|
|
)
|
|
) {
|
|
this.#primaryFailure = { ...this.#primaryFailure, heapExhaustion: true };
|
|
this.#frameSignal.wake("failure");
|
|
}
|
|
if (event.type === "exit" && (event.code !== 0 || event.signal !== null)) {
|
|
if (this.#primaryFailure === undefined)
|
|
this.#primaryFailure = {
|
|
kind: "exit",
|
|
code: event.code,
|
|
signal: event.signal,
|
|
heapExhaustion:
|
|
/heap out of memory|heap limit|allocation failed/iu.test(
|
|
event.snapshot.stderr.text,
|
|
),
|
|
};
|
|
else if (
|
|
this.#primaryFailure.kind === "exit" &&
|
|
/heap out of memory|heap limit|allocation failed/iu.test(
|
|
event.snapshot.stderr.text,
|
|
)
|
|
) {
|
|
this.#primaryFailure = {
|
|
...this.#primaryFailure,
|
|
heapExhaustion: true,
|
|
};
|
|
this.#frameSignal.wake("failure");
|
|
}
|
|
}
|
|
}
|
|
#observeMemoryLimit(): void {
|
|
if (
|
|
this.#deadline?.interruption === undefined &&
|
|
this.#memoryLimitReached()
|
|
) {
|
|
if (this.#primaryFailure === undefined)
|
|
this.#primaryFailure = { kind: "memory" };
|
|
else if (this.#primaryFailure.kind === "exit")
|
|
this.#primaryFailure = {
|
|
...this.#primaryFailure,
|
|
heapExhaustion: true,
|
|
};
|
|
if (
|
|
this.#primaryFailure?.kind === "memory" ||
|
|
this.#primaryFailure?.kind === "exit"
|
|
)
|
|
this.#frameSignal.wake("failure");
|
|
}
|
|
}
|
|
#observeJobFrame(frame: JavaScriptWorkerMessage): void {
|
|
if (
|
|
this.#deadline?.interruption !== undefined ||
|
|
this.#activeJobId === undefined ||
|
|
frame.kind === "ready"
|
|
)
|
|
return;
|
|
if (frame.id !== this.#activeJobId) return;
|
|
if (frame.kind === "phase") {
|
|
this.#activeJobPhase = frame.phase;
|
|
return;
|
|
}
|
|
if (frame.kind === "static") {
|
|
this.#staticResponseId = frame.id;
|
|
return;
|
|
}
|
|
if (frame.kind === "complete") {
|
|
if (this.#staticResponseId !== frame.id) {
|
|
if (this.#primaryFailure === undefined)
|
|
this.#primaryFailure = {
|
|
kind: "output",
|
|
reason: "JavaScript worker completed before a static checkpoint",
|
|
};
|
|
this.#frameSignal.wake("failure");
|
|
return;
|
|
}
|
|
this.#terminalResponseId = frame.id;
|
|
return;
|
|
}
|
|
if (frame.kind === "failure") this.#terminalResponseId = frame.id;
|
|
if (frame.kind === "failure" && this.#primaryFailure === undefined)
|
|
this.#primaryFailure = {
|
|
kind: "protocol",
|
|
id: frame.id,
|
|
phase: this.#activeJobPhase,
|
|
response: frame,
|
|
};
|
|
}
|
|
#latchOutputFailure(reason: string): void {
|
|
if (this.#deadline?.interruption !== undefined) return;
|
|
if (this.#primaryFailure === undefined)
|
|
this.#primaryFailure = { kind: "output", reason };
|
|
this.#frameSignal.wake("failure");
|
|
}
|
|
#latchInputFailure(cause: Error): void {
|
|
if (this.#deadline?.interruption !== undefined) return;
|
|
if (this.#primaryFailure === undefined)
|
|
this.#primaryFailure = { kind: "input", cause };
|
|
this.#frameSignal.wake("failure");
|
|
}
|
|
async #waitForFrameOrOutputClose(
|
|
supervisor: ProviderProcessSupervisor,
|
|
deadline: ProviderStartupDeadline,
|
|
): Promise<"frame" | "failure" | "closed" | "interrupted"> {
|
|
if (this.#frames.length !== 0) return "frame";
|
|
return this.#frameSignal.waitForOutputClose(supervisor, deadline.signal);
|
|
}
|
|
#selectedWorkerFailure(path: string): AnalysisError {
|
|
if (this.#primaryFailure !== undefined)
|
|
return this.#projectPrimaryFailure(path);
|
|
const snapshot = this.#runtime.supervisor?.snapshot();
|
|
if (snapshot?.exitCode !== undefined || snapshot?.signal !== undefined)
|
|
return this.#workerExitFailure(path, {
|
|
kind: "exit",
|
|
code: snapshot.exitCode ?? null,
|
|
signal: snapshot.signal ?? null,
|
|
heapExhaustion: false,
|
|
});
|
|
return new ProviderAdapterError(PROVIDER, OPERATION, {
|
|
capturedOutput: this.#captured(),
|
|
diagnostics: { source_path: path, phase: this.#phase },
|
|
});
|
|
}
|
|
#interruptionFailure(
|
|
path: string,
|
|
deadline: ProviderStartupDeadline,
|
|
): AnalysisError {
|
|
if (this.#primaryFailure !== undefined)
|
|
return this.#projectPrimaryFailure(path);
|
|
return deadline.interruption === "timeout"
|
|
? new AnalysisTimeoutError(OPERATION, this.limits.timeoutMs, {
|
|
limit: analysisSettingLimit(
|
|
"javascript.analysis_timeout_ms",
|
|
this.limits.timeoutMs,
|
|
undefined,
|
|
),
|
|
capturedOutput: this.#captured(),
|
|
userMessage: `JavaScript source analysis reached its ${String(this.limits.timeoutMs)}ms per-source server/provider deadline (analysis_timeout_ms), not the MCP client deadline.`,
|
|
remediationAction:
|
|
"Reuse accessible partial_observation Evidence to avoid recomputation; reading it is not a prerequisite for a corrected retry. Increase analysis_timeout_ms through MCP, or use rea analyze-javascript-application <input_path> --analysis-timeout-ms <milliseconds> on the CLI. Alternatively, reduce semantic limits or select exact inventoried source_paths (CLI: --source-path on that command). Omit source_paths to analyze all sources; an empty array is invalid. detail: summary reduces response size, not analysis work. Allow execution, cleanup and delivery headroom in the MCP client tool deadline; increasing that client deadline alone does not change this server budget. Resolve any reported incomplete cleanup before retrying.",
|
|
})
|
|
: new AnalysisCancelledError(OPERATION, {
|
|
cause: deadline.signal.reason,
|
|
capturedOutput: this.#captured(),
|
|
});
|
|
}
|
|
#workerExitFailure(
|
|
path: string,
|
|
failure: Extract<WorkerPrimaryFailure, { kind: "exit" }>,
|
|
): AnalysisError {
|
|
if (failure.heapExhaustion) {
|
|
const recovery = javaScriptWorkerHeapExhaustionRecovery({
|
|
heapFlagsEnforced: WORKER_HEAP_FLAGS_ENFORCED,
|
|
heapMb: this.limits.heapMb,
|
|
path,
|
|
phase: this.#phase,
|
|
});
|
|
return new AnalysisResourceConstraintError(
|
|
OPERATION,
|
|
"memory",
|
|
recovery.reason,
|
|
this.#limits(),
|
|
{
|
|
capturedOutput: this.#captured(),
|
|
...(WORKER_HEAP_FLAGS_ENFORCED
|
|
? {
|
|
limit: analysisSettingLimit(
|
|
"javascript.max_heap_mb",
|
|
this.limits.heapMb,
|
|
undefined,
|
|
),
|
|
}
|
|
: {}),
|
|
remediationAction: recovery.remediationAction,
|
|
},
|
|
);
|
|
}
|
|
return new ProviderAdapterError(PROVIDER, OPERATION, {
|
|
capturedOutput: this.#captured(),
|
|
diagnostics: {
|
|
source_path: path,
|
|
phase: this.#phase,
|
|
exit_code: failure.code,
|
|
signal: failure.signal,
|
|
},
|
|
});
|
|
}
|
|
#projectPrimaryFailure(path: string): AnalysisError {
|
|
const failure = this.#primaryFailure;
|
|
if (failure === undefined) return this.#selectedWorkerFailure(path);
|
|
if (failure.kind === "memory") return this.#memoryFailure(path);
|
|
if (failure.kind === "exit") return this.#workerExitFailure(path, failure);
|
|
if (failure.kind === "input")
|
|
return new AnalysisOutputError(
|
|
OPERATION,
|
|
`Owned JavaScript worker request stream failed during ${path}`,
|
|
{ cause: failure.cause, capturedOutput: this.#captured() },
|
|
);
|
|
if (failure.kind === "protocol") {
|
|
return this.#protocolFailure(path, failure.response, failure.phase);
|
|
}
|
|
return new AnalysisOutputError(OPERATION, failure.reason, {
|
|
capturedOutput: this.#captured(),
|
|
});
|
|
}
|
|
#protocolFailure(
|
|
path: string,
|
|
response: Extract<JavaScriptWorkerMessage, { kind: "failure" }>,
|
|
phase: string,
|
|
): AnalysisError {
|
|
if (response.reason === "io")
|
|
return new ProviderAdapterError(PROVIDER, OPERATION, {
|
|
capturedOutput: this.#captured(),
|
|
userMessage: `Owned JavaScript worker storage failed for ${path} during ${phase}. Retained observations and cleanup status are reported separately.`,
|
|
remediationAction:
|
|
"Check the availability and permissions of REA's private temporary storage, correct the reported I/O failure, and retry. Changing the selected source is not required by this storage failure.",
|
|
diagnostics: {
|
|
source_path: path,
|
|
phase,
|
|
worker_failure_reason: response.reason,
|
|
message: response.message,
|
|
},
|
|
});
|
|
if (response.reason === "resource") {
|
|
const reported = response.limits;
|
|
const limits = { ...this.#limits(), ...reported };
|
|
const transferRemediation =
|
|
javaScriptForwardedTransferRemediation(reported);
|
|
const checkpointBytes = reported?.checkpoint_limit_bytes;
|
|
const checkpointRemediation =
|
|
typeof checkpointBytes === "number"
|
|
? javaScriptCheckpointCeilingRemediation(checkpointBytes)
|
|
: undefined;
|
|
const parentBudget =
|
|
reported?.semantic_result_budget_bytes !== undefined ||
|
|
reported?.parent_result_budget_bytes !== undefined;
|
|
const remediationAction =
|
|
transferRemediation ??
|
|
checkpointRemediation ??
|
|
(parentBudget
|
|
? JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION
|
|
: undefined);
|
|
return new AnalysisResourceConstraintError(
|
|
OPERATION,
|
|
response.resource ?? "memory",
|
|
response.message,
|
|
limits,
|
|
remediationAction === undefined ? undefined : { remediationAction },
|
|
);
|
|
}
|
|
return new AnalysisOutputError(
|
|
OPERATION,
|
|
`${path} during ${phase}: ${response.message}`,
|
|
{ capturedOutput: this.#captured() },
|
|
);
|
|
}
|
|
#failure(
|
|
cause: unknown,
|
|
path: string,
|
|
deadline: ProviderStartupDeadline | undefined,
|
|
signal: AbortSignal | undefined,
|
|
): AnalysisError {
|
|
if (
|
|
deadline?.interruption === "cancelled" &&
|
|
cause === deadline.signal.reason
|
|
)
|
|
return this.#interruptionFailure(path, deadline);
|
|
if (
|
|
deadline === undefined &&
|
|
signal?.aborted === true &&
|
|
cause === signal.reason
|
|
)
|
|
return new AnalysisCancelledError(OPERATION, {
|
|
cause: signal.reason,
|
|
capturedOutput: this.#captured(),
|
|
});
|
|
if (cause instanceof OwnedWorkerRuntimeCleanupError) {
|
|
const error = this.#cleanupError(
|
|
cause.message,
|
|
this.#lastFailure,
|
|
cause.cleanupResources,
|
|
cause.processId,
|
|
);
|
|
cause.bindCleanupFailure(error);
|
|
return error;
|
|
}
|
|
// Ownership preparation is abortable before the worker deadline exists.
|
|
if (deadline?.interruption !== undefined)
|
|
return this.#interruptionFailure(path, deadline);
|
|
if (
|
|
deadline === undefined &&
|
|
signal?.aborted === true &&
|
|
!(cause instanceof ProviderCleanupError)
|
|
)
|
|
return new AnalysisCancelledError(OPERATION, {
|
|
cause: signal.reason,
|
|
capturedOutput: this.#captured(),
|
|
});
|
|
const inputFailure = this.#runtime.inputFailure;
|
|
if (inputFailure !== undefined)
|
|
return new AnalysisOutputError(
|
|
OPERATION,
|
|
`Owned JavaScript worker request stream failed during ${this.#phase}`,
|
|
{ cause: inputFailure, capturedOutput: this.#captured() },
|
|
);
|
|
// A reached process limit explains the worker's exit, broken pipe or
|
|
// truncated frame better than the symptom it produced.
|
|
if (
|
|
this.#memoryLimitReached() &&
|
|
!(cause instanceof AnalysisResourceConstraintError)
|
|
)
|
|
return this.#memoryFailure(path);
|
|
if (cause instanceof AnalysisError) return cause;
|
|
if (
|
|
cause instanceof z.ZodError ||
|
|
cause instanceof TypeError ||
|
|
cause instanceof SyntaxError
|
|
)
|
|
return new AnalysisOutputError(
|
|
OPERATION,
|
|
`${path} during ${this.#phase}: ${message(cause)}`,
|
|
{ cause, capturedOutput: this.#captured() },
|
|
);
|
|
return cause instanceof RuntimeControlUnavailableError
|
|
? runtimeControlCapabilityError(PROVIDER, OPERATION, cause)
|
|
: new ProviderAdapterError(PROVIDER, OPERATION, {
|
|
cause,
|
|
capturedOutput: this.#captured(),
|
|
diagnostics: {
|
|
source_path: path,
|
|
phase: this.#phase,
|
|
message: message(cause),
|
|
},
|
|
});
|
|
}
|
|
#cleanupError(
|
|
reason: string,
|
|
original: AnalysisError | undefined = this.#lastFailure,
|
|
cleanupResources: readonly string[] = [
|
|
this.#runId,
|
|
...(this.#runtime.rootPath === undefined ? [] : [this.#runtime.rootPath]),
|
|
],
|
|
processId: number | null = this.#runtime.supervisor?.launch.process.pid ??
|
|
null,
|
|
): ProviderCleanupError {
|
|
return new ProviderCleanupError(
|
|
PROVIDER,
|
|
cleanupResources,
|
|
{
|
|
reason,
|
|
worker_pid: processId,
|
|
...(original === undefined
|
|
? {}
|
|
: {
|
|
execution_failure: {
|
|
tag: original._tag,
|
|
message: original.message,
|
|
captured_output: {
|
|
stdout: original.capturedOutput?.stdout ?? "",
|
|
stderr: original.capturedOutput?.stderr ?? "",
|
|
truncated: original.capturedOutput?.truncated ?? false,
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
operation: OPERATION,
|
|
...(original === undefined ? {} : { cleanupPrimary: original }),
|
|
cause: new Error(
|
|
reason,
|
|
original === undefined ? undefined : { cause: original },
|
|
),
|
|
},
|
|
);
|
|
}
|
|
async #stop(
|
|
original: AnalysisError | undefined = this.#lastFailure,
|
|
): Promise<ProviderCleanupError | undefined> {
|
|
const stopped = await this.#runtime.stop();
|
|
if (stopped.status === "incomplete") {
|
|
this.#resetState();
|
|
const error = this.#cleanupError(stopped.reason, original);
|
|
this.#runtime.bindCleanupFailure(error);
|
|
return error;
|
|
}
|
|
this.#ready = false;
|
|
this.#resetState();
|
|
return undefined;
|
|
}
|
|
|
|
#resetState(): void {
|
|
this.#ready = false;
|
|
this.#frames = [];
|
|
this.#frameSignal.clear();
|
|
this.#pending = "";
|
|
this.#decoder = new StringDecoder("utf8");
|
|
this.#primaryFailure = undefined;
|
|
this.#activeJobId = undefined;
|
|
this.#staticResponseId = undefined;
|
|
this.#terminalResponseId = undefined;
|
|
}
|
|
}
|
|
|
|
const message = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|