Files
reverse-engineering-anything/src/browser/WebBundleAnalysisWorker.ts
T

895 lines
31 KiB
TypeScript

import { WorkerFrameSignal } from "../process/WorkerFrameSignal.js";
import { createHash, randomUUID } from "node:crypto";
import { open, rm } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { StringDecoder } from "node:string_decoder";
import { getHeapStatistics } from "node:v8";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../artifacts/AnalysisCleanupLease.js";
import {
ArtifactFileCancelled,
ArtifactFileCleanupFailure,
artifactFileCancellationCause,
withArtifactFileHandle,
} from "../artifacts/ArtifactFileOperation.js";
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
import type { StableArtifactSnapshot } from "../artifacts/readStableArtifact.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisTimeoutError,
AnalysisOutputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { jsonParts, bufferedJsonParts } from "../domain/jsonSerialization.js";
import { javaScriptRuntime } from "../domain/runtimeVersion.js";
import { err, ok, type Result } from "../domain/result.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import {
webBundleScriptAnalysisSchema,
type WebBundleScriptAnalysis,
} from "../domain/webBundleScriptAnalysis.js";
import { OwnedWorkerRuntime } from "../process/OwnedWorkerRuntime.js";
import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
import { javaScriptWorkerMemoryLimitBytes } from "../process/JavaScriptWorkerMemoryLimit.js";
import {
DEFAULT_WEB_BUNDLE_HEAP_MB,
MAX_WEB_BUNDLE_HEAP_MB,
MIN_WEB_BUNDLE_HEAP_MB,
webBundleFixedByteBudget,
webBundleHeapExhaustedReason,
webBundleHeapExhaustedRemediation,
webBundleMemoryExhaustion,
webBundleResultAdmissionFailure,
webBundleScriptTimeoutRecovery,
} from "../domain/webBundleAnalysis.js";
import {
WEB_BUNDLE_WORKER_LIMITS,
webBundleWorkerMessageSchema,
webBundleWorkerRequestSchema,
type WebBundleWorkerMessage,
} from "./WebBundleAnalysisWorkerProtocol.js";
const OPERATION = "analyze_web_bundle";
const PROVIDER = "web-bundle-static-analysis";
const scriptTimeout = (
timeoutMs: number,
cause?: unknown,
): AnalysisTimeoutError =>
new AnalysisTimeoutError(OPERATION, timeoutMs, {
...(cause === undefined ? {} : { cause }),
...webBundleScriptTimeoutRecovery(timeoutMs),
});
const MIB = 1024 * 1024;
/** Source/result buffers, decoded strings, parsed objects and validation copies. */
const RESULT_EXPANSION = 16;
type WebBundleResultDescriptor = Extract<
WebBundleWorkerMessage,
{ readonly kind: "complete" }
>["result"];
type WebBundleObservedFailure =
| { readonly kind: "input"; readonly cause: Error }
| { readonly kind: "protocol"; readonly cause: unknown }
| { readonly kind: "incomplete-output" }
| {
readonly kind: "frame";
readonly frame: Extract<
WebBundleWorkerMessage,
{ readonly kind: "failure" }
>;
readonly resultBudgetBytes: number;
}
| {
readonly kind: "exit";
readonly code: number | null;
readonly signal: NodeJS.Signals | null;
readonly memoryLimitReached: boolean;
readonly heapExhaustion: boolean;
};
type WebBundleActiveJob = {
readonly id: string;
readonly resultBudgetBytes: number;
};
const stableSnapshotValue = (
value: unknown,
): StableArtifactSnapshot | undefined => {
if (
value === null ||
typeof value !== "object" ||
!("bytes" in value) ||
!Buffer.isBuffer(value.bytes) ||
!("sha256" in value) ||
typeof value.sha256 !== "string"
)
return undefined;
return { bytes: value.bytes, sha256: value.sha256 };
};
export interface WebBundleScriptAnalysisFailure {
readonly error: AnalysisError;
readonly completed: WebBundleScriptAnalysis | null;
readonly continuation?: "result_capacity_exhausted";
}
/** Sequential inert-source analysis; only bounded completed facts cross into the host. */
export class WebBundleAnalysisWorker {
static readonly #pendingCleanup = new Set<WebBundleAnalysisWorker>();
readonly #runtime: OwnedWorkerRuntime;
readonly #files = new ArtifactResourceScope();
readonly #cleanup: AnalysisCleanupLease;
#decoder = new StringDecoder("utf8");
#pending = "";
#frames: WebBundleWorkerMessage[] = [];
#protocolFailure: unknown;
#observedFailure: WebBundleObservedFailure | undefined;
#activeDeadline: ProviderStartupDeadline | undefined;
#activeJob: WebBundleActiveJob | undefined;
#activeTerminalResponseObserved = false;
readonly #frameSignal = new WorkerFrameSignal();
#retainedResultBytes = 0;
#ready = false;
readonly #stdout = (chunk: Buffer | string): void => {
if (this.#activeDeadline?.interruption !== undefined) return;
if (this.#protocolFailure !== undefined) return;
try {
// Refuse the chunk before appending it to any retained control text.
if (
Buffer.byteLength(chunk) + Buffer.byteLength(this.#pending) >
WEB_BUNDLE_WORKER_LIMITS.protocolBytes
)
throw new RangeError(
webBundleFixedByteBudget(
WEB_BUNDLE_WORKER_LIMITS.protocolBytes,
"control-frame budget",
),
);
this.#pending +=
typeof chunk === "string" ? chunk : this.#decoder.write(chunk);
for (;;) {
const end = this.#pending.indexOf("\n");
if (end < 0) break;
if (this.#frames.length >= 16)
throw new RangeError("Web analyzer exceeded its control-frame queue");
this.#frames.push(
webBundleWorkerMessageSchema.parse(
JSON.parse(this.#pending.slice(0, end)),
),
);
this.#observeFrame(this.#frames.at(-1)!);
this.#pending = this.#pending.slice(end + 1);
}
this.#frameSignal.wake("frame");
} catch (cause) {
this.#pending = "";
this.#protocolFailure = cause;
this.#latchFailure({ kind: "protocol", cause });
this.#frameSignal.wake("failure");
}
};
constructor(
readonly heapMb = DEFAULT_WEB_BUNDLE_HEAP_MB,
launcher: typeof spawnOwnedProviderProcess = spawnOwnedProviderProcess,
) {
if (
!Number.isSafeInteger(heapMb) ||
heapMb < MIN_WEB_BUNDLE_HEAP_MB ||
heapMb > MAX_WEB_BUNDLE_HEAP_MB
)
throw new RangeError(
`Web analyzer heap must be an integer from ${String(MIN_WEB_BUNDLE_HEAP_MB)} through ${String(MAX_WEB_BUNDLE_HEAP_MB)} MiB (max_heap_mb)`,
);
this.#runtime = new OwnedWorkerRuntime({
rootPrefix: "rea-web-bundle-analysis-",
launcher,
retryCleanup: () => this.close(),
onInputFailure: (cause) => {
if (this.#activeDeadline?.interruption !== undefined) return;
this.#latchFailure({ kind: "input", cause });
this.#frameSignal.wake("failure");
},
supervisorOptions: {
captureStdout: false,
maxDiagnosticBytes: 256 * 1024,
onDiagnostic: (event) => {
if (this.#activeDeadline?.interruption !== undefined) return;
if (
event.type === "exit" &&
(event.code !== 0 || event.signal !== null)
) {
const stderr = event.snapshot.stderr.text;
const memoryLimitReached =
event.snapshot.memoryLimitReached === true;
this.#latchFailure({
kind: "exit",
code: event.code,
signal: event.signal,
memoryLimitReached,
heapExhaustion:
memoryLimitReached ||
/heap out of memory|heap limit|allocation failed/iu.test(
stderr,
),
});
} else if (event.type === "output-close") {
if (
this.#activeDeadline !== undefined &&
(this.#activeJob === undefined ||
!this.#activeTerminalResponseObserved)
)
this.#latchFailure({ kind: "incomplete-output" });
this.#frameSignal.wake("failure");
} else if (event.type === "output") {
const failure = this.#observedFailure;
if (failure?.kind !== "exit") return;
const memoryLimitReached =
this.#runtime.supervisor?.snapshot().memoryLimitReached === true;
const heapMarker =
event.stream === "stderr" &&
/heap out of memory|heap limit|allocation failed/iu.test(
this.#runtime.supervisor?.snapshot().stderr.text ?? "",
);
if (!memoryLimitReached && !heapMarker) return;
this.#observedFailure = {
...failure,
heapExhaustion: true,
memoryLimitReached:
failure.memoryLimitReached || memoryLimitReached,
};
}
},
},
});
const owner = this;
this.#cleanup = new AnalysisCleanupLease(() => this.#release(), {
get cleanupResources() {
return [
...owner.#files.cleanupResources,
...(owner.#runtime.cleanupResources ?? []),
];
},
});
}
static async retryPending(): Promise<void> {
for (const worker of [...this.#pendingCleanup]) await worker.close();
await OwnedWorkerRuntime.retryPending();
}
async analyze(
script: IncludedScript,
timeoutMs: number,
signal?: AbortSignal,
): Promise<Result<WebBundleScriptAnalysis, WebBundleScriptAnalysisFailure>> {
let deadline: ProviderStartupDeadline | undefined;
const id = randomUUID();
this.#observedFailure = undefined;
this.#activeJob = undefined;
this.#activeTerminalResponseObserved = false;
let result: Result<WebBundleScriptAnalysis, AnalysisError>;
let completed: WebBundleScriptAnalysis | null = null;
let continuation: "result_capacity_exhausted" | undefined;
let resultDescriptor: WebBundleResultDescriptor | undefined;
const failed = (
error: AnalysisError,
): Result<WebBundleScriptAnalysis, WebBundleScriptAnalysisFailure> =>
err({
error,
completed,
...(continuation === undefined ? {} : { continuation }),
});
try {
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause: signal.reason });
const admitted = this.#admittedResultBudget();
if (!admitted.ok) {
continuation = "result_capacity_exhausted";
throw admitted.error;
}
const resultBudget = admitted.bytes;
await prepareProcessOwnershipInspection(signal);
deadline = new ProviderStartupDeadline(timeoutMs, signal);
this.#activeDeadline = deadline;
await this.#start(deadline);
if (this.#frames.length > 0 || this.#pending !== "")
throw new AnalysisOutputError(
OPERATION,
"Web analyzer emitted unsolicited control data",
);
const root = this.#runtime.rootPath;
const supervisor = this.#runtime.supervisor;
if (root === undefined || supervisor === undefined)
throw new TypeError("Web analyzer resources were not acquired");
supervisor.resetOutput();
const source = await this.#files.run(() =>
this.#writeSource(join(root, `${id}.source`), script, deadline!.signal),
);
const request = webBundleWorkerRequestSchema.parse({
kind: "analyze",
id,
source,
result_budget_bytes: resultBudget,
});
const frame = `${JSON.stringify(request)}\n`;
const stdin = supervisor.launch.process.stdin;
if (stdin === null || stdin === undefined)
throw new TypeError("Web analyzer request stream is unavailable");
this.#activeJob = { id, resultBudgetBytes: resultBudget };
this.#activeTerminalResponseObserved = false;
await new Promise<void>((resolve, reject) =>
stdin.write(frame, (error) =>
error == null ? resolve() : reject(error),
),
);
let response: WebBundleWorkerMessage;
do {
response = await this.#next(deadline);
if (response.kind === "ready" || response.id !== id)
throw new AnalysisOutputError(
OPERATION,
"Web analyzer response names another request",
);
} while (response.kind === "phase");
if (response.kind === "failure") {
if (response.reason === "resource")
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
response.message,
{ result_budget_bytes: resultBudget },
);
throw new AnalysisOutputError(
OPERATION,
`Web script analysis failed (${response.reason}): ${response.message}`,
);
}
if (response.kind !== "complete")
throw new AnalysisOutputError(
OPERATION,
"Web analyzer did not complete a result",
);
const descriptor = response.result;
resultDescriptor = descriptor;
// Reserve expansion before reading bytes, decoding JSON or schema validation.
if (
descriptor.bytes > resultBudget ||
descriptor.bytes * RESULT_EXPANSION + 64 * MIB >
getHeapStatistics().heap_size_limit - process.memoryUsage().heapUsed
)
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
"Completed script facts exceed the host's remaining reserved result capacity",
{ result_budget_bytes: resultBudget },
);
const stored = await this.#files.run(() =>
readStableArtifact(
join(root, `${id}.result`),
descriptor.bytes,
deadline!.signal,
undefined,
this.#files,
),
);
const facts = this.#validateCompletedResult(stored, descriptor, script);
this.#retainedResultBytes += descriptor.bytes;
completed = facts;
result = ok(facts);
} catch (cause) {
const snapshot = this.#completedSnapshot(cause);
if (snapshot !== undefined && resultDescriptor !== undefined) {
try {
completed = this.#validateCompletedResult(
snapshot,
resultDescriptor,
script,
);
this.#retainedResultBytes += resultDescriptor.bytes;
} catch {
// The physical read completed, but only validated facts are retained.
}
}
const cancellationCause = this.#completedReadCancellation(cause);
const interrupted =
deadline?.interruption === "timeout"
? scriptTimeout(timeoutMs, cause)
: cancellationCause !== undefined
? new AnalysisCancelledError(OPERATION, {
cause: cancellationCause.reason,
})
: deadline?.interruption === "cancelled" || signal?.aborted
? new AnalysisCancelledError(OPERATION, {
cause: signal?.aborted
? signal.reason
: deadline?.signal.reason,
})
: undefined;
const observedFailure =
this.#observedFailure === undefined
? undefined
: this.#projectObservedFailure();
if (observedFailure !== undefined && interrupted !== undefined)
observedFailure.retainSecondaryFailure(interrupted);
const failure =
cause instanceof ArtifactFileCleanupFailure &&
cause.fileCleanupFailure.outcome.kind === "completed"
? this.#cleanupFailure(cause, interrupted)
: interrupted !== undefined && observedFailure !== undefined
? observedFailure
: (interrupted ??
(cause instanceof PrivateRuntimeRootUnavailableError
? new AnalysisCapabilityUnavailableError(
PROVIDER,
OPERATION,
cause.message,
{ cause, retryable: cause.retryable },
)
: cause instanceof AnalysisError
? cause
: new AnalysisOutputError(
OPERATION,
"Owned web-script analysis failed",
{ cause },
)));
result = err(failure);
try {
const stopped = await this.#runtime.stop();
if (stopped.status === "incomplete") throw new Error(stopped.reason);
this.#reset();
} catch (cleanupCause) {
result = err(this.#cleanupFailure(cleanupCause, failure));
}
} finally {
this.#activeDeadline = undefined;
deadline?.dispose();
this.#activeJob = undefined;
this.#activeTerminalResponseObserved = false;
}
// Never remove job files while a failed descriptor or child remains owned.
if (!result.ok && result.error.cleanupIncomplete)
return failed(result.error);
if (this.#files.hasPending)
return failed(
this.#cleanupFailure(
new Error("Web analyzer artifact cleanup is incomplete"),
result.ok ? undefined : result.error,
),
);
try {
const root = this.#runtime.rootPath;
if (root !== undefined) {
await rm(join(root, `${id}.source`), { force: true });
await rm(join(root, `${id}.result`), { force: true });
}
} catch (cause) {
return failed(
this.#cleanupFailure(cause, result.ok ? undefined : result.error),
);
}
if (signal?.aborted && result.ok)
return failed(
new AnalysisCancelledError(OPERATION, { cause: signal.reason }),
);
return result.ok ? result : failed(result.error);
}
#admittedResultBudget():
| { readonly ok: true; readonly bytes: number }
| { readonly ok: false; readonly error: AnalysisResourceConstraintError } {
const fixedRemaining =
WEB_BUNDLE_WORKER_LIMITS.resultBytes - this.#retainedResultBytes;
const hostCapacity = Math.max(
0,
Math.floor(
(getHeapStatistics().heap_size_limit -
process.memoryUsage().heapUsed -
64 * MIB) /
RESULT_EXPANSION,
),
);
const bytes = Math.min(fixedRemaining, hostCapacity);
if (bytes > 0) return { ok: true, bytes };
const failure = webBundleResultAdmissionFailure({
fixedBudgetBytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes,
fixedRemainingBytes: fixedRemaining,
hostCapacityBytes: hostCapacity,
});
return {
ok: false,
error: new AnalysisResourceConstraintError(
OPERATION,
"memory",
failure.reason,
failure.reportedLimits,
failure.kind === "host-capacity"
? { remediationAction: failure.remediationAction }
: undefined,
),
};
}
async #writeSource(
path: string,
script: IncludedScript,
signal: AbortSignal,
): Promise<{ bytes: number; sha256: string }> {
const digest = createHash("sha256");
let bytes = 0;
const file = await open(path, "wx", 0o600);
await withArtifactFileHandle(
path,
file,
this.#files,
signal,
async (handle) => {
for (const part of bufferedJsonParts(jsonParts(script))) {
signal.throwIfAborted();
const chunk = Buffer.from(part, "utf8");
if (chunk.length > WEB_BUNDLE_WORKER_LIMITS.sourceBytes - bytes)
throw new AnalysisResourceConstraintError(
OPERATION,
"transport",
webBundleFixedByteBudget(
WEB_BUNDLE_WORKER_LIMITS.sourceBytes,
"worker transfer budget",
),
{ source_bytes: WEB_BUNDLE_WORKER_LIMITS.sourceBytes },
);
await handle.writeFile(chunk);
bytes += chunk.length;
digest.update(chunk);
}
},
);
return { bytes, sha256: digest.digest("hex") };
}
async #start(deadline: ProviderStartupDeadline): Promise<void> {
if (this.#ready && this.#runtime.supervisor !== undefined) return;
const environment: NodeJS.ProcessEnv = {
...process.env,
UV_THREADPOOL_SIZE: "1",
};
delete environment.NODE_OPTIONS;
const memoryLimitBytes = javaScriptWorkerMemoryLimitBytes(this.heapMb);
await this.#runtime.start(
{
command: process.execPath,
arguments: [
`--max-old-space-size=${String(this.heapMb)}`,
"--max-semi-space-size=8",
"--v8-pool-size=1",
"--expose-gc",
"--stack_size=2048",
fileURLToPath(import.meta.resolve("#web-bundle-analysis-worker")),
],
runId: `rea-web-bundle-analysis-${randomUUID()}`,
hostEnvironment: environment,
stdin: "pipe",
signal: deadline.signal,
...(memoryLimitBytes === null ? {} : { memoryLimitBytes }),
},
async () => {
this.#runtime.onStdout(this.#stdout);
const ready = await this.#next(deadline);
if (
ready.kind !== "ready" ||
(ready.heap_limit_bytes === null) ===
(javaScriptRuntime(process.versions).id === "node")
)
throw new AnalysisOutputError(
OPERATION,
"Web analyzer ready frame has an incompatible runtime heap limit",
);
this.#ready = true;
},
);
}
async #next(
deadline: ProviderStartupDeadline,
): Promise<WebBundleWorkerMessage> {
for (;;) {
if (deadline.interruption === "timeout")
throw scriptTimeout(deadline.timeoutMs);
if (deadline.interruption === "cancelled")
throw new AnalysisCancelledError(OPERATION, {
cause: deadline.signal.reason,
});
deadline.signal.throwIfAborted();
const frame = this.#frames.shift();
if (frame !== undefined) return frame;
if (this.#runtime.inputFailure !== undefined)
this.#latchFailure({
kind: "input",
cause: this.#runtime.inputFailure,
});
if (this.#protocolFailure !== undefined)
this.#latchFailure({ kind: "protocol", cause: this.#protocolFailure });
if (
this.#observedFailure !== undefined &&
this.#observedFailure.kind !== "exit"
)
throw this.#projectObservedFailure();
const supervisor = this.#runtime.supervisor;
const snapshot = supervisor?.snapshot();
if (
snapshot !== undefined &&
(snapshot.exitCode !== undefined || snapshot.signal !== undefined)
) {
if (supervisor === undefined) continue;
const outcome = await this.#waitForFrameOrOutputClose(
supervisor,
deadline,
);
if (outcome === "interrupted") {
const completedFrame = this.#frames.shift();
if (completedFrame !== undefined) return completedFrame;
throw deadline.interruption === "timeout"
? scriptTimeout(deadline.timeoutMs)
: new AnalysisCancelledError(OPERATION, {
cause: deadline.signal.reason,
});
}
const completedFrame = this.#frames.shift();
if (completedFrame !== undefined) return completedFrame;
if (outcome === "failure") throw this.#projectObservedFailure();
if (outcome === "frame") continue;
if (this.#observedFailure !== undefined)
throw this.#projectObservedFailure();
throw new AnalysisOutputError(
OPERATION,
"Owned web analyzer exited before completing this script",
{ capturedOutput: this.#capturedOutput() },
);
}
await deadline.wait(10);
}
}
#latchFailure(failure: WebBundleObservedFailure): void {
if (
this.#observedFailure === undefined &&
this.#activeDeadline?.interruption === undefined
)
this.#observedFailure = failure;
}
#observeFrame(frame: WebBundleWorkerMessage): void {
if (
this.#activeDeadline?.interruption !== undefined ||
this.#activeJob === undefined ||
frame.kind === "ready" ||
frame.id !== this.#activeJob.id
)
return;
if (frame.kind === "complete" || frame.kind === "failure")
this.#activeTerminalResponseObserved = true;
if (frame.kind !== "failure") return;
this.#latchFailure({
kind: "frame",
frame,
resultBudgetBytes: this.#activeJob.resultBudgetBytes,
});
}
#projectObservedFailure(): AnalysisError {
const failure = this.#observedFailure;
if (failure === undefined)
return new AnalysisOutputError(
OPERATION,
"Owned web analyzer exited before completing this script",
{ capturedOutput: this.#capturedOutput() },
);
const capturedOutput = this.#capturedOutput();
switch (failure.kind) {
case "input":
return new AnalysisOutputError(
OPERATION,
"Web analyzer request stream failed",
{ cause: failure.cause, capturedOutput },
);
case "protocol":
return new AnalysisOutputError(
OPERATION,
"Invalid web analyzer control frame",
{ cause: failure.cause, capturedOutput },
);
case "incomplete-output":
return new AnalysisOutputError(
OPERATION,
"Owned web analyzer output closed before completing this script",
{ capturedOutput },
);
case "frame":
return failure.frame.reason === "resource"
? new AnalysisResourceConstraintError(
OPERATION,
"memory",
failure.frame.message,
{
result_budget_bytes: failure.resultBudgetBytes,
},
{ capturedOutput },
)
: new AnalysisOutputError(
OPERATION,
`Web script analysis failed (${failure.frame.reason}): ${failure.frame.message}`,
{ capturedOutput },
);
case "exit":
if (failure.heapExhaustion) {
const exhaustion = webBundleMemoryExhaustion({
runtime: javaScriptRuntime(process.versions).id,
heapMb: this.heapMb,
memoryLimitReached: failure.memoryLimitReached,
processMemoryLimitBytes: javaScriptWorkerMemoryLimitBytes(
this.heapMb,
),
});
return new AnalysisResourceConstraintError(
OPERATION,
"memory",
webBundleHeapExhaustedReason(exhaustion),
{
worker_heap_limit_mb: this.heapMb,
max_heap_mb: this.heapMb,
worker_process_memory_limit_bytes:
javaScriptWorkerMemoryLimitBytes(this.heapMb),
},
{
capturedOutput,
remediationAction: webBundleHeapExhaustedRemediation(exhaustion),
},
);
}
return new AnalysisOutputError(
OPERATION,
"Owned web analyzer exited before completing this script",
{ capturedOutput },
);
}
}
#capturedOutput() {
const supervisor = this.#runtime.supervisor;
const snapshot = supervisor?.snapshot();
return {
stdout: "",
stderr: snapshot?.stderr.text ?? "",
truncated:
snapshot?.diagnosticTruncated === true ||
snapshot?.outputIncomplete === true ||
(supervisor !== undefined && !supervisor.observedOutputClose()),
exit_code: snapshot?.exitCode ?? null,
signal: snapshot?.signal ?? null,
};
}
async #waitForFrameOrOutputClose(
supervisor: NonNullable<OwnedWorkerRuntime["supervisor"]>,
deadline: ProviderStartupDeadline,
): Promise<"frame" | "failure" | "closed" | "interrupted"> {
if (this.#frames.length > 0) return "frame";
return this.#frameSignal.waitForOutputClose(supervisor, deadline.signal);
}
#completedSnapshot(cause: unknown): StableArtifactSnapshot | undefined {
if (cause instanceof ArtifactFileCancelled)
return stableSnapshotValue(cause.fileCancellation.outcome.value);
if (
cause instanceof ArtifactFileCleanupFailure &&
cause.fileCleanupFailure.outcome.kind === "completed"
)
return stableSnapshotValue(cause.fileCleanupFailure.outcome.value);
return undefined;
}
#completedReadCancellation(
cause: unknown,
): { readonly reason: unknown } | undefined {
if (cause instanceof ArtifactFileCancelled)
return { reason: artifactFileCancellationCause(cause) };
if (
cause instanceof ArtifactFileCleanupFailure &&
cause.fileCleanupFailure.outcome.kind === "completed"
)
return cause.fileCleanupFailure.completionCancellation;
return undefined;
}
#validateCompletedResult(
stored: StableArtifactSnapshot,
descriptor: WebBundleResultDescriptor,
script: IncludedScript,
): WebBundleScriptAnalysis {
if (
stored.bytes.length !== descriptor.bytes ||
stored.sha256 !== descriptor.sha256
)
throw new AnalysisOutputError(
OPERATION,
"Web analyzer result differs from its descriptor",
);
const facts = webBundleScriptAnalysisSchema.parse(
JSON.parse(stored.bytes.toString("utf8")),
);
if (facts.status !== "analyzed" || facts.script_key !== script.script_key)
throw new AnalysisOutputError(
OPERATION,
"Web analyzer facts name another script",
);
return facts;
}
#reset(): void {
this.#ready = false;
this.#frames = [];
this.#pending = "";
this.#decoder = new StringDecoder("utf8");
this.#protocolFailure = undefined;
this.#observedFailure = undefined;
this.#activeDeadline = undefined;
this.#activeJob = undefined;
this.#activeTerminalResponseObserved = false;
this.#frameSignal.wake("failure");
}
#cleanupFailure(cause: unknown, primary?: AnalysisError): AnalysisError {
WebBundleAnalysisWorker.#pendingCleanup.add(this);
this.#runtime.retainForCleanup();
const cleanup = new ProviderCleanupError(
PROVIDER,
this.#cleanup.cleanupResources ?? [],
{ reason: "Owned web analyzer cleanup remains incomplete" },
{ operation: OPERATION, cause },
);
new AnalysisCleanupLeaseGroup(cleanup, [this.#cleanup]);
return primary === undefined
? cleanup
: analysisErrorWithCleanupFailure(
primary,
cleanup,
OPERATION,
).bindCleanupDependencies([primary, cleanup]);
}
async close(): Promise<void> {
try {
await this.#cleanup.release();
} catch (cause) {
throw this.#cleanupFailure(cause);
}
}
async #release(): Promise<void> {
try {
const stopped = await this.#runtime.stop();
if (stopped.status === "incomplete") throw new Error(stopped.reason);
await this.#files.close();
await this.#runtime.close();
this.#reset();
WebBundleAnalysisWorker.#pendingCleanup.delete(this);
} catch (cause) {
WebBundleAnalysisWorker.#pendingCleanup.add(this);
this.#runtime.retainForCleanup();
throw cause;
}
}
}