Files
rea/src/browser/IsolatedWebBundleAnalyzer.ts
T
morluto 9aca543f46 fix(browser): isolate web bundle analysis with bounded partial results
Run inert captured-script analysis in owned children with per-script and aggregate deadlines, bounded source/result transfer and explicit per-script coverage. Preserve completed facts and exact cleanup authority across cancellation, mixed descriptor/process failures and cross-consumer retry.

Share aggregate source admission across page and Electron capture, expose CLI/MCP resource controls and retain selected parameters in Evidence. Verified with controlled CDP public workflows, real owned Node failure controls and the reporter-matching ReactDOM UMD artifact; real Chrome/Electron and Windows/Bun remain unqualified.
2026-10-19 13:22:54 +08:00

337 lines
12 KiB
TypeScript

import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import type {
AnalyzeWebBundleInput,
WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import { buildCapturedWebBundleAnalysis } from "../domain/webBundleAnalyzerResult.js";
import { isIncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { WebBundleScriptAnalysis } from "../domain/webBundleScriptAnalysis.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisTimeoutError,
AnalysisResourceConstraintError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js";
import { WEB_BUNDLE_WORKER_LIMITS } from "./WebBundleAnalysisWorkerProtocol.js";
// A process-wide admission, rather than one permit per provider instance, bounds
// simultaneous web analyzer children and their source/result transfer reservations.
export type WebBundleAnalysisWorkerPort = Pick<
WebBundleAnalysisWorker,
"analyze" | "close"
>;
let activeAnalyzer: IsolatedWebBundleAnalyzer | undefined;
/** Own one bundle's sequential analysis and finalization through provider close. */
export class IsolatedWebBundleAnalyzer {
static readonly #pendingCleanup = new Set<IsolatedWebBundleAnalyzer>();
#closed = false;
#running: Promise<Result<WebBundleAnalysis, AnalysisError>> | undefined;
#controller: AbortController | undefined;
#worker: WebBundleAnalysisWorkerPort | undefined;
constructor(
readonly createWorker: (heapMb: number) => WebBundleAnalysisWorkerPort = (
heapMb,
) => new WebBundleAnalysisWorker(heapMb),
) {}
/** Reserve capacity before capture, so concurrent captures cannot retain source inventories. */
async reserve(): Promise<Result<void, AnalysisError>> {
if (this.#closed)
return err(new AnalysisCancelledError("analyze_web_bundle"));
if (activeAnalyzer !== undefined)
return err(
new AnalysisResourceConstraintError(
"analyze_web_bundle",
"capacity",
"Another web-bundle capture or analysis owns the process-wide source and result reservation",
{ concurrent_web_bundle_analyses: 1 },
),
);
// Retry exact earlier owners before a replacement capture can retain sources.
try {
for (const owner of [...IsolatedWebBundleAnalyzer.#pendingCleanup])
await owner.#releaseWorker();
await WebBundleAnalysisWorker.retryPending();
await this.#releaseWorker();
} catch (cause) {
return err(
cause instanceof AnalysisError
? cause
: new ProviderCleanupError(
"web-bundle-static-analysis",
[],
{ reason: "Earlier web analyzer cleanup remains incomplete" },
{ operation: "analyze_web_bundle", cause },
),
);
}
if (this.#closed)
return err(new AnalysisCancelledError("analyze_web_bundle"));
if (activeAnalyzer !== undefined)
return err(
new AnalysisResourceConstraintError(
"analyze_web_bundle",
"capacity",
"Another web-bundle capture or analysis owns the process-wide source and result reservation",
{ concurrent_web_bundle_analyses: 1 },
),
);
activeAnalyzer = this;
return ok(undefined);
}
releaseReservation(): void {
if (activeAnalyzer === this) activeAnalyzer = undefined;
}
analyze(
inspection: WebPageInspection,
sourceMaps: WebBundleAnalysis["observations"]["source_maps"],
input: AnalyzeWebBundleInput,
signal?: AbortSignal,
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
if (this.#closed)
return Promise.resolve(
err(
new AnalysisCancelledError("analyze_web_bundle", {
partialObservation: inspection,
}),
),
);
if (this.#running !== undefined)
return Promise.resolve(
err(
new AnalysisResourceConstraintError(
"analyze_web_bundle",
"capacity",
"This web analyzer already owns an active analysis",
{ concurrent_web_bundle_analyses: 1 },
),
),
);
this.#controller = new AbortController();
const running = this.#analyze(
inspection,
sourceMaps,
input,
signal,
).finally(() => {
this.#running = undefined;
this.#controller = undefined;
});
this.#running = running;
return running;
}
async #analyze(
inspection: WebPageInspection,
sourceMaps: WebBundleAnalysis["observations"]["source_maps"],
input: AnalyzeWebBundleInput,
signal?: AbortSignal,
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
const scripts = inspection.scripts.items.filter(isIncludedScript);
const results: WebBundleScriptAnalysis[] = [];
const signals = [
this.#controller!.signal,
...(signal === undefined ? [] : [signal]),
];
const caller = AbortSignal.any(signals);
const deadline = new ProviderStartupDeadline(
input.total_analysis_timeout_ms ??
WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs,
caller,
);
let failure: AnalysisError | undefined;
let resultCapacityExhausted = false;
const appendUnstarted = (): void => {
for (const script of scripts.slice(results.length))
results.push({
status: "unavailable",
script_key: script.script_key,
reason: "not_started",
detail: caller.aborted
? "The operation was cancelled before this script was started."
: deadline.interruption === "timeout"
? `The total captured-script analysis deadline of ${String(input.total_analysis_timeout_ms ?? WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs)} ms expired before this script was started.`
: resultCapacityExhausted
? "The host could not reserve more web-analysis result capacity; this script was not started."
: "An earlier analyzer failure prevented this script from starting.",
});
};
try {
await WebBundleAnalysisWorker.retryPending();
this.#worker = this.createWorker(
input.max_heap_mb ?? WEB_BUNDLE_WORKER_LIMITS.heapMb,
);
for (const script of scripts) {
if (deadline.signal.aborted) break;
const analyzed = await this.#worker.analyze(
script,
input.analysis_timeout_ms ?? WEB_BUNDLE_WORKER_LIMITS.scriptTimeoutMs,
deadline.signal,
);
if (analyzed.ok) results.push(analyzed.value);
else {
const error = analyzed.error.error;
results.push(
analyzed.error.completed ?? {
status: "unavailable",
script_key: script.script_key,
reason: caller.aborted
? "cancelled"
: deadline.interruption === "timeout" ||
error instanceof AnalysisTimeoutError
? "deadline"
: error instanceof AnalysisResourceConstraintError
? "resource_constraint"
: "worker_failure",
detail:
error instanceof AnalysisResourceConstraintError &&
error.reportedLimits !== null
? `${error.message} Reported limits: ${JSON.stringify(error.reportedLimits)}`
: error.message,
},
);
if (error.cleanupIncomplete) {
failure = error;
break;
}
if (analyzed.error.continuation === "result_capacity_exhausted") {
resultCapacityExhausted = true;
failure = error;
break;
}
if (error instanceof AnalysisCapabilityUnavailableError) {
failure = error;
break;
}
if (caller.aborted || deadline.signal.aborted) break;
}
}
} catch (cause) {
failure =
cause instanceof AnalysisError
? cause
: new AnalysisOutputError(
"analyze_web_bundle",
"Web analyzer could not complete its owned work",
{ cause },
);
}
appendUnstarted();
let partial: WebBundleAnalysis | undefined;
try {
partial = buildCapturedWebBundleAnalysis(inspection, sourceMaps, results);
} catch (cause) {
failure = new AnalysisOutputError(
"analyze_web_bundle",
"Completed web script facts could not be assembled",
{ cause },
);
}
const hasPrimaryFailure =
failure !== undefined &&
(!failure.cleanupIncomplete || failure.cleanupPrimary !== undefined);
try {
await this.#releaseWorker();
} catch (cause) {
const cleanup =
cause instanceof AnalysisError
? cause
: new ProviderCleanupError(
"web-bundle-static-analysis",
[],
{ reason: "Web analyzer final cleanup remains incomplete" },
{ operation: "analyze_web_bundle", cause },
);
failure =
failure === undefined
? cleanup
: analysisErrorWithCleanupFailure(
failure,
cleanup,
"analyze_web_bundle",
).bindCleanupDependencies([failure, cleanup]);
} finally {
deadline.dispose();
}
if (
!hasPrimaryFailure &&
caller.aborted &&
deadline.interruption !== "timeout"
) {
const cancelled = new AnalysisCancelledError("analyze_web_bundle", {
cause: caller.reason,
partialObservation: partial ?? inspection,
});
failure =
failure?.cleanupIncomplete === true
? analysisErrorWithCleanupFailure(
cancelled,
failure,
"analyze_web_bundle",
).bindCleanupDependencies([cancelled, failure])
: cancelled;
} else if (!hasPrimaryFailure && deadline.interruption === "timeout") {
const timeout = new AnalysisTimeoutError(
"analyze_web_bundle",
input.total_analysis_timeout_ms ??
WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs,
{
cause: deadline.signal.reason,
partialObservation: partial ?? inspection,
},
);
failure =
failure?.cleanupIncomplete === true
? analysisErrorWithCleanupFailure(
timeout,
failure,
"analyze_web_bundle",
).bindCleanupDependencies([timeout, failure])
: timeout;
}
if (failure !== undefined) {
failure.retainPartialObservation(partial ?? inspection);
return err(failure);
}
if (partial === undefined)
return err(
new AnalysisOutputError(
"analyze_web_bundle",
"Web analysis produced no result",
),
);
return ok(partial);
}
async close(): Promise<void> {
this.#closed = true;
this.#controller?.abort(
new Error("Browser provider closed during bundle analysis"),
);
await this.#running;
await this.#releaseWorker();
}
async #releaseWorker(): Promise<void> {
try {
await this.#worker?.close();
this.#worker = undefined;
IsolatedWebBundleAnalyzer.#pendingCleanup.delete(this);
} catch (cause) {
IsolatedWebBundleAnalyzer.#pendingCleanup.add(this);
throw cause;
}
}
}