mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
9aca543f46
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.
337 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|