fix(javascript): preserve observed worker failure ordering
This commit is contained in:
1 parent
483678bf9f
commit
3a9176098d
3 files changed
+1190
-67
No files matched your search
@@ -30,7 +30,11 @@ import { ok, err, type Result } from "../../domain/result.js";
|
||||
import { javaScriptRuntime } from "../../domain/runtimeVersion.js";
|
||||
import { PrivateRuntimeRootUnavailableError } from "../../process/PrivateRuntimeRootUnavailableError.js";
|
||||
import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js";
|
||||
import { spawnOwnedProviderProcess } from "../../process/ProviderProcess.js";
|
||||
import {
|
||||
spawnOwnedProviderProcess,
|
||||
type ProviderProcessDiagnostic,
|
||||
type ProviderProcessSupervisor,
|
||||
} from "../../process/ProviderProcess.js";
|
||||
import {
|
||||
OwnedWorkerRuntime,
|
||||
OwnedWorkerRuntimeCleanupError,
|
||||
@@ -62,6 +66,23 @@ const STATIC_CHECKPOINT_RETENTION_EXPANSION = 6;
|
||||
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()}`;
|
||||
@@ -69,19 +90,25 @@ export class JavaScriptAnalysisWorker {
|
||||
#ready = false;
|
||||
#heapLimitBytes = 0;
|
||||
#frames: JavaScriptWorkerMessage[] = [];
|
||||
#frameWaiters = new Set<(outcome: "frame" | "failure") => void>();
|
||||
#pending = "";
|
||||
#decoder = new StringDecoder("utf8");
|
||||
#protocolFailure: string | undefined;
|
||||
#primaryFailure: WorkerPrimaryFailure | undefined;
|
||||
#activeJobId: string | undefined;
|
||||
#activeJobPhase = "startup";
|
||||
#phase = "startup";
|
||||
#lastFailure: AnalysisError | undefined;
|
||||
#estimatedRetainedBytes = 0;
|
||||
#applicationProjectionFailure: AnalysisResourceConstraintError | undefined;
|
||||
#parentCapacityExhausted = false;
|
||||
#sourcesRemaining: number | undefined;
|
||||
#deadline: ProviderStartupDeadline | undefined;
|
||||
readonly #resultBudgetBytes: number;
|
||||
/** 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);
|
||||
@@ -92,19 +119,24 @@ export class JavaScriptAnalysisWorker {
|
||||
if (
|
||||
Buffer.byteLength(line) > DEFAULTS.protocolBytes ||
|
||||
this.#frames.length >= 16
|
||||
)
|
||||
throw new RangeError(
|
||||
) {
|
||||
this.#latchOutputFailure(
|
||||
"JavaScript worker exceeded its control-frame budget",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const raw: unknown = JSON.parse(line);
|
||||
this.#frames.push(javaScriptWorkerMessageSchema.parse(raw));
|
||||
const frame = javaScriptWorkerMessageSchema.parse(raw);
|
||||
this.#frames.push(frame);
|
||||
this.#observeJobFrame(frame);
|
||||
this.#wakeFrameWaiters("frame");
|
||||
}
|
||||
if (Buffer.byteLength(this.#pending) > DEFAULTS.protocolBytes)
|
||||
throw new RangeError(
|
||||
this.#latchOutputFailure(
|
||||
"JavaScript worker control frame is unterminated or oversized",
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
this.#protocolFailure = message(cause);
|
||||
this.#latchOutputFailure(message(cause));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -119,7 +151,9 @@ export class JavaScriptAnalysisWorker {
|
||||
supervisorOptions: {
|
||||
captureStdout: false,
|
||||
maxDiagnosticBytes: 256 * 1024,
|
||||
onDiagnostic: (event) => this.#observeProcessDiagnostic(event),
|
||||
},
|
||||
onInputFailure: (cause) => this.#latchInputFailure(cause),
|
||||
});
|
||||
if (
|
||||
!Number.isSafeInteger(limits.heapMb) ||
|
||||
@@ -178,7 +212,8 @@ export class JavaScriptAnalysisWorker {
|
||||
JavaScriptSourceAnalysisFailure
|
||||
>;
|
||||
try {
|
||||
if (signal?.aborted === true) throw new AnalysisCancelledError(OPERATION);
|
||||
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",
|
||||
@@ -187,6 +222,7 @@ export class JavaScriptAnalysisWorker {
|
||||
await prepareProcessOwnershipInspection(signal);
|
||||
await OwnedWorkerRuntime.retryPending();
|
||||
deadline = new ProviderStartupDeadline(this.limits.timeoutMs, signal);
|
||||
this.#deadline = deadline;
|
||||
await this.#start(deadline);
|
||||
const rootPath = this.#runtime.rootPath;
|
||||
const supervisor = this.#runtime.supervisor;
|
||||
@@ -224,6 +260,8 @@ export class JavaScriptAnalysisWorker {
|
||||
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;
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
stdin.write(frame, (error) =>
|
||||
error === null || error === undefined ? resolve() : reject(error),
|
||||
@@ -276,24 +314,21 @@ export class JavaScriptAnalysisWorker {
|
||||
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: new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
response.resource ?? "memory",
|
||||
response.message,
|
||||
{ ...this.#limits(), ...response.limits },
|
||||
),
|
||||
error: this.#protocolFailure(file.path, response, this.#phase),
|
||||
});
|
||||
break;
|
||||
}
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
`${file.path} during ${this.#phase}: ${response.message}`,
|
||||
);
|
||||
throw this.#protocolFailure(file.path, response, this.#phase);
|
||||
}
|
||||
if (javascript === null)
|
||||
throw new AnalysisOutputError(
|
||||
@@ -385,7 +420,9 @@ export class JavaScriptAnalysisWorker {
|
||||
}
|
||||
// An allocation failure may surface as a protocol failure or be caught
|
||||
// inside the worker; any reached limit makes this file's result partial.
|
||||
if (this.#memoryLimitReached()) throw this.#memoryFailure(file.path);
|
||||
this.#observeMemoryLimit();
|
||||
if (this.#primaryFailure?.kind === "memory")
|
||||
throw this.#projectPrimaryFailure(file.path);
|
||||
} catch (cause: unknown) {
|
||||
const error = this.#failure(cause, file.path, deadline, signal);
|
||||
this.#lastFailure = error;
|
||||
@@ -405,6 +442,8 @@ export class JavaScriptAnalysisWorker {
|
||||
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))
|
||||
@@ -514,52 +553,48 @@ export class JavaScriptAnalysisWorker {
|
||||
path: string,
|
||||
): Promise<JavaScriptWorkerMessage> {
|
||||
for (;;) {
|
||||
if (deadline.interruption === "cancelled")
|
||||
throw new AnalysisCancelledError(OPERATION);
|
||||
if (deadline.interruption === "timeout")
|
||||
throw new AnalysisTimeoutError(OPERATION, this.limits.timeoutMs);
|
||||
if (deadline.interruption !== undefined) {
|
||||
const failure = this.#interruptionFailure(path, deadline);
|
||||
throw failure;
|
||||
}
|
||||
this.#observeMemoryLimit();
|
||||
const inputFailure = this.#runtime.inputFailure;
|
||||
if (inputFailure !== undefined)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
`Owned JavaScript worker request stream failed during ${path}`,
|
||||
{ cause: inputFailure, capturedOutput: this.#captured() },
|
||||
);
|
||||
if (this.#protocolFailure !== undefined)
|
||||
throw new AnalysisOutputError(OPERATION, this.#protocolFailure);
|
||||
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 is not complete output: let any final control frame drain first.
|
||||
if (!(await supervisor?.waitForOutputClose(10))) continue;
|
||||
if (this.#frames.length !== 0) continue;
|
||||
const drained = supervisor?.snapshot() ?? snapshot;
|
||||
throw this.#memoryLimitReached()
|
||||
? this.#memoryFailure(path)
|
||||
: /heap out of memory|heap limit|allocation failed/iu.test(
|
||||
drained.stderr.text,
|
||||
)
|
||||
? new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"memory",
|
||||
`Owned JavaScript worker exhausted its heap while analyzing ${path} during ${this.#phase}`,
|
||||
this.#limits(),
|
||||
{ capturedOutput: this.#captured() },
|
||||
)
|
||||
: new ProviderAdapterError(PROVIDER, OPERATION, {
|
||||
capturedOutput: this.#captured(),
|
||||
diagnostics: {
|
||||
source_path: path,
|
||||
phase: this.#phase,
|
||||
exit_code: drained.exitCode ?? null,
|
||||
signal: drained.signal ?? null,
|
||||
},
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
@@ -695,21 +730,252 @@ export class JavaScriptAnalysisWorker {
|
||||
}
|
||||
#captured() {
|
||||
const snapshot = this.#runtime.supervisor?.snapshot();
|
||||
const supervisor = this.#runtime.supervisor;
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: snapshot?.stderr.text ?? "",
|
||||
truncated: snapshot?.diagnosticTruncated ?? false,
|
||||
truncated:
|
||||
snapshot?.diagnosticTruncated === 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" &&
|
||||
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.#wakeFrameWaiters("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.#wakeFrameWaiters("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.#wakeFrameWaiters("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 === "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.#wakeFrameWaiters("failure");
|
||||
}
|
||||
#latchInputFailure(cause: Error): void {
|
||||
if (this.#deadline?.interruption !== undefined) return;
|
||||
if (this.#primaryFailure === undefined)
|
||||
this.#primaryFailure = { kind: "input", cause };
|
||||
this.#wakeFrameWaiters("failure");
|
||||
}
|
||||
async #waitForFrameOrOutputClose(
|
||||
supervisor: ProviderProcessSupervisor,
|
||||
deadline: ProviderStartupDeadline,
|
||||
): Promise<"frame" | "failure" | "closed" | "interrupted"> {
|
||||
if (this.#frames.length !== 0) return "frame";
|
||||
let wakeFrame!: (outcome: "frame" | "failure") => void;
|
||||
const frameArrival = new Promise<"frame" | "failure">((resolve) => {
|
||||
wakeFrame = resolve;
|
||||
this.#frameWaiters.add(wakeFrame);
|
||||
});
|
||||
const outputCloseController = new AbortController();
|
||||
const interruptOutputClose = () =>
|
||||
outputCloseController.abort(deadline.signal.reason);
|
||||
if (deadline.signal.aborted) interruptOutputClose();
|
||||
else
|
||||
deadline.signal.addEventListener("abort", interruptOutputClose, {
|
||||
once: true,
|
||||
});
|
||||
try {
|
||||
const outputClose = supervisor
|
||||
.waitForOutputClose(undefined, outputCloseController.signal)
|
||||
.then((closed): "closed" | "interrupted" =>
|
||||
closed ? "closed" : "interrupted",
|
||||
);
|
||||
return await Promise.race([frameArrival, outputClose]);
|
||||
} finally {
|
||||
outputCloseController.abort();
|
||||
deadline.signal.removeEventListener("abort", interruptOutputClose);
|
||||
this.#frameWaiters.delete(wakeFrame);
|
||||
}
|
||||
}
|
||||
#wakeFrameWaiters(outcome: "frame" | "failure"): void {
|
||||
for (const wake of this.#frameWaiters) wake(outcome);
|
||||
this.#frameWaiters.clear();
|
||||
}
|
||||
#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, {
|
||||
capturedOutput: this.#captured(),
|
||||
})
|
||||
: new AnalysisCancelledError(OPERATION, {
|
||||
cause: deadline.signal.reason,
|
||||
capturedOutput: this.#captured(),
|
||||
});
|
||||
}
|
||||
#workerExitFailure(
|
||||
path: string,
|
||||
failure: Extract<WorkerPrimaryFailure, { kind: "exit" }>,
|
||||
): AnalysisError {
|
||||
if (failure.heapExhaustion)
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"memory",
|
||||
`Owned JavaScript worker exhausted its heap while analyzing ${path} during ${this.#phase}`,
|
||||
this.#limits(),
|
||||
{ capturedOutput: this.#captured() },
|
||||
);
|
||||
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 === "resource")
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
response.resource ?? "memory",
|
||||
response.message,
|
||||
{ ...this.#limits(), ...response.limits },
|
||||
);
|
||||
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,
|
||||
@@ -721,18 +987,15 @@ export class JavaScriptAnalysisWorker {
|
||||
return error;
|
||||
}
|
||||
// Ownership preparation is abortable before the worker deadline exists.
|
||||
if (deadline?.interruption !== undefined)
|
||||
return this.#interruptionFailure(path, deadline);
|
||||
if (
|
||||
deadline?.interruption === "cancelled" ||
|
||||
(deadline === undefined &&
|
||||
signal?.aborted === true &&
|
||||
!(cause instanceof ProviderCleanupError) &&
|
||||
!(cause instanceof OwnedWorkerRuntimeCleanupError))
|
||||
deadline === undefined &&
|
||||
signal?.aborted === true &&
|
||||
!(cause instanceof ProviderCleanupError)
|
||||
)
|
||||
return new AnalysisCancelledError(OPERATION, {
|
||||
capturedOutput: this.#captured(),
|
||||
});
|
||||
if (deadline?.interruption === "timeout")
|
||||
return new AnalysisTimeoutError(OPERATION, this.limits.timeoutMs, {
|
||||
cause: signal.reason,
|
||||
capturedOutput: this.#captured(),
|
||||
});
|
||||
const inputFailure = this.#runtime.inputFailure;
|
||||
@@ -834,9 +1097,10 @@ export class JavaScriptAnalysisWorker {
|
||||
#resetState(): void {
|
||||
this.#ready = false;
|
||||
this.#frames = [];
|
||||
this.#frameWaiters.clear();
|
||||
this.#pending = "";
|
||||
this.#decoder = new StringDecoder("utf8");
|
||||
this.#protocolFailure = undefined;
|
||||
this.#primaryFailure = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface OwnedWorkerRuntimeOptions {
|
||||
readonly rootPrefix: string;
|
||||
readonly launcher?: typeof spawnOwnedProviderProcess;
|
||||
readonly supervisorOptions?: ProviderProcessSupervisorOptions;
|
||||
/** Report request-stream failure at the lifecycle owner observation point. */
|
||||
readonly onInputFailure?: (cause: Error) => void;
|
||||
/** Let a higher-level owner release its dependent resources before this root. */
|
||||
readonly retryCleanup?: () => Promise<void>;
|
||||
}
|
||||
@@ -400,6 +402,7 @@ export class OwnedWorkerRuntime {
|
||||
cause instanceof Error
|
||||
? cause
|
||||
: new Error("Owned worker request stream failed", { cause });
|
||||
this.options.onInputFailure?.(this.#inputFailure);
|
||||
};
|
||||
const onClose = (): void => {
|
||||
stream.off("error", onError);
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect, it } from "vitest";
|
||||
import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js";
|
||||
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
||||
import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js";
|
||||
|
||||
const sourceText = "export const observed = 1;";
|
||||
const file: JavaScriptArtifactFile = {
|
||||
path: "app.mjs",
|
||||
sha256: createHash("sha256").update(sourceText).digest("hex"),
|
||||
container_sha256: "1".repeat(64),
|
||||
bytes: Buffer.byteLength(sourceText),
|
||||
inventory_artifact_id: "fixture-source",
|
||||
kind: "javascript",
|
||||
unpacked: false,
|
||||
text: { included: true, value: sourceText },
|
||||
};
|
||||
const ready =
|
||||
'process.stdout.write(JSON.stringify({kind:"ready",heap_limit_bytes:require("node:v8").getHeapStatistics().heap_size_limit})+"\\n");';
|
||||
const phaseAfterRequest =
|
||||
'process.stdin.once("data", chunk => { const request=JSON.parse(String(chunk)); process.stdout.write(JSON.stringify({kind:"phase",id:request.id,phase:"parse"})+"\\n");';
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps an owned worker exit observed before caller abort as the primary result (memory limit: %s)",
|
||||
async (memoryLimitReached) => {
|
||||
const controller = new AbortController();
|
||||
const reason = { requested: "interrupt after worker exit" };
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} process.stdin.once("data", () => process.exit(7));`,
|
||||
],
|
||||
});
|
||||
let exited = false;
|
||||
launched.process.once("exit", () => {
|
||||
exited = true;
|
||||
setImmediate(() => controller.abort(reason));
|
||||
});
|
||||
Object.defineProperty(launched.process, "memoryLimitReached", {
|
||||
get: () => memoryLimitReached && exited,
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(controller.signal.reason).toBe(reason);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error.error).toMatchObject(
|
||||
memoryLimitReached
|
||||
? { _tag: "AnalysisResourceConstraintError", resource: "memory" }
|
||||
: {
|
||||
_tag: "ProviderAdapterError",
|
||||
diagnostics: { exit_code: 7 },
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "an AnalysisOutputError instance",
|
||||
reason: new AnalysisOutputError("fixture", "opaque caller value"),
|
||||
},
|
||||
{ label: "explicit null", reason: null },
|
||||
])(
|
||||
"preserves caller cancellation after the request barrier with $label",
|
||||
async ({ reason }) => {
|
||||
const controller = new AbortController();
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} ${phaseAfterRequest} setTimeout(() => process.exit(7), 500); });`,
|
||||
],
|
||||
});
|
||||
let acknowledged = false;
|
||||
launched.process.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
if (String(chunk).includes('"kind":"phase"') && !acknowledged) {
|
||||
acknowledged = true;
|
||||
setImmediate(() => controller.abort(reason));
|
||||
}
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.error.cause).toBe(reason);
|
||||
}
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("clears a consumed resource frame before the next reusable worker job", async () => {
|
||||
const controller = new AbortController();
|
||||
let launchCount = 0;
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
launchCount += 1;
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} let count=0; require("node:readline").createInterface({input:process.stdin}).on("line", line => { const {id}=JSON.parse(line); const first=++count===1; const response={kind:"failure",id,reason:first?"resource":"output",resource:first?"memory":null,limits:null,message:first?"first resource response":"second job output response"}; process.stdout.write(JSON.stringify(response)+"\\n"); if(!first)setInterval(()=>{},1000); });`,
|
||||
],
|
||||
});
|
||||
launched.process.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
if (String(chunk).includes("second job output response"))
|
||||
setTimeout(() => controller.abort("abort after second response"), 5);
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const first = await worker.analyze(file, { nodes: 100, unknowns: 100 });
|
||||
expect(first.ok).toBe(false);
|
||||
if (!first.ok)
|
||||
expect(first.error.error).toMatchObject({
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
reason: "first resource response",
|
||||
});
|
||||
const second = await worker.analyze(
|
||||
{ ...file, path: "next.mjs" },
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(second.ok).toBe(false);
|
||||
if (!second.ok)
|
||||
expect(second.error.error).toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
reason: "next.mjs during source-transfer: second job output response",
|
||||
});
|
||||
expect(launchCount).toBe(1);
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps coalesced static and resource frames in FIFO order and reuses the worker", async () => {
|
||||
const staticFacts = {
|
||||
parse_status: "complete",
|
||||
parse_error_count: 0,
|
||||
visited_ast_nodes: 1,
|
||||
references: [],
|
||||
endpoints: [],
|
||||
storage: [],
|
||||
bundler_registrations: [],
|
||||
role_paths: [],
|
||||
source_map_urls: [],
|
||||
vendors: [],
|
||||
electron: {
|
||||
browser_windows: [],
|
||||
context_bridge_apis: [],
|
||||
ipc: [],
|
||||
sender_validations: [],
|
||||
utility_processes: [],
|
||||
native_addon_bindings: [],
|
||||
},
|
||||
limitations: [],
|
||||
};
|
||||
const encodedStatic = JSON.stringify(staticFacts);
|
||||
let launchCount = 0;
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
launchCount += 1;
|
||||
return spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} const fs=require("node:fs"); const {createHash}=require("node:crypto"); const staticFacts=JSON.parse(${JSON.stringify(encodedStatic)}); require("node:readline").createInterface({input:process.stdin}).on("line", line => { const request=JSON.parse(line); if(!request.file)return; const text=JSON.stringify(staticFacts); fs.writeFileSync(request.id+".static.json",text); const frames=[{kind:"static",id:request.id,bytes:Buffer.byteLength(text),sha256:createHash("sha256").update(text).digest("hex")},{kind:"failure",id:request.id,reason:"resource",resource:"memory",limits:null,message:"fixture resource after static"}]; process.stdout.write(frames.map(frame=>JSON.stringify(frame)).join("\\n")+"\\n"); });`,
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
try {
|
||||
for (const path of ["first.mjs", "second.mjs"]) {
|
||||
const result = await worker.analyze(
|
||||
{ ...file, path },
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.error).toMatchObject({
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
reason: "fixture resource after static",
|
||||
});
|
||||
expect(result.error.javascript?.parse_status).toBe("complete");
|
||||
}
|
||||
}
|
||||
expect(launchCount).toBe(1);
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains coalesced static evidence when abort follows a resource frame", async () => {
|
||||
const controller = new AbortController();
|
||||
const staticFacts = {
|
||||
parse_status: "complete",
|
||||
parse_error_count: 0,
|
||||
visited_ast_nodes: 1,
|
||||
references: [],
|
||||
endpoints: [],
|
||||
storage: [],
|
||||
bundler_registrations: [],
|
||||
role_paths: [],
|
||||
source_map_urls: [],
|
||||
vendors: [],
|
||||
electron: {
|
||||
browser_windows: [],
|
||||
context_bridge_apis: [],
|
||||
ipc: [],
|
||||
sender_validations: [],
|
||||
utility_processes: [],
|
||||
native_addon_bindings: [],
|
||||
},
|
||||
limitations: [],
|
||||
};
|
||||
const encodedStatic = JSON.stringify(staticFacts);
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} const fs=require("node:fs"); const {createHash}=require("node:crypto"); const staticFacts=JSON.parse(${JSON.stringify(encodedStatic)}); require("node:readline").createInterface({input:process.stdin}).on("line", line => { const request=JSON.parse(line); if(!request.file)return; const text=JSON.stringify(staticFacts); fs.writeFileSync(request.id+".static.json",text); const frames=[{kind:"static",id:request.id,bytes:Buffer.byteLength(text),sha256:createHash("sha256").update(text).digest("hex")},{kind:"failure",id:request.id,reason:"resource",resource:"memory",limits:null,message:"resource failure before abort"}]; process.stdout.write(frames.map(frame=>JSON.stringify(frame)).join("\\n")+"\\n"); });`,
|
||||
],
|
||||
});
|
||||
launched.process.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
if (String(chunk).includes("resource failure before abort"))
|
||||
setTimeout(() => controller.abort(null), 5);
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.error).toMatchObject({
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
reason: "resource failure before abort",
|
||||
});
|
||||
expect(result.error.javascript?.parse_status).toBe("complete");
|
||||
}
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("selects a control-frame budget failure observed before caller abort", async () => {
|
||||
const controller = new AbortController();
|
||||
let stdoutBytes = 0;
|
||||
let abortQueued = false;
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} ${phaseAfterRequest} process.stdout.write("x".repeat(1_100_000)+"\\n", () => process.exit(7)); });`,
|
||||
],
|
||||
});
|
||||
launched.process.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdoutBytes += Buffer.byteLength(chunk);
|
||||
if (stdoutBytes > 65_536 && !abortQueued) {
|
||||
abortQueued = true;
|
||||
setTimeout(() => controller.abort("late caller abort"), 20);
|
||||
}
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(abortQueued).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error.error).toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
reason: "JavaScript worker control frame is unterminated or oversized",
|
||||
});
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps abort-first cancellation when the owned worker exits during stop", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new AnalysisOutputError("fixture", "opaque abort value");
|
||||
let exitedDuringStop = false;
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} process.on("SIGTERM",()=>process.exit(7)); ${phaseAfterRequest} setInterval(()=>{},1000); });`,
|
||||
],
|
||||
});
|
||||
let acknowledged = false;
|
||||
launched.process.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
if (String(chunk).includes('"kind":"phase"') && !acknowledged) {
|
||||
acknowledged = true;
|
||||
setImmediate(() => controller.abort(reason));
|
||||
}
|
||||
});
|
||||
launched.process.once("exit", () => {
|
||||
exitedDuringStop = controller.signal.aborted;
|
||||
});
|
||||
return { process: launched.process, ownership: launched.ownership };
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(exitedDuringStop).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.error.cause).toBe(reason);
|
||||
}
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds exit-output drain when a descendant holds the inherited pipe", async () => {
|
||||
const controller = new AbortController();
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} const {spawn}=require("node:child_process"); spawn(process.execPath,["-e","setInterval(()=>{},1000)"],{stdio:"inherit"}); process.stdin.once("data",()=>process.exit(7));`,
|
||||
],
|
||||
});
|
||||
launched.process.once("exit", () => {
|
||||
setImmediate(() => controller.abort("abort after observed exit"));
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
const started = performance.now();
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(performance.now() - started).toBeLessThan(2_000);
|
||||
if (!result.ok)
|
||||
expect(result.error.error).toMatchObject({
|
||||
_tag: "ProviderAdapterError",
|
||||
diagnostics: { exit_code: 7 },
|
||||
});
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("admits a final valid control frame written after the worker exit event", async () => {
|
||||
const frameWasWrittenAfterExit: boolean[] = [];
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const response = {
|
||||
kind: "failure",
|
||||
id: "",
|
||||
reason: "output",
|
||||
resource: null,
|
||||
limits: null,
|
||||
message: "final response after child exit",
|
||||
};
|
||||
const script = `${ready} const {spawn}=require("node:child_process"); process.stdin.once("data", chunk => { const response=${JSON.stringify(response)}; response.id=JSON.parse(String(chunk)).id; const frame=JSON.stringify(response)+"\\n"; const lateScript="setTimeout(()=>process.stdout.write("+JSON.stringify(frame)+"),50); setInterval(()=>{},1000)"; spawn(process.execPath,["-e",lateScript],{stdio:["ignore","inherit","ignore"]}); process.exit(7); });`;
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: ["-e", script],
|
||||
});
|
||||
let exited = false;
|
||||
launched.process.once("exit", () => {
|
||||
exited = true;
|
||||
});
|
||||
launched.process.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
if (String(chunk).includes("final response after child exit"))
|
||||
frameWasWrittenAfterExit.push(exited);
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
const started = performance.now();
|
||||
try {
|
||||
const result = await worker.analyze(file, { nodes: 100, unknowns: 100 });
|
||||
expect(frameWasWrittenAfterExit).toEqual([true]);
|
||||
expect(performance.now() - started).toBeLessThan(2_000);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error.error).toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
reason: expect.stringContaining("final response after child exit"),
|
||||
});
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains the owned worker's V8 heap exhaustion classification", async () => {
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) =>
|
||||
spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} process.stdin.once("data", () => { process.stderr.write("FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\\n"); process.exit(7); });`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(file, { nodes: 100, unknowns: 100 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error.error).toMatchObject({
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
resource: "memory",
|
||||
});
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("captures late stderr completely when the exited worker's inherited pipes close", async () => {
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) =>
|
||||
spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} const {spawn}=require("node:child_process"); process.stdin.once("data", () => { spawn(process.execPath,["-e","setTimeout(()=>{process.stderr.write('late stderr tail');process.exit(0)},80)"],{stdio:["ignore","inherit","inherit"]}); process.exit(7); });`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(file, { nodes: 100, unknowns: 100 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.error._tag).toBe("ProviderAdapterError");
|
||||
expect(result.error.error.capturedOutput).toMatchObject({
|
||||
stderr: "late stderr tail",
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("marks worker diagnostics incomplete when caller abort interrupts inherited-pipe drain", async () => {
|
||||
const controller = new AbortController();
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} const {spawn}=require("node:child_process"); process.stdin.once("data", () => { spawn(process.execPath,["-e","setTimeout(()=>{process.stderr.write('late stderr tail');process.exit(0)},1000)"],{stdio:["ignore","inherit","inherit"]}); process.exit(7); });`,
|
||||
],
|
||||
});
|
||||
launched.process.once("exit", () => {
|
||||
setImmediate(() =>
|
||||
controller.abort("abort during output finalization"),
|
||||
);
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.error._tag).toBe("ProviderAdapterError");
|
||||
expect(result.error.error.capturedOutput).toMatchObject({
|
||||
stderr: "",
|
||||
truncated: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not classify a live worker's heap-related stderr as OOM", async () => {
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) =>
|
||||
spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} process.stdin.once("data", chunk => { const id=JSON.parse(String(chunk)).id; process.stderr.write("heap limit is configured\\n"); process.stdout.write(JSON.stringify({kind:"phase",id,phase:"parse"})+"\\n"+JSON.stringify({kind:"failure",id,reason:"output",resource:null,limits:null,message:"fixture output failure"})+"\\n",()=>process.exit(0)); });`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(file, { nodes: 100, unknowns: 100 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error.error).toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
reason: "app.mjs during parse: fixture output failure",
|
||||
});
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps cancellation after successful exit while a final response is pending", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new AnalysisOutputError(
|
||||
"fixture",
|
||||
"abort after successful exit",
|
||||
);
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} const {spawn}=require("node:child_process"); process.stdin.once("data", chunk => { const id=JSON.parse(String(chunk)).id; const frame=JSON.stringify({kind:"failure",id,reason:"output",resource:null,limits:null,message:"late healthy response"})+"\\n"; const lateScript="setTimeout(()=>process.stdout.write("+JSON.stringify(frame)+"),500)"; spawn(process.execPath,["-e",lateScript],{stdio:["ignore","inherit","ignore"]}); process.exit(0); });`,
|
||||
],
|
||||
});
|
||||
launched.process.once("exit", () => {
|
||||
setImmediate(() => controller.abort(reason));
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.error.cause).toBe(reason);
|
||||
}
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("latches an owned stdin error observed before caller abort", async () => {
|
||||
const controller = new AbortController();
|
||||
const abortReason = { requested: "cancel after input failure" };
|
||||
const inputFailure = new Error("fixture stdin failure");
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} process.stdin.once("data", () => setInterval(()=>{},1000));`,
|
||||
],
|
||||
});
|
||||
let injected = false;
|
||||
launched.process.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
if (!injected && String(chunk).includes('"kind":"ready"')) {
|
||||
injected = true;
|
||||
setImmediate(() => {
|
||||
launched.process.stdin?.emit("error", inputFailure);
|
||||
controller.abort(abortReason);
|
||||
});
|
||||
}
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.error._tag).toBe("AnalysisOutputError");
|
||||
expect(result.error.error.cause).toBe(inputFailure);
|
||||
}
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a failed exit primary over a later request-stream error", async () => {
|
||||
const controller = new AbortController();
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} process.stdin.once("data", () => process.exit(7));`,
|
||||
],
|
||||
});
|
||||
launched.process.once("exit", () => {
|
||||
queueMicrotask(() => {
|
||||
launched.process.stdin?.emit("error", new Error("late EPIPE"));
|
||||
controller.abort("late abort");
|
||||
});
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error.error).toMatchObject({
|
||||
_tag: "ProviderAdapterError",
|
||||
diagnostics: { exit_code: 7, signal: null },
|
||||
});
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a failed exit primary over malformed descendant control output", async () => {
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) =>
|
||||
spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} const {spawn}=require("node:child_process"); process.stdin.once("data", () => { spawn(process.execPath,["-e","setTimeout(()=>process.stdout.write('not-json\\\\n'),50);setInterval(()=>{},1000)"],{stdio:["ignore","inherit","ignore"]}); process.exit(7); });`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(file, { nodes: 100, unknowns: 100 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error.error).toMatchObject({
|
||||
_tag: "ProviderAdapterError",
|
||||
diagnostics: { exit_code: 7 },
|
||||
});
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a complete current-job failure parsed before caller abort", async () => {
|
||||
const controller = new AbortController();
|
||||
let completeFrameSeen = false;
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} process.stdin.once("data", chunk => { const id=JSON.parse(String(chunk)).id; process.stdout.write(JSON.stringify({kind:"phase",id,phase:"parse"})+"\\n"); setTimeout(() => process.stdout.write(JSON.stringify({kind:"failure",id,reason:"output",resource:null,limits:null,message:"complete current-job failure"})+"\\n"),1); setInterval(()=>{},1000); });`,
|
||||
],
|
||||
});
|
||||
launched.process.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
if (String(chunk).includes("complete current-job failure")) {
|
||||
completeFrameSeen = true;
|
||||
setTimeout(() => controller.abort("abort after parsed failure"), 5);
|
||||
}
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(completeFrameSeen).toBe(true);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error.error).toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
reason: "app.mjs during parse: complete current-job failure",
|
||||
});
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let a wrong-job failure frame override later caller abort", async () => {
|
||||
const controller = new AbortController();
|
||||
let completeFrameSeen = false;
|
||||
const reason = { requested: "stop after unrelated frame" };
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} process.stdin.once("data", () => { const id="00000000-0000-4000-8000-000000000000"; process.stdout.write(JSON.stringify({kind:"failure",id,reason:"output",resource:null,limits:null,message:"unrelated job failure"})+"\\n"); setInterval(()=>{},1000); });`,
|
||||
],
|
||||
});
|
||||
launched.process.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
if (String(chunk).includes("unrelated job failure")) {
|
||||
completeFrameSeen = true;
|
||||
setTimeout(() => controller.abort(reason), 5);
|
||||
}
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(completeFrameSeen).toBe(true);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.error.cause).toBe(reason);
|
||||
}
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
reason: "output" as const,
|
||||
resource: null,
|
||||
limits: null,
|
||||
},
|
||||
{
|
||||
reason: "resource" as const,
|
||||
resource: "memory" as const,
|
||||
limits: { worker_heap_bytes: 1 },
|
||||
},
|
||||
])(
|
||||
"keeps abort-first cancellation when a matching $reason frame arrives afterward",
|
||||
async (failure) => {
|
||||
const controller = new AbortController();
|
||||
let failureFrameSeenAfterAbort = false;
|
||||
const worker = new JavaScriptAnalysisWorker(
|
||||
{ heapMb: 128, timeoutMs: 30_000 },
|
||||
async (request) => {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...request,
|
||||
arguments: [
|
||||
"-e",
|
||||
`${ready} process.stdin.once("data", chunk => { const id=JSON.parse(String(chunk)).id; process.stdout.write(JSON.stringify({kind:"phase",id,phase:"parse"})+"\\n"); const failure=${JSON.stringify(failure)}; failure.kind="failure"; failure.id=id; setTimeout(() => process.stdout.write(JSON.stringify(failure)+"\\n"),20); setInterval(()=>{},1000); });`,
|
||||
],
|
||||
});
|
||||
launched.process.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
if (String(chunk).includes('"kind":"phase"')) controller.abort(null);
|
||||
if (
|
||||
controller.signal.aborted &&
|
||||
String(chunk).includes('"kind":"failure"')
|
||||
)
|
||||
failureFrameSeenAfterAbort = true;
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await worker.analyze(
|
||||
file,
|
||||
{ nodes: 100, unknowns: 100 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(controller.signal.reason).toBe(null);
|
||||
expect(failureFrameSeenAfterAbort).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.error.cause).toBe(null);
|
||||
}
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user