diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 4c9f5fc388..3ea8b51012 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -350,6 +350,40 @@ validation and resource bounds. Omitted script metadata makes its affected section explicitly truncated. Disallowed-origin entries are filtered before retention. +Selected script-source collection shares a 32 MiB aggregate UTF-8 retention +budget and a 20-second deadline across the capture. An oversized source is +omitted while later smaller sources may still fit. When the deadline expires, +remaining source reads are skipped. Script metadata, per-item omission reasons, +and `script_sources` completeness remain available. A single CDP response can +still be materialized up to the separate 64 MiB transport limit before source +admission; the retention budget does not bound that transient allocation. + +Bundle parsing and semantic inspection run sequentially in an owned child, +without executing captured code. `max_heap_mb` defaults to 1024 MiB V8 +old-space; `analysis_timeout_ms` defaults to 30000 per script, including startup +and transfer. A failed or timed-out script becomes an explicit unknown while +completed findings and later scripts remain available. `total_analysis_timeout_ms` +defaults to 300000 across script analysis; expiry returns a typed timeout with +partial Evidence and identifies unstarted scripts. These controls are also +available as CLI flags with hyphenated names. Capture and optional source-map +fetching have separate budgets. + +One web-bundle capture/analysis is admitted per REA process at a time. Completed +script results share an 8 MiB serialized transfer budget, with host heap +reservation checked before decoding and validating another result. +Exhausted result capacity stops further script launches and returns a typed +resource failure with completed facts and explicit unstarted-script unknowns. +Worker control frames are limited to 64 KiB. Cleanup must confirm child termination +before releasing its private files; failed cleanup retains its exact owner and +blocks replacement until retry succeeds. Cancellation retains completed bundle +facts, including cancellation during final cleanup. + +The worker heap setting is not a process-family RSS limit. Node enforces V8 +old-space flags; Bun ignores them. Windows Bun instead uses an owned process +commit limit with a 512 MiB runtime reserve; other Bun hosts have no enforced +memory limit. These bounds do not establish an aggregate host-memory limit +across other REA operations or external processes. + Resource-tree inventories distinguish a valid empty `resources` array from a missing or malformed collection. Invalid resource entries are omitted with incomplete coverage, while valid resources from that frame and other frames diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 391aa5c17f..d0b07dd2f0 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -68,7 +68,12 @@ Script metadata retains its execution-context frame ID when CDP supplies one. Th inventories worker, service-worker, and shared-worker targets with validated opener-target and parent-frame IDs. Worker discovery uses passive target metadata; REA does not attach to or execute code in those targets. -Collection counts and aggregate script-source bytes are not capped. Like every +Script-source collection retains at most 32 MiB of UTF-8 text across scripts +and shares a 20-second collection deadline. Omitted sources retain their script +metadata and an explicit reason; later smaller sources may still fit after an +oversized source is omitted. The separate 64 MiB CDP response limit bounds one +wire message before retention admission, rather than total transient memory. +The source budget adds no script-count cap. Like every target, frame, script, and resource, a worker URL must resolve to a local file before it is retained. Relationship IDs improve attribution but do not prove which static module started a worker or that its diff --git a/knip.json b/knip.json index df594f3f00..acea26bd11 100644 --- a/knip.json +++ b/knip.json @@ -4,6 +4,7 @@ "src/main.ts", "src/cli.ts", "src/composition/javascriptAnalysisWorkerProcess.ts", + "src/composition/webBundleAnalysisWorkerProcess.ts", "src/contracts/errorSchemas.ts", "src/evaluation/AgentEvaluationFixtures.ts", "src/application/RealHopperAssertions.ts", diff --git a/package.json b/package.json index 1e112fcb81..58c354e11e 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ }, "type": "module", "imports": { - "#javascript-analysis-worker": "./dist/composition/javascriptAnalysisWorkerProcess.js" + "#javascript-analysis-worker": "./dist/composition/javascriptAnalysisWorkerProcess.js", + "#web-bundle-analysis-worker": "./dist/composition/webBundleAnalysisWorkerProcess.js" }, "bin": { "rea": "scripts/rea.mjs", diff --git a/src/application/BrowserEvidence.ts b/src/application/BrowserEvidence.ts index 495f1f1975..b8d99186c6 100644 --- a/src/application/BrowserEvidence.ts +++ b/src/application/BrowserEvidence.ts @@ -160,7 +160,18 @@ const browserParameters = ( } : {}), ...("fetch_source_maps" in input - ? { fetch_source_maps: input.fetch_source_maps } + ? { + fetch_source_maps: input.fetch_source_maps, + ...(input.max_heap_mb === undefined + ? {} + : { max_heap_mb: input.max_heap_mb }), + ...(input.analysis_timeout_ms === undefined + ? {} + : { analysis_timeout_ms: input.analysis_timeout_ms }), + ...(input.total_analysis_timeout_ms === undefined + ? {} + : { total_analysis_timeout_ms: input.total_analysis_timeout_ms }), + } : {}), }; }; diff --git a/src/application/BrowserObservationService.test.ts b/src/application/BrowserObservationService.test.ts index 213885c2f2..20b40c10ce 100644 --- a/src/application/BrowserObservationService.test.ts +++ b/src/application/BrowserObservationService.test.ts @@ -137,8 +137,8 @@ it("retains captured page Evidence when bundle analysis fails after capture", as "cancelled", { partialObservation: inspection }, ); - const browser = { - identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }), + const browser: BrowserObservationPort = { + ...provider(async () => ok(report)), analyzeBundle: async () => ({ ok: false as const, error: failure }), } as unknown as BrowserObservationPort; const bundleInput = analyzeWebBundleInputSchema.parse({ @@ -159,3 +159,41 @@ it("retains captured page Evidence when bundle analysis fails after capture", as }); } }); + +it("retains completed bundle facts as bundle Evidence after cancellation", async () => { + const { analyzeCapturedWebBundle } = + await import("../domain/webBundleAnalyzer.js"); + const { AnalysisCancelledError } = + await import("../domain/analysisErrorCore.js"); + const bundle = analyzeCapturedWebBundle(passiveNetworkCapture()); + const reason = { requested: "stop after completed script" }; + const failure = new AnalysisCancelledError("analyze_web_bundle", { + cause: reason, + partialObservation: bundle, + }); + const browser: BrowserObservationPort = { + ...provider(async () => ok(report)), + analyzeBundle: async () => ({ ok: false as const, error: failure }), + }; + const selected = analyzeWebBundleInputSchema.parse({ + cdp_endpoint: input.cdp_endpoint.recorded, + target_id: "page", + max_heap_mb: 256, + analysis_timeout_ms: 100, + total_analysis_timeout_ms: 1000, + }); + const result = await analyzeWebBundle(browser, selected); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBe(failure); + expect(result.error.cause).toBe(reason); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "analyze_web_bundle", + parameters: expect.objectContaining({ + max_heap_mb: 256, + analysis_timeout_ms: 100, + total_analysis_timeout_ms: 1000, + }), + normalized_result: bundle, + }); +}); diff --git a/src/application/BrowserObservationService.ts b/src/application/BrowserObservationService.ts index 53f44360a0..0c1f2a0360 100644 --- a/src/application/BrowserObservationService.ts +++ b/src/application/BrowserObservationService.ts @@ -3,7 +3,10 @@ import type { InspectWebPageInput, ListBrowserTargetsInput, } from "../domain/browserObservation.js"; -import type { AnalyzeWebBundleInput } from "../domain/webBundleAnalysis.js"; +import { + webBundleAnalysisSchema, + type AnalyzeWebBundleInput, +} from "../domain/webBundleAnalysis.js"; import type { ObserveWebSessionInput } from "../domain/browserSession.js"; import type { DiscoverWebMcpToolsInput } from "../domain/webMcpDiscovery.js"; import type { BrowserCaptureComparisonInput } from "../domain/browserCaptureComparison.js"; @@ -24,6 +27,7 @@ import { createBrowserEvidence } from "./BrowserEvidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { completeProviderEvidence } from "./ProviderOperationExecution.js"; import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; /** List browser targets within the endpoint and origin scope in the request. */ export const listBrowserTargets = async ( @@ -59,7 +63,7 @@ export const inspectWebPage = async ( return err(new AnalysisCancelledError("inspect_web_page")); const result = await ready.value.inspectPage(input, options); if (!result.ok) - return retainPageInspection( + return retainBrowserObservation( result.error, input, ready.value, @@ -88,7 +92,7 @@ export const analyzeWebBundle = async ( return err(new AnalysisCancelledError("analyze_web_bundle")); const analyzed = await ready.value.analyzeBundle(input, options); if (!analyzed.ok) - return retainPageInspection( + return retainBrowserObservation( analyzed.error, input, ready.value, @@ -247,14 +251,46 @@ const browserEvidence = ( ); }; -/** Convert a provider-retained page capture without claiming bundle analysis completed. */ -const retainPageInspection = ( +/** Retain the completed capture or bundle facts under their actual operation. */ +const retainBrowserObservation = ( error: AnalysisError, input: InspectWebPageInput | AnalyzeWebBundleInput, provider: BrowserObservationPort, errorOperation: "inspect_web_page" | "analyze_web_bundle", ): Result => { const partial = error.partialObservation; + if ( + errorOperation === "analyze_web_bundle" && + partial !== undefined && + !("evidence_id" in partial) && + "observations" in partial && + "capture" in partial + ) { + const bundle = webBundleAnalysisSchema.safeParse(partial); + if (!bundle.success) { + const malformed = new AnalysisOutputError( + errorOperation, + "Browser provider retained malformed completed bundle facts.", + { cause: bundle.error }, + ); + return err( + error.cleanupIncomplete + ? analysisErrorWithCleanupFailure( + malformed, + error, + errorOperation, + ).bindCleanupDependencies([malformed, error]) + : malformed, + ); + } + error.partialObservation = createBrowserEvidence( + "analyze_web_bundle", + input, + bundle.data, + provider.identity(), + ); + return err(error); + } if ( partial === undefined || "evidence_id" in partial || diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index 2137d43482..3557a427d4 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -12,11 +12,10 @@ import { type BrowserTargetList, type WebPageInspection, } from "../domain/browserObservationSchemas.js"; -import { analyzeCapturedWebBundle } from "../domain/webBundleAnalyzer.js"; -import { - webBundleAnalysisSchema, - type AnalyzeWebBundleInput, - type WebBundleAnalysis, +import { IsolatedWebBundleAnalyzer } from "./IsolatedWebBundleAnalyzer.js"; +import type { + AnalyzeWebBundleInput, + WebBundleAnalysis, } from "../domain/webBundleAnalysis.js"; import { webObservationSessionSchema, @@ -93,9 +92,31 @@ const CLEANUP_DOMAINS = [ /** Passive browser observation through a selected user-owned CDP endpoint. */ export class CdpBrowserProvider implements BrowserObservationPort { readonly #owner = new CdpTargetSessionOwner(); + readonly #bundleAnalyzer = new IsolatedWebBundleAnalyzer(); - close(): Promise { - return this.#owner.close(); + async close(): Promise { + const outcomes = await Promise.allSettled([ + this.#owner.close(), + this.#bundleAnalyzer.close(), + ]); + const failures = outcomes.flatMap((outcome) => + outcome.status === "rejected" ? [outcome.reason as unknown] : [], + ); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + const typed = failures.filter( + (failure): failure is AnalysisError => failure instanceof AnalysisError, + ); + throw new ProviderCleanupError( + CDP_BROWSER_PROVIDER_IDENTITY.id, + typed.flatMap((failure) => failure.cleanupResources), + { reason: "Browser transport and analyzer cleanup remain incomplete" }, + { + operation: "analyze_web_bundle", + cause: new AggregateError(failures), + }, + ).bindCleanupDependencies(typed); + } } identity(): ProviderIdentity { @@ -166,6 +187,8 @@ export class CdpBrowserProvider implements BrowserObservationPort { input: AnalyzeWebBundleInput, options: ExecutionOptions = {}, ): Promise> { + const admission = await this.#bundleAnalyzer.reserve(); + if (!admission.ok) return admission; let captured: CapturedPage | undefined; try { captured = await this.#capture(input, options, "analyze_web_bundle"); @@ -182,10 +205,16 @@ export class CdpBrowserProvider implements BrowserObservationPort { options.signal, ) : undefined; - return ok( - webBundleAnalysisSchema.parse( - analyzeCapturedWebBundle(captured.inspection, sourceMaps), - ), + return await this.#bundleAnalyzer.analyze( + captured.inspection, + sourceMaps ?? { + status: "not_requested", + requested: 0, + processed: 0, + items: [], + }, + input, + options.signal, ); } catch (cause: unknown) { // fetchWebSourceMaps rethrows the exact abort reason. Preserve cancellation @@ -201,6 +230,8 @@ export class CdpBrowserProvider implements BrowserObservationPort { ); } return err(providerError(cause, "analyze_web_bundle", options.signal)); + } finally { + this.#bundleAnalyzer.releaseReservation(); } } diff --git a/src/browser/CdpElectronInspection.test.ts b/src/browser/CdpElectronInspection.test.ts index 018d15ec77..dcd77f008c 100644 --- a/src/browser/CdpElectronInspection.test.ts +++ b/src/browser/CdpElectronInspection.test.ts @@ -159,3 +159,75 @@ describe("Electron frame capture", () => { }); }); }); + +it("admits a prefix and fitting suffix around an oversized Electron source", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-electron-source-budget-")); + temporary.push(root); + const paths = await Promise.all( + ["prefix.js", "oversized.js", "suffix.js"].map(async (name) => { + const path = join(root, name); + await writeFile(path, "metadata"); + return path; + }), + ); + const sourceById = { + prefix: "p".repeat(16 * 1024 * 1024), + oversized: "o".repeat(17 * 1024 * 1024), + suffix: "s".repeat(1024 * 1024), + }; + const scripts: ElectronScriptDraft[] = paths.map((path, index) => { + const scriptId = ["prefix", "oversized", "suffix"][index]; + if (scriptId === undefined) throw new Error("Missing script identity"); + return { + scriptId, + rawUrl: pathToFileURL(path).href, + hash: scriptId, + length: null, + isModule: false, + language: "JavaScript", + frameId: null, + }; + }); + const completeness = new CdpCaptureCompleteness(); + const capture = await captureElectronScripts({ + connection: { + send: async ( + _method: string, + params: Readonly>, + ) => ({ + scriptSource: + sourceById[ + (params as { scriptId: keyof typeof sourceById }).scriptId + ], + }), + } as unknown as CdpConnection, + sessionId: undefined, + request: inspectElectronPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page-1", + observation_ms: 0, + include_script_sources: true, + }), + scripts, + frameIds: new Set(), + completeness, + }); + const byHash = new Map( + capture.items.map((item) => [item.cdp_hash, item.source]), + ); + + expect(capture.total).toBe(3); + expect([...byHash.keys()].sort()).toEqual(["oversized", "prefix", "suffix"]); + expect(byHash.get("prefix")?.included).toBe(true); + expect(byHash.get("oversized")).toEqual({ + included: false, + reason: + "script source was omitted because the aggregate 32 MiB source budget was exhausted", + }); + expect(byHash.get("suffix")?.included).toBe(true); + expect(completeness.snapshot().excluded).toContainEqual({ + section: "script_sources", + reason: "resource_budget_exhausted", + count: 1, + }); +}); diff --git a/src/browser/CdpElectronScripts.ts b/src/browser/CdpElectronScripts.ts index ae1d245861..d648bc0c16 100644 --- a/src/browser/CdpElectronScripts.ts +++ b/src/browser/CdpElectronScripts.ts @@ -10,6 +10,12 @@ import { requiredScriptSource } from "./CdpCaptureValues.js"; import type { ElectronScriptDraft } from "./CdpElectronScriptEvents.js"; import { authorizedElectronFile } from "./ElectronFileScope.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON, + CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON, + CdpScriptSourceBudget, +} from "./CdpScriptSourceBudget.js"; interface ElectronScriptCaptureInput { readonly connection: CdpConnection; @@ -28,33 +34,65 @@ export const captureElectronScripts = async ( let total = 0; const items: ElectronPageInspection["scripts"]["items"] = []; const seen = new Set(); - for (const script of input.scripts) { - const path = await authorizedElectronFile(script.rawUrl); - if (path === undefined) { - input.completeness.exclude("scripts", "out_of_target_scope"); - continue; + const sourceBudget = + input.request.include_script_sources && input.scripts.length > 0 + ? new CdpScriptSourceBudget(input.signal) + : undefined; + try { + for (const script of input.scripts) { + const path = await authorizedElectronFile(script.rawUrl); + if (path === undefined) { + input.completeness.exclude("scripts", "out_of_target_scope"); + continue; + } + const identity = { + frame_id: + script.frameId !== null && input.frameIds.has(script.frameId) + ? script.frameId + : null, + file_path: path, + cdp_hash: script.hash, + length: script.length, + is_module: script.isModule, + language: script.language, + }; + const scriptKey = `electron_script_${digestCanonicalValue(identity, "CDP capture")}`; + if (seen.has(scriptKey)) continue; + seen.add(scriptKey); + total += 1; + let source: ElectronScriptSource = input.request.include_script_sources + ? { + included: false, + reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON, + } + : { + included: false, + reason: "source capture was not selected", + }; + if ( + input.request.include_script_sources && + isSourceDeadlineExpired(sourceBudget) + ) { + excludeScriptSource(input.completeness); + } else if ( + input.request.include_script_sources && + sourceBudget !== undefined + ) { + try { + source = await captureScriptSource(input, script, sourceBudget); + } catch (cause: unknown) { + throwIfCallerCancelled(input.signal); + if (isSourceDeadlineExpired(sourceBudget)) { + excludeScriptSource(input.completeness); + } else { + throw cause; + } + } + } + items.push({ script_key: scriptKey, ...identity, source }); } - const identity = { - frame_id: - script.frameId !== null && input.frameIds.has(script.frameId) - ? script.frameId - : null, - file_path: path, - cdp_hash: script.hash, - length: script.length, - is_module: script.isModule, - language: script.language, - }; - const scriptKey = `electron_script_${digestCanonicalValue(identity, "CDP capture")}`; - if (seen.has(scriptKey)) continue; - seen.add(scriptKey); - total += 1; - const capturedSource = await captureScriptSource(input, script); - items.push({ - script_key: scriptKey, - ...identity, - source: capturedSource.source, - }); + } finally { + sourceBudget?.dispose(); } if (!input.request.include_script_sources) input.completeness.exclude("script_sources", "not_approved", total); @@ -72,29 +110,44 @@ type ElectronScriptSource = const captureScriptSource = async ( input: ElectronScriptCaptureInput, script: ElectronScriptDraft, -): Promise<{ - readonly source: ElectronScriptSource; -}> => { - if (!input.request.include_script_sources) - return { - source: { - included: false, - reason: "source capture was not selected", - }, - }; - const text = requiredScriptSource( - await input.connection.send( - "Debugger.getScriptSource", - { scriptId: script.scriptId }, - input.sessionId, - input.signal, - ), - "inspect_electron_page", + sourceBudget: CdpScriptSourceBudget, +): Promise => { + const response = await input.connection.send( + "Debugger.getScriptSource", + { scriptId: script.scriptId }, + input.sessionId, + sourceBudget.signal, ); + throwIfCallerCancelled(input.signal); + const text = requiredScriptSource(response, "inspect_electron_page"); + if (sourceBudget.timedOut) { + excludeScriptSource(input.completeness); + return { + included: false, + reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON, + }; + } + if (!sourceBudget.admit(text)) { + excludeScriptSource(input.completeness); + return { included: false, reason: CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON }; + } return { - source: { - included: true, - artifact: createWebTextArtifact(text, "text/javascript"), - }, + included: true, + artifact: createWebTextArtifact(text, "text/javascript"), }; }; + +const excludeScriptSource = (completeness: CdpCaptureCompleteness): void => { + completeness.exclude("script_sources", "resource_budget_exhausted"); +}; + +const throwIfCallerCancelled = (signal: AbortSignal | undefined): void => { + if (signal?.aborted === true) + throw new AnalysisCancelledError("inspect_electron_page", { + cause: signal.reason, + }); +}; + +const isSourceDeadlineExpired = ( + sourceBudget: CdpScriptSourceBudget | undefined, +): boolean => sourceBudget?.timedOut === true; diff --git a/src/browser/CdpPageCaptureScripts.test.ts b/src/browser/CdpPageCaptureScripts.test.ts new file mode 100644 index 0000000000..f591041fca --- /dev/null +++ b/src/browser/CdpPageCaptureScripts.test.ts @@ -0,0 +1,209 @@ +import { expect, it, vi } from "vitest"; + +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { inspectWebPageInputSchema } from "../domain/browserObservation.js"; +import { CdpCaptureEvents } from "./CdpCaptureEvents.js"; +import type { CdpConnection } from "./CdpConnection.js"; +import { captureScripts } from "./CdpPageCaptureScripts.js"; +import type { CaptureContext } from "./CdpPageCapture.js"; +import type { CapturedScript } from "./CdpCaptureEventTypes.js"; + +const script = (scriptId: string): CapturedScript => ({ + scriptId, + rawUrl: `https://example.test/${scriptId}.js`, + url: `https://example.test/${scriptId}.js`, + origin: "https://example.test", + hash: scriptId, + length: null, + isModule: false, + language: "JavaScript", + sourceMapUrl: null, + sourceMapRawUrl: null, + executionContextKey: null, + frameId: null, +}); + +const capture = async ( + sources: Readonly>, + includeScriptSources = true, + signal?: AbortSignal, +) => { + const input = inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page-1", + allowed_origins: ["https://example.test"], + observation_ms: 0, + include_script_sources: includeScriptSources, + }); + const events = new CdpCaptureEvents(input, new Set(input.allowed_origins)); + for (const id of Object.keys(sources)) events.scripts.set(id, script(id)); + const send = vi.fn(async (method: string, params: unknown) => { + if (method !== "Debugger.getScriptSource") + throw new Error(`Unexpected CDP command: ${method}`); + const scriptId = (params as { scriptId: string }).scriptId; + return { scriptSource: sources[scriptId] }; + }); + const context = { + connection: { send } as unknown as CdpConnection, + sessionId: undefined, + operation: "inspect_web_page", + discovery: undefined, + target: undefined, + input, + ...(signal === undefined ? {} : { signal }), + } as unknown as CaptureContext; + const result = await captureScripts({ + context, + events, + rawResources: [], + resources: [], + frameIds: new Set(), + }); + return { result, events, send }; +}; + +it("admits a prefix and fitting suffix around an oversized script source", async () => { + const prefix = "p".repeat(16 * 1024 * 1024); + const oversized = "o".repeat(17 * 1024 * 1024); + const suffix = "s".repeat(1024 * 1024); + const { result, events } = await capture({ prefix, oversized, suffix }); + const byId = new Map( + result.inventory.items.map((item) => [item.cdp_hash, item.source]), + ); + + expect(byId.get("prefix")).toMatchObject({ included: true }); + expect(byId.get("oversized")).toEqual({ + included: false, + reason: + "script source was omitted because the aggregate 32 MiB source budget was exhausted", + }); + expect(byId.get("suffix")).toMatchObject({ included: true }); + expect(events.completeness.snapshot().excluded).toContainEqual({ + section: "script_sources", + reason: "resource_budget_exhausted", + count: 1, + }); +}); + +it("preserves the caller cancellation reason when a source read is pending", async () => { + const controller = new AbortController(); + const reason = new AnalysisCancelledError("inspect_web_page"); + const input = inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page-1", + allowed_origins: ["https://example.test"], + observation_ms: 0, + include_script_sources: true, + }); + const events = new CdpCaptureEvents(input, new Set(input.allowed_origins)); + events.scripts.set("first", script("first")); + const context = { + connection: { + send: async () => { + controller.abort(reason); + throw new AnalysisCancelledError("inspect_web_page"); + }, + } as unknown as CdpConnection, + sessionId: undefined, + operation: "inspect_web_page", + discovery: undefined, + target: undefined, + input, + signal: controller.signal, + } as unknown as CaptureContext; + + await expect( + captureScripts({ + context, + events, + rawResources: [], + resources: [], + frameIds: new Set(), + }), + ).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: reason }); +}); + +it("stops source reads at the shared collection deadline and retains metadata", async () => { + vi.useFakeTimers(); + try { + const input = inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page-1", + allowed_origins: ["https://example.test"], + observation_ms: 0, + include_script_sources: true, + }); + const events = new CdpCaptureEvents(input, new Set(input.allowed_origins)); + events.scripts.set("first", script("first")); + events.scripts.set("second", script("second")); + const send = vi.fn( + ( + _method: string, + _params: unknown, + _sessionId: string | undefined, + signal?: AbortSignal, + ) => + new Promise((_resolve, reject) => { + signal?.addEventListener( + "abort", + () => reject(new AnalysisCancelledError("inspect_web_page")), + { once: true }, + ); + }), + ); + const context = { + connection: { send } as unknown as CdpConnection, + sessionId: undefined, + operation: "inspect_web_page", + discovery: undefined, + target: undefined, + input, + } as unknown as CaptureContext; + const pending = captureScripts({ + context, + events, + rawResources: [], + resources: [], + frameIds: new Set(), + }); + + await vi.advanceTimersByTimeAsync(20_000); + const result = await pending; + + expect(send).toHaveBeenCalledTimes(1); + expect(result.inventory.items.map((item) => item.source)).toEqual([ + { + included: false, + reason: + "script source was omitted because the 20-second source collection deadline elapsed", + }, + { + included: false, + reason: + "script source was omitted because the 20-second source collection deadline elapsed", + }, + ]); + expect(events.completeness.snapshot().excluded).toContainEqual({ + section: "script_sources", + reason: "resource_budget_exhausted", + count: 2, + }); + } finally { + vi.useRealTimers(); + } +}); + +it("leaves source-off capture unchanged and does not issue source reads", async () => { + const { result, events, send } = await capture({ first: "unused" }, false); + + expect(send).not.toHaveBeenCalled(); + expect(result.inventory.items[0]?.source).toEqual({ + included: false, + reason: "source capture was not selected", + }); + expect(events.completeness.snapshot().excluded).toContainEqual({ + section: "script_sources", + reason: "not_approved", + count: 1, + }); +}); diff --git a/src/browser/CdpPageCaptureScripts.ts b/src/browser/CdpPageCaptureScripts.ts index 6c20c75140..97348d22e9 100644 --- a/src/browser/CdpPageCaptureScripts.ts +++ b/src/browser/CdpPageCaptureScripts.ts @@ -14,6 +14,13 @@ import { import type { CaptureContext } from "./CdpPageCapture.js"; import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; +import { + CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON, + CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON, + CdpScriptSourceBudget, +} from "./CdpScriptSourceBudget.js"; export interface CapturedScripts { readonly inventory: WebPageInspection["scripts"]; @@ -51,17 +58,54 @@ export const captureScripts = async ( readonly item: ScriptDraftItem; readonly sourceMapRawUrl: string | null; }[] = []; - for (const script of events.scripts.values()) { - state ??= { - context, - events, - frameIds, - reconcileResource: createCapturedWebScriptReconciler( - rawResources, - resources, - ), - }; - drafts.push(await buildScriptDraft(script, state)); + const scripts = [...events.scripts.values()]; + const sourceBudget = + context.input.include_script_sources && scripts.length > 0 + ? new CdpScriptSourceBudget(context.signal) + : undefined; + try { + for (const script of scripts) { + state ??= { + context, + events, + frameIds, + reconcileResource: createCapturedWebScriptReconciler( + rawResources, + resources, + ), + }; + let source: WebPageInspection["scripts"]["items"][number]["source"] = + context.input.include_script_sources + ? sourceExcluded(CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON) + : sourceExcluded("source capture was not selected"); + if ( + context.input.include_script_sources && + isSourceDeadlineExpired(sourceBudget) + ) { + events.completeness.exclude( + "script_sources", + "resource_budget_exhausted", + ); + } else if ( + context.input.include_script_sources && + sourceBudget !== undefined + ) { + try { + source = await captureScriptSource(script, state, sourceBudget); + } catch (cause: unknown) { + throwIfCallerCancelled(context.signal, context.operation); + if (isSourceDeadlineExpired(sourceBudget)) + events.completeness.exclude( + "script_sources", + "resource_budget_exhausted", + ); + else throw cause; + } + } + drafts.push(buildScriptDraft(script, state, source)); + } + } finally { + sourceBudget?.dispose(); } if (!context.input.include_script_sources) events.completeness.exclude( @@ -72,17 +116,14 @@ export const captureScripts = async ( return keyAndBuildInventory(drafts, events.scripts.size); }; -const buildScriptDraft = async ( +const buildScriptDraft = ( script: CapturedScript, state: ScriptDraftState, -): Promise<{ + source: WebPageInspection["scripts"]["items"][number]["source"], +): { readonly item: ScriptDraftItem; readonly sourceMapRawUrl: string | null; -}> => { - let source: WebPageInspection["scripts"]["items"][number]["source"] = state - .context.input.include_script_sources - ? await captureScriptSource(script, state) - : sourceExcluded("source capture was not selected"); +} => { const inventoryScript = { frame_id: state.events.frameForScript(script, state.frameIds), url: script.url, @@ -109,20 +150,46 @@ const buildScriptDraft = async ( const captureScriptSource = async ( script: CapturedScript, state: ScriptDraftState, + sourceBudget: CdpScriptSourceBudget, ): Promise => { const { context } = state; - const content = requiredScriptSource( - await context.connection.send( - "Debugger.getScriptSource", - { scriptId: script.scriptId }, - context.sessionId, - context.signal, - ), - context.operation, + const response = await context.connection.send( + "Debugger.getScriptSource", + { scriptId: script.scriptId }, + context.sessionId, + sourceBudget.signal, ); + throwIfCallerCancelled(context.signal, context.operation); + const content = requiredScriptSource(response, context.operation); + if (sourceBudget.timedOut) { + state.events.completeness.exclude( + "script_sources", + "resource_budget_exhausted", + ); + return sourceExcluded(CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON); + } + if (!sourceBudget.admit(content)) { + state.events.completeness.exclude( + "script_sources", + "resource_budget_exhausted", + ); + return sourceExcluded(CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON); + } return sourceResult(content); }; +const throwIfCallerCancelled = ( + signal: AbortSignal | undefined, + operation: BrowserObservationOperation, +): void => { + if (signal?.aborted === true) + throw new AnalysisCancelledError(operation, { cause: signal.reason }); +}; + +const isSourceDeadlineExpired = ( + sourceBudget: CdpScriptSourceBudget | undefined, +): boolean => sourceBudget?.timedOut === true; + const keyAndBuildInventory = ( drafts: readonly { readonly item: ScriptDraftItem; diff --git a/src/browser/CdpScriptSourceBudget.ts b/src/browser/CdpScriptSourceBudget.ts new file mode 100644 index 0000000000..689f38ca6b --- /dev/null +++ b/src/browser/CdpScriptSourceBudget.ts @@ -0,0 +1,41 @@ +import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js"; +import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; + +const MIB = 1024 * 1024; + +export const CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON = `script source was omitted because the aggregate ${String(WEB_RUNTIME_LIMITS.sourceBytes / MIB)} MiB source budget was exhausted`; +export const CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON = `script source was omitted because the ${String(WEB_RUNTIME_LIMITS.commandTimeoutMs / 1000)}-second source collection deadline elapsed`; + +/** Shared admission for source text retained from passive CDP script reads. */ +export class CdpScriptSourceBudget { + readonly #deadline: ProviderStartupDeadline; + #retainedBytes = 0; + + constructor(signal?: AbortSignal) { + this.#deadline = new ProviderStartupDeadline( + WEB_RUNTIME_LIMITS.commandTimeoutMs, + signal, + ); + } + + get signal(): AbortSignal { + return this.#deadline.signal; + } + + get timedOut(): boolean { + return this.#deadline.interruption === "timeout"; + } + + /** Admit exact UTF-8 source bytes before building or retaining its artifact. */ + admit(text: string): boolean { + const bytes = Buffer.byteLength(text, "utf8"); + if (bytes > WEB_RUNTIME_LIMITS.sourceBytes - this.#retainedBytes) + return false; + this.#retainedBytes += bytes; + return true; + } + + dispose(): void { + this.#deadline.dispose(); + } +} diff --git a/src/browser/IsolatedWebBundleAnalyzer.test.ts b/src/browser/IsolatedWebBundleAnalyzer.test.ts new file mode 100644 index 0000000000..131f839750 --- /dev/null +++ b/src/browser/IsolatedWebBundleAnalyzer.test.ts @@ -0,0 +1,479 @@ +import { expect, it } from "vitest"; + +import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import type { + AnalyzeWebBundleInput, + WebBundleAnalysis, +} from "../domain/webBundleAnalysis.js"; +import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; +import { analyzeCapturedWebScript } from "../domain/webBundleAnalyzer.js"; +import { createWebTextArtifact } from "../domain/webContentArtifact.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { err, ok } from "../domain/result.js"; +import { + IsolatedWebBundleAnalyzer, + type WebBundleAnalysisWorkerPort, +} from "./IsolatedWebBundleAnalyzer.js"; + +const origin = "https://app.example.test"; +const noSourceMaps: WebBundleAnalysis["observations"]["source_maps"] = { + status: "not_requested", + requested: 0, + processed: 0, + items: [], +}; +const input: AnalyzeWebBundleInput = analyzeWebBundleInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page-1", + include_script_sources: true, +}); + +it("preserves completed prefix and suffix facts when one script times out", async () => { + const captured = inspection([ + "import './prefix.js'; fetch('/prefix');", + "function stalled() {}", + "import './suffix.js'; fetch('/suffix');", + ]); + const calls: string[] = []; + const analyzer = new IsolatedWebBundleAnalyzer(() => ({ + analyze: async (script) => { + calls.push(script.script_key); + if (calls.length === 2) + return err({ + error: new AnalysisTimeoutError("analyze_web_bundle", 5), + completed: null, + }); + return ok(analyzeCapturedWebScript(script)); + }, + close: async () => {}, + })); + expect((await analyzer.reserve()).ok).toBe(true); + + const result = await analyzer.analyze(captured, noSourceMaps, input); + analyzer.releaseReservation(); + + expect(calls).toEqual( + captured.scripts.items.map(({ script_key }) => script_key), + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect( + result.value.observations.chunks.edges.map(({ specifier }) => specifier), + ).toEqual(["./prefix.js", "./suffix.js"]); + expect(result.value.observations.endpoints.map(({ value }) => value)).toEqual( + ["/prefix", "/suffix"], + ); + expect(result.value.completeness).toMatchObject({ + status: "partial", + parsed_scripts: 2, + parse_failures: 0, + }); + expect(result.value.unknowns).toContainEqual({ + dimension: "javascript_analysis", + reason: expect.stringContaining("deadline"), + affected_script_keys: [captured.scripts.items[1]?.script_key], + }); +}); + +it("marks the unstarted suffix when the aggregate deadline expires", async () => { + const captured = inspection([ + "fetch('/prefix');", + "fetch('/stalled');", + "fetch('/not-started');", + ]); + const calls: string[] = []; + const analyzer = new IsolatedWebBundleAnalyzer(() => ({ + analyze: async (script, _timeout, signal) => { + calls.push(script.script_key); + if (calls.length === 1) return ok(analyzeCapturedWebScript(script)); + await waitForAbort(signal); + return err({ + error: new AnalysisTimeoutError("analyze_web_bundle", 50), + completed: null, + }); + }, + close: async () => {}, + })); + expect((await analyzer.reserve()).ok).toBe(true); + + const result = await analyzer.analyze(captured, noSourceMaps, { + ...input, + total_analysis_timeout_ms: 50, + }); + + analyzer.releaseReservation(); + + expect(calls).toEqual( + captured.scripts.items.slice(0, 2).map(({ script_key }) => script_key), + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected total deadline failure"); + expect(result.error._tag).toBe("AnalysisTimeoutError"); + expect(result.error.partialObservation).toMatchObject({ + completeness: { status: "partial", parsed_scripts: 1 }, + observations: { endpoints: [{ value: "/prefix" }] }, + }); + expect(result.error.partialObservation).toMatchObject({ + unknowns: expect.arrayContaining([ + expect.objectContaining({ + dimension: "javascript_analysis", + affected_script_keys: [captured.scripts.items[1]?.script_key], + }), + expect.objectContaining({ + dimension: "javascript_analysis", + affected_script_keys: [captured.scripts.items[2]?.script_key], + }), + ]), + }); +}); + +it("stops after a middle-script result-capacity failure and retains prefix facts", async () => { + const captured = inspection([ + "import './prefix.js'; fetch('/prefix');", + "fetch('/capacity-blocked');", + "fetch('/must-not-start');", + ]); + const calls: string[] = []; + const caller = new AbortController(); + const primary = new AnalysisResourceConstraintError( + "analyze_web_bundle", + "memory", + "No result capacity remains", + { result_bytes: 0 }, + ); + const analyzer = new IsolatedWebBundleAnalyzer(() => ({ + analyze: async (script) => { + calls.push(script.script_key); + if (calls.length === 1) return ok(analyzeCapturedWebScript(script)); + return err({ + error: primary, + completed: null, + continuation: "result_capacity_exhausted", + }); + }, + close: async () => { + caller.abort(new Error("later cancellation during final close")); + }, + })); + expect((await analyzer.reserve()).ok).toBe(true); + + const result = await analyzer.analyze( + captured, + noSourceMaps, + input, + caller.signal, + ); + analyzer.releaseReservation(); + + expect(calls).toEqual( + captured.scripts.items.slice(0, 2).map(({ script_key }) => script_key), + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected result-capacity failure"); + expect(result.error).toBe(primary); + expect(result.error.partialObservation).toMatchObject({ + completeness: { status: "partial", parsed_scripts: 1 }, + observations: { + chunks: { edges: [{ specifier: "./prefix.js" }] }, + endpoints: [{ value: "/prefix" }], + }, + unknowns: expect.arrayContaining([ + expect.objectContaining({ + dimension: "javascript_analysis", + affected_script_keys: [captured.scripts.items[1]?.script_key], + }), + expect.objectContaining({ + dimension: "javascript_analysis", + affected_script_keys: [captured.scripts.items[2]?.script_key], + }), + ]), + }); +}); + +it("carries the exact caller cancellation reason and completed prefix facts", async () => { + const captured = inspection([ + "fetch('/prefix');", + "fetch('/cancelled');", + "fetch('/cancelled-suffix');", + ]); + const started = deferred(); + const controller = new AbortController(); + const analyzer = new IsolatedWebBundleAnalyzer(() => ({ + analyze: async (script, _timeout, signal) => { + if (script.script_key === captured.scripts.items[0]?.script_key) + return ok(analyzeCapturedWebScript(script)); + started.resolve(); + await waitForAbort(signal); + return err({ + error: new AnalysisCancelledError("analyze_web_bundle", { + cause: signal?.reason, + }), + completed: null, + }); + }, + close: async () => {}, + })); + expect((await analyzer.reserve()).ok).toBe(true); + const exactReason = new Error("caller stopped analysis"); + const pending = analyzer.analyze( + captured, + noSourceMaps, + input, + controller.signal, + ); + await started.promise; + controller.abort(exactReason); + const result = await pending; + analyzer.releaseReservation(); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(exactReason); + expect(result.error.partialObservation).toMatchObject({ + completeness: { status: "partial", parsed_scripts: 1 }, + observations: { endpoints: [{ value: "/prefix" }] }, + unknowns: expect.arrayContaining([ + expect.objectContaining({ + dimension: "javascript_analysis", + affected_script_keys: [captured.scripts.items[1]?.script_key], + }), + expect.objectContaining({ + dimension: "javascript_analysis", + affected_script_keys: [captured.scripts.items[2]?.script_key], + }), + ]), + }); +}); + +it("retains all completed facts when cancellation arrives during worker close", async () => { + const captured = inspection(["fetch('/one');", "fetch('/two');"]); + const closeStarted = deferred(); + let closeWorker: (() => void) | undefined; + const analyzer = new IsolatedWebBundleAnalyzer(() => ({ + analyze: async (script) => ok(analyzeCapturedWebScript(script)), + close: async () => { + closeStarted.resolve(); + await new Promise((resolve) => { + closeWorker = resolve; + }); + }, + })); + expect((await analyzer.reserve()).ok).toBe(true); + const pending = analyzer.analyze(captured, noSourceMaps, input); + await closeStarted.promise; + const closing = analyzer.close(); + closeWorker?.(); + await closing; + const result = await pending; + analyzer.releaseReservation(); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation during finalization"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.partialObservation).toMatchObject({ + completeness: { status: "complete", parsed_scripts: 2 }, + observations: { endpoints: [{ value: "/one" }, { value: "/two" }] }, + }); +}); + +it("retains cleanup ownership after mixed worker/close failure and resolves it on retry", async () => { + const captured = inspection(["fetch('/partial');"]); + const primary = new ProviderCleanupError( + "web-bundle-static-analysis", + ["worker-job"], + { reason: "analysis cleanup pending" }, + { operation: "analyze_web_bundle" }, + ); + const closeFailure = new ProviderCleanupError( + "web-bundle-static-analysis", + ["worker-root"], + { reason: "worker close pending" }, + { operation: "analyze_web_bundle" }, + ); + let closeAttempts = 0; + const analyzer = new IsolatedWebBundleAnalyzer(() => ({ + analyze: async () => err({ error: primary, completed: null }), + close: async () => { + closeAttempts += 1; + if (closeAttempts <= 2) throw closeFailure; + closeFailure.resolveCleanup(); + primary.resolveCleanup(); + }, + })); + expect((await analyzer.reserve()).ok).toBe(true); + + const result = await analyzer.analyze(captured, noSourceMaps, input); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected mixed failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupPrimary).toBe(primary); + expect(result.error.cleanupResources).toEqual(["worker-job", "worker-root"]); + expect(result.error.partialObservation).toMatchObject({ + completeness: { status: "partial", parsed_scripts: 0 }, + }); + + const replacement = new IsolatedWebBundleAnalyzer(() => inertWorker()); + analyzer.releaseReservation(); + const blocked = await replacement.reserve(); + expect(blocked.ok).toBe(false); + + await analyzer.close(); + expect(closeAttempts).toBe(3); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + expect((await replacement.reserve()).ok).toBe(true); + replacement.releaseReservation(); +}); + +it("enforces one process-wide capture reservation across controller instances", async () => { + const first = new IsolatedWebBundleAnalyzer(() => inertWorker()); + const second = new IsolatedWebBundleAnalyzer(() => inertWorker()); + expect((await first.reserve()).ok).toBe(true); + const blocked = await second.reserve(); + expect(blocked.ok).toBe(false); + if (blocked.ok) throw new Error("Expected process-wide admission refusal"); + expect(blocked.error._tag).toBe("AnalysisResourceConstraintError"); + first.releaseReservation(); + expect((await second.reserve()).ok).toBe(true); + second.releaseReservation(); +}); + +const inertWorker = (): WebBundleAnalysisWorkerPort => ({ + analyze: async () => + err({ + error: new AnalysisOutputError("analyze_web_bundle", "unused"), + completed: null, + }), + close: async () => {}, +}); + +const waitForAbort = (signal: AbortSignal | undefined): Promise => { + if (signal?.aborted) return Promise.resolve(); + return new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve(), { once: true }); + }); +}; + +const deferred = (): { + promise: Promise; + resolve: (value: T) => void; +} => { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; + +const inspection = (sources: readonly string[]) => + webPageInspectionSchema.parse({ + browser: { + product: "Fake Chrome", + protocol_version: "1.3", + revision: "1", + user_agent: "fake", + js_version: "1", + }, + target: { + target_id: "page-1", + type: "page", + title: "App", + url: `${origin}/app`, + origin, + attached: false, + }, + capture_window: { + started_at: "2026-07-14T00:00:00.000Z", + ended_at: "2026-07-14T00:00:01.000Z", + observation_ms: 1_000, + }, + completeness: { + status: "attach_limited", + conditions: ["attach_limited"], + policy_filtered_sections: [], + attach_limited_sections: ["network_requests"], + truncated_sections: [], + unavailable_sections: [], + excluded: [], + dropped_events: { + scripts: 0, + network_requests: 0, + console_events: 0, + websocket_connections: 0, + websocket_frames: 0, + webmcp_tools: 0, + timeline_events: 0, + total: 0, + }, + }, + frames: [], + dom: { total_nodes: 0, nodes: [] }, + accessibility: { + total_nodes: 0, + text_capture: { + status: "not_approved", + retained_bytes: 0, + excluded_fields: 0, + }, + nodes: [], + }, + scripts: { + total: sources.length, + items: sources.map((source, index) => ({ + script_key: `scr_${String(index + 1).repeat(64)}`, + url: `${origin}/assets/${String(index)}.js`, + origin, + cdp_hash: `hash-${String(index)}`, + length: Buffer.byteLength(source), + is_module: true, + language: "JavaScript", + source_map_url: null, + resource_reconciliation: { + status: "unmatched", + reason: "no_exact_sanitized_url", + }, + source: { + included: true, + artifact: createWebTextArtifact(source, "text/javascript"), + }, + })), + }, + resources: [], + network: { + requests: [], + websocket_connections: [], + coverage_started_at: "2026-07-14T00:00:00.000Z", + prior_activity_available: false, + }, + console: { + events: [], + coverage_started_at: "2026-07-14T00:00:00.000Z", + prior_activity_available: false, + }, + workers: [], + metadata: { + responses: [], + dom_urls: [], + agent_hints: [], + excluded_dom_urls: 0, + headers_allowlisted: true, + }, + storage: { + origin, + usage_bytes: null, + quota_bytes: null, + local_storage_keys: [], + session_storage_keys: [], + indexed_db_names: [], + cache_names: [], + values_redacted: true, + }, + limitations: [], + }); diff --git a/src/browser/IsolatedWebBundleAnalyzer.ts b/src/browser/IsolatedWebBundleAnalyzer.ts new file mode 100644 index 0000000000..ee77e3684d --- /dev/null +++ b/src/browser/IsolatedWebBundleAnalyzer.ts @@ -0,0 +1,336 @@ +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(); + #closed = false; + #running: Promise> | 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> { + 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> { + 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> { + 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 { + this.#closed = true; + this.#controller?.abort( + new Error("Browser provider closed during bundle analysis"), + ); + await this.#running; + await this.#releaseWorker(); + } + async #releaseWorker(): Promise { + try { + await this.#worker?.close(); + this.#worker = undefined; + IsolatedWebBundleAnalyzer.#pendingCleanup.delete(this); + } catch (cause) { + IsolatedWebBundleAnalyzer.#pendingCleanup.add(this); + throw cause; + } + } +} diff --git a/src/browser/WebBundleAnalysisWorker.test.ts b/src/browser/WebBundleAnalysisWorker.test.ts new file mode 100644 index 0000000000..a75be3a8c5 --- /dev/null +++ b/src/browser/WebBundleAnalysisWorker.test.ts @@ -0,0 +1,360 @@ +import { createHash, randomUUID } from "node:crypto"; +import { access } from "node:fs/promises"; +import { expect, it } from "vitest"; + +import { createWebTextArtifact } from "../domain/webContentArtifact.js"; +import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { + resolvedAnalysisError, + type AnalysisError, +} from "../domain/analysisErrorBase.js"; +import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; +import { + spawnOwnedProviderProcess, + type ProviderProcessHandle, +} from "../process/ProviderProcess.js"; +import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js"; +import { WEB_BUNDLE_WORKER_LIMITS } from "./WebBundleAnalysisWorkerProtocol.js"; + +const makeScript = (text: string): IncludedScript => ({ + script_key: `scr_${createHash("sha256").update(text).digest("hex")}`, + frame_id: "frame-1", + url: "https://app.example.test/app.js", + origin: "https://app.example.test", + cdp_hash: null, + length: Buffer.byteLength(text), + is_module: false, + language: "JavaScript", + source_map_url: null, + resource_reconciliation: { + status: "unmatched", + reason: "no_exact_sanitized_url", + }, + source: { + included: true, + artifact: createWebTextArtifact(text, "application/javascript"), + }, +}); + +const sourceWorkerProgram = ( + hangMarker?: string, + oomMarker?: string, +): string => ` + const { serveWebBundleAnalysisWorker } = await import(${JSON.stringify(new URL("./WebBundleAnalysisWorkerServer.ts", import.meta.url).href)}); + const { analyzeCapturedWebScript } = await import(${JSON.stringify(new URL("../domain/webBundleAnalyzer.ts", import.meta.url).href)}); + const analyze = (script) => { + if (${JSON.stringify(hangMarker)} !== undefined && script.source.artifact.text === ${JSON.stringify(hangMarker)}) { + for (;;) {} + } + if (${JSON.stringify(oomMarker)} !== undefined && script.source.artifact.text === ${JSON.stringify(oomMarker)}) { + const retained = []; + for (;;) retained.push(new Array(50_000).fill({ marker: "bounded-child-oom" })); + } + return analyzeCapturedWebScript(script); + }; + await serveWebBundleAnalysisWorker(analyze); +`; + +type TestMode = + | { + readonly kind: "source"; + readonly hangMarker?: string; + readonly oomMarker?: string; + } + | { readonly kind: "delayed-ready" } + | { + readonly kind: "protocol"; + readonly failure: "wrong-id" | "oversized-control" | "oversized-result"; + }; + +const protocolWorkerProgram = ( + failure: "wrong-id" | "oversized-control" | "oversized-result", +): string => ` + import { createInterface } from "node:readline"; + import { getHeapStatistics } from "node:v8"; + const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); + send({ kind: "ready", heap_limit_bytes: getHeapStatistics().heap_size_limit }); + const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); + input.once("line", (line) => { + const request = JSON.parse(line); + ${ + failure === "wrong-id" + ? `send({ kind: "phase", id: ${JSON.stringify(randomUUID())}, phase: "analyze" });` + : failure === "oversized-control" + ? `process.stdout.write("x".repeat(70 * 1024) + "\\n");` + : `send({ kind: "complete", id: request.id, result: { bytes: request.result_budget_bytes + 1, sha256: "${"0".repeat(64)}" } });` + } + }); +`; + +const delayedReadyProgram = ` + import { createInterface } from "node:readline"; + import { getHeapStatistics } from "node:v8"; + setTimeout(() => { + process.stdout.write(JSON.stringify({ kind: "ready", heap_limit_bytes: getHeapStatistics().heap_size_limit }) + "\\n"); + }, 1_500); + createInterface({ input: process.stdin, crlfDelay: Infinity }); +`; + +interface SpawnedTestProcess { + readonly process: ProviderProcessHandle; + readonly root: string | undefined; +} + +const launcherFor = ( + mode: TestMode, + options: { + readonly failCleanupOnce?: boolean; + readonly onSpawn?: (spawned: SpawnedTestProcess) => void; + } = {}, +): typeof spawnOwnedProviderProcess => { + let failCleanup = options.failCleanupOnce ?? false; + return async (request) => { + const loader = import.meta.resolve("tsx"); + const program = + mode.kind === "source" + ? sourceWorkerProgram(mode.hangMarker, mode.oomMarker) + : mode.kind === "delayed-ready" + ? delayedReadyProgram + : protocolWorkerProgram(mode.failure); + const spawned = await spawnOwnedProviderProcess({ + ...request, + arguments: [ + ...request.arguments.slice(0, -1), + "--import", + loader, + "--input-type=module", + "-e", + program, + ], + }); + options.onSpawn?.({ process: spawned.process, root: request.cwd }); + return { + ...spawned, + cleanup: async () => { + if (failCleanup) { + failCleanup = false; + return { cleaned: false, reason: "controlled worker cleanup retry" }; + } + return spawned.cleanup === undefined + ? cleanupOwnedProcessGroup(spawned.ownership) + : spawned.cleanup(); + }, + }; + }; +}; + +const failureOf = ( + result: Awaited>, +): AnalysisError => { + if (result.ok) throw new Error("Expected web worker failure"); + return result.error.error; +}; + +const waitForExit = async (child: ProviderProcessHandle): Promise => { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | undefined; + const finish = (): void => { + if (timer !== undefined) clearTimeout(timer); + child.off("exit", onExit); + resolve(); + }; + const onExit = (): void => finish(); + child.once("exit", onExit); + timer = setTimeout(() => { + child.off("exit", onExit); + reject(new Error("Owned child did not exit")); + }, 3_000); + if (child.exitCode !== null || child.signalCode !== null) finish(); + }); +}; + +it("reuses one real owned worker for completed facts from consecutive scripts", async () => { + const children: ProviderProcessHandle[] = []; + const worker = new WebBundleAnalysisWorker( + WEB_BUNDLE_WORKER_LIMITS.heapMb, + launcherFor( + { kind: "source" }, + { onSpawn: ({ process: child }) => children.push(child) }, + ), + ); + try { + const first = await worker.analyze(makeScript("fetch('/users');"), 10_000); + const second = await worker.analyze( + makeScript("fetch('/orders');"), + 10_000, + ); + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) + throw new Error("Expected both captured scripts to complete"); + expect(first.value).toMatchObject({ + status: "analyzed", + script_key: makeScript("fetch('/users');").script_key, + }); + expect(second.value).toMatchObject({ + status: "analyzed", + script_key: makeScript("fetch('/orders');").script_key, + }); + expect(children).toHaveLength(1); + } finally { + await worker.close(); + } +}); + +it("kills a hung owned child at the deadline and starts a fresh worker for the next script", async () => { + const children: ProviderProcessHandle[] = []; + const worker = new WebBundleAnalysisWorker( + WEB_BUNDLE_WORKER_LIMITS.heapMb, + launcherFor( + { kind: "source", hangMarker: "hang forever" }, + { onSpawn: ({ process: child }) => children.push(child) }, + ), + ); + try { + const timedOut = await worker.analyze(makeScript("hang forever"), 500); + expect(failureOf(timedOut)).toBeInstanceOf(AnalysisTimeoutError); + await waitForExit(children[0]!); + expect( + children[0]?.exitCode !== null || children[0]?.signalCode !== null, + ).toBe(true); + const recovered = await worker.analyze( + makeScript("fetch('/after-timeout');"), + 10_000, + ); + expect(recovered.ok).toBe(true); + expect(children).toHaveLength(2); + } finally { + await worker.close(); + } +}); + +it("contains child heap exhaustion and reuses the host for a later script", async () => { + const children: ProviderProcessHandle[] = []; + const roots: string[] = []; + const worker = new WebBundleAnalysisWorker( + 128, + launcherFor( + { kind: "source", oomMarker: "exhaust child heap" }, + { + onSpawn: ({ process: child, root }) => { + children.push(child); + if (root !== undefined) roots.push(root); + }, + }, + ), + ); + try { + const exhausted = await worker.analyze( + makeScript("exhaust child heap"), + 15_000, + ); + expect(failureOf(exhausted)).toBeInstanceOf( + AnalysisResourceConstraintError, + ); + await waitForExit(children[0]!); + expect( + children[0]?.exitCode !== null || children[0]?.signalCode !== null, + ).toBe(true); + const recovered = await worker.analyze( + makeScript("fetch('/after-oom');"), + 10_000, + ); + expect(recovered.ok).toBe(true); + expect(children).toHaveLength(2); + expect(roots).toHaveLength(2); + expect(roots[0]).toBe(roots[1]); + await access(roots[0]!); + await worker.close(); + await expect(access(roots[0]!)).rejects.toThrow(); + } finally { + await worker.close(); + } +}); + +it("cancels during the late ready handshake and retains failed cleanup until exact retry", async () => { + const children: SpawnedTestProcess[] = []; + let announceSpawn: () => void = () => {}; + const spawned = new Promise((resolve) => { + announceSpawn = resolve; + }); + const worker = new WebBundleAnalysisWorker( + WEB_BUNDLE_WORKER_LIMITS.heapMb, + launcherFor( + { kind: "delayed-ready" }, + { + failCleanupOnce: true, + onSpawn: (child) => { + children.push(child); + announceSpawn(); + }, + }, + ), + ); + const controller = new AbortController(); + const reason = { source: "late-handshake-test" }; + try { + const analyzing = worker.analyze( + makeScript("fetch('/cancelled');"), + 10_000, + controller.signal, + ); + await spawned; + controller.abort(reason); + const result = await analyzing; + const error = failureOf(result); + expect(error.cleanupIncomplete).toBe(true); + expect(error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); + expect(error.cleanupPrimary?.cause).toBe(reason); + expect(children[0]?.root).toBeDefined(); + await access(children[0]?.root ?? ""); + + await worker.close(); + expect(error.cleanupIncomplete).toBe(false); + expect(resolvedAnalysisError(error).cause).toBe(reason); + await expect(access(children[0]?.root ?? "")).rejects.toThrow(); + await waitForExit(children[0]!.process); + } finally { + if ( + children[0]?.process.exitCode === null && + children[0]?.process.signalCode === null + ) + children[0]?.process.kill("SIGKILL"); + await worker.close().catch(() => undefined); + await WebBundleAnalysisWorker.retryPending().catch(() => undefined); + } +}); + +it.each([ + ["wrong-id", AnalysisOutputError], + ["oversized-control", AnalysisOutputError], + ["oversized-result", AnalysisResourceConstraintError], +] as const)( + "rejects %s before accepting unverified result bytes", + async (failure, ErrorType) => { + const worker = new WebBundleAnalysisWorker( + WEB_BUNDLE_WORKER_LIMITS.heapMb, + launcherFor({ kind: "protocol", failure }), + ); + try { + const result = await worker.analyze( + makeScript("fetch('/not-read');"), + 10_000, + ); + expect(failureOf(result)).toBeInstanceOf(ErrorType); + if (failure === "oversized-result") + expect(failureOf(result).message).toContain( + "exceed the host's remaining reserved result capacity", + ); + } finally { + await worker.close(); + } + }, +); diff --git a/src/browser/WebBundleAnalysisWorker.ts b/src/browser/WebBundleAnalysisWorker.ts new file mode 100644 index 0000000000..74a99c7182 --- /dev/null +++ b/src/browser/WebBundleAnalysisWorker.ts @@ -0,0 +1,612 @@ +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/webBundleAnalyzerInspection.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 { + WEB_BUNDLE_WORKER_LIMITS, + webBundleWorkerMessageSchema, + webBundleWorkerRequestSchema, + type WebBundleWorkerMessage, +} from "./WebBundleAnalysisWorkerProtocol.js"; + +const OPERATION = "analyze_web_bundle"; +const PROVIDER = "web-bundle-static-analysis"; +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"]; + +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(); + readonly #runtime: OwnedWorkerRuntime; + readonly #files = new ArtifactResourceScope(); + readonly #cleanup: AnalysisCleanupLease; + #decoder = new StringDecoder("utf8"); + #pending = ""; + #frames: WebBundleWorkerMessage[] = []; + #protocolFailure: unknown; + #retainedResultBytes = 0; + #ready = false; + readonly #stdout = (chunk: Buffer | string): void => { + 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("Web analyzer exceeded its 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.#pending = this.#pending.slice(end + 1); + } + } catch (cause) { + this.#pending = ""; + this.#protocolFailure = cause; + } + }; + + constructor( + readonly heapMb = 1024, + launcher: typeof spawnOwnedProviderProcess = spawnOwnedProviderProcess, + ) { + if (!Number.isSafeInteger(heapMb) || heapMb < 128 || heapMb > 16_384) + throw new RangeError( + "Web analyzer heap must be an integer from 128 through 16384 MiB", + ); + this.#runtime = new OwnedWorkerRuntime({ + rootPrefix: "rea-web-bundle-analysis-", + launcher, + retryCleanup: () => this.close(), + supervisorOptions: { + captureStdout: false, + maxDiagnosticBytes: 256 * 1024, + }, + }); + const owner = this; + this.#cleanup = new AnalysisCleanupLease(() => this.#release(), { + get cleanupResources() { + return [ + ...owner.#files.cleanupResources, + ...(owner.#runtime.cleanupResources ?? []), + ]; + }, + }); + } + + static async retryPending(): Promise { + for (const worker of [...this.#pendingCleanup]) await worker.close(); + await OwnedWorkerRuntime.retryPending(); + } + + async analyze( + script: IncludedScript, + timeoutMs: number, + signal?: AbortSignal, + ): Promise> { + let deadline: ProviderStartupDeadline | undefined; + const id = randomUUID(); + let result: Result; + let completed: WebBundleScriptAnalysis | null = null; + let continuation: "result_capacity_exhausted" | undefined; + let resultDescriptor: WebBundleResultDescriptor | undefined; + const failed = ( + error: AnalysisError, + ): Result => + err({ + error, + completed, + ...(continuation === undefined ? {} : { continuation }), + }); + try { + if (signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { cause: signal.reason }); + const resultBudget = Math.min( + WEB_BUNDLE_WORKER_LIMITS.resultBytes - this.#retainedResultBytes, + Math.max( + 0, + Math.floor( + (getHeapStatistics().heap_size_limit - + process.memoryUsage().heapUsed - + 64 * MIB) / + RESULT_EXPANSION, + ), + ), + ); + if (resultBudget <= 0) { + continuation = "result_capacity_exhausted"; + throw new AnalysisResourceConstraintError( + OPERATION, + "memory", + "The host has no remaining reserved web-analysis result capacity", + { result_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes }, + ); + } + await prepareProcessOwnershipInspection(signal); + deadline = new ProviderStartupDeadline(timeoutMs, signal); + 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"); + await new Promise((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" + ? new AnalysisTimeoutError(OPERATION, 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 failure = + cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "completed" + ? this.#cleanupFailure(cause, interrupted) + : (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 { + deadline?.dispose(); + } + // 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); + } + + 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", + "Captured script metadata and source exceed the 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 { + 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 { + for (;;) { + if (deadline.interruption === "timeout") + throw new AnalysisTimeoutError(OPERATION, deadline.timeoutMs); + if (deadline.interruption === "cancelled") + throw new AnalysisCancelledError(OPERATION, { + cause: deadline.signal.reason, + }); + deadline.signal.throwIfAborted(); + if (this.#runtime.inputFailure !== undefined) + throw new AnalysisOutputError( + OPERATION, + "Web analyzer request stream failed", + { cause: this.#runtime.inputFailure }, + ); + if (this.#protocolFailure !== undefined) + throw new AnalysisOutputError( + OPERATION, + "Invalid web analyzer control frame", + { cause: this.#protocolFailure }, + ); + const frame = this.#frames.shift(); + if (frame !== undefined) return frame; + const supervisor = this.#runtime.supervisor; + const snapshot = supervisor?.snapshot(); + if ( + snapshot !== undefined && + (snapshot.exitCode !== undefined || snapshot.signal !== undefined) + ) { + if (!(await supervisor?.waitForOutputClose(10))) continue; + if (this.#frames.length > 0) continue; + const finalSnapshot = supervisor?.snapshot() ?? snapshot; + const capturedOutput = { + stdout: "", + stderr: finalSnapshot.stderr.text, + truncated: finalSnapshot.diagnosticTruncated ?? false, + exit_code: finalSnapshot.exitCode ?? null, + signal: finalSnapshot.signal ?? null, + }; + if ( + supervisor?.launch.process.memoryLimitReached === true || + /heap out of memory|heap limit|allocation failed/iu.test( + finalSnapshot.stderr.text, + ) + ) + throw new AnalysisResourceConstraintError( + OPERATION, + "memory", + "Owned web analyzer exhausted its configured memory limit", + { + worker_heap_limit_mb: this.heapMb, + worker_process_memory_limit_bytes: + javaScriptWorkerMemoryLimitBytes(this.heapMb), + }, + { capturedOutput }, + ); + throw new AnalysisOutputError( + OPERATION, + "Owned web analyzer exited before completing this script", + { capturedOutput }, + ); + } + await deadline.wait(10); + } + } + + #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; + } + + #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 { + try { + await this.#cleanup.release(); + } catch (cause) { + throw this.#cleanupFailure(cause); + } + } + + async #release(): Promise { + 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; + } + } +} diff --git a/src/browser/WebBundleAnalysisWorkerCapacity.test.ts b/src/browser/WebBundleAnalysisWorkerCapacity.test.ts new file mode 100644 index 0000000000..7d43f53693 --- /dev/null +++ b/src/browser/WebBundleAnalysisWorkerCapacity.test.ts @@ -0,0 +1,82 @@ +import { createHash } from "node:crypto"; +import { expect, it, vi } from "vitest"; + +const heapLimit = vi.hoisted(() => ({ override: null as number | null })); + +vi.mock("node:v8", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getHeapStatistics: () => { + const statistics = actual.getHeapStatistics(); + return heapLimit.override === null + ? statistics + : { ...statistics, heap_size_limit: heapLimit.override }; + }, + }; +}); + +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { createWebTextArtifact } from "../domain/webContentArtifact.js"; +import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js"; +import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js"; + +it("refuses before spawning or reading source when no host result capacity remains", async () => { + const text = "fetch('/never-serialized');"; + const base = makeScript(text); + let sourceReads = 0; + let launches = 0; + const artifact = { ...base.source.artifact }; + Object.defineProperty(artifact, "text", { + configurable: true, + get: () => { + sourceReads += 1; + return text; + }, + }); + const script: IncludedScript = { + ...base, + source: { included: true, artifact }, + }; + const worker = new WebBundleAnalysisWorker(1024, async () => { + launches += 1; + throw new Error( + "The worker must not launch when the host has no result capacity", + ); + }); + heapLimit.override = 0; + + try { + const result = await worker.analyze(script, 10_000); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected result-capacity refusal"); + expect(result.error.error).toBeInstanceOf(AnalysisResourceConstraintError); + expect(result.error.continuation).toBe("result_capacity_exhausted"); + expect(result.error.completed).toBeNull(); + expect(sourceReads).toBe(0); + expect(launches).toBe(0); + } finally { + heapLimit.override = null; + await worker.close(); + } +}); + +const makeScript = (text: string): IncludedScript => ({ + script_key: `scr_${createHash("sha256").update(text).digest("hex")}`, + frame_id: "frame-1", + url: "https://app.example.test/app.js", + origin: "https://app.example.test", + cdp_hash: null, + length: Buffer.byteLength(text), + is_module: false, + language: "JavaScript", + source_map_url: null, + resource_reconciliation: { + status: "unmatched", + reason: "no_exact_sanitized_url", + }, + source: { + included: true, + artifact: createWebTextArtifact(text, "application/javascript"), + }, +}); diff --git a/src/browser/WebBundleAnalysisWorkerProtocol.ts b/src/browser/WebBundleAnalysisWorkerProtocol.ts new file mode 100644 index 0000000000..527c2e62eb --- /dev/null +++ b/src/browser/WebBundleAnalysisWorkerProtocol.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { digestSchema } from "../domain/digests.js"; +import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js"; +import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js"; +import { webTextArtifactSchema } from "../domain/webContentArtifact.js"; + +/** Independent per-script worker limits for source records and control frames. */ +export const WEB_BUNDLE_WORKER_LIMITS = { + heapMb: 1024, + scriptTimeoutMs: 30_000, + totalTimeoutMs: 300_000, + protocolBytes: 64 * 1024, + sourceBytes: 8 * WEB_RUNTIME_LIMITS.sourceBytes, + resultBytes: 8 * 1024 * 1024, +} as const; + +const includedSourceSchema = z.strictObject({ + included: z.literal(true), + artifact: webTextArtifactSchema.strict(), +}); + +const browserScriptSchema = + webPageInspectionSchema.shape.scripts.shape.items.element; + +/** Derive producer metadata rules; the worker requires captured source text. */ +export const includedWebScriptSchema = browserScriptSchema + .extend({ source: includedSourceSchema }) + .strict(); + +export const webBundleWorkerRequestSchema = z.strictObject({ + kind: z.literal("analyze"), + id: z.uuid(), + source: z.strictObject({ + bytes: z.number().int().min(0).max(WEB_BUNDLE_WORKER_LIMITS.sourceBytes), + sha256: digestSchema, + }), + result_budget_bytes: z + .number() + .int() + .min(0) + .max(WEB_BUNDLE_WORKER_LIMITS.resultBytes), +}); + +const identity = { id: z.uuid() }; + +/** Control frames contain descriptors and progress only, never source or findings. */ +export const webBundleWorkerMessageSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("ready"), + heap_limit_bytes: z.number().int().positive().nullable(), + }), + z.strictObject({ + kind: z.literal("phase"), + ...identity, + phase: z.literal("analyze"), + }), + z.strictObject({ + kind: z.literal("complete"), + ...identity, + result: z.strictObject({ + bytes: z.number().int().nonnegative(), + sha256: digestSchema, + }), + }), + z.strictObject({ + kind: z.literal("failure"), + ...identity, + reason: z.enum(["input", "resource", "output", "io"]), + message: z.string(), + }), +]); + +export type WebBundleWorkerMessage = z.output< + typeof webBundleWorkerMessageSchema +>; diff --git a/src/browser/WebBundleAnalysisWorkerReadCompletion.test.ts b/src/browser/WebBundleAnalysisWorkerReadCompletion.test.ts new file mode 100644 index 0000000000..e7135d723a --- /dev/null +++ b/src/browser/WebBundleAnalysisWorkerReadCompletion.test.ts @@ -0,0 +1,174 @@ +import { createHash } from "node:crypto"; +import { access } from "node:fs/promises"; +import { expect, it, vi } from "vitest"; + +const closeFailure = vi.hoisted(() => ({ + enabled: false, + resultCloseAttempts: 0, + root: undefined as string | undefined, + rootRemovedBeforeResultClose: false, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rm: async (...args: Parameters) => { + if ( + closeFailure.root !== undefined && + String(args[0]) === closeFailure.root && + closeFailure.resultCloseAttempts < 2 + ) + closeFailure.rootRemovedBeforeResultClose = true; + return await actual.rm(...args); + }, + open: async (...args: Parameters) => { + const handle = await actual.open(...args); + const path = args[0]; + if (closeFailure.enabled && String(path).endsWith(".result")) { + closeFailure.enabled = false; + const close = handle.close.bind(handle); + let failOnce = true; + Object.defineProperty(handle, "close", { + configurable: true, + value: async () => { + closeFailure.resultCloseAttempts += 1; + if (failOnce) { + failOnce = false; + throw new Error("controlled completed-read close failure"); + } + await close(); + }, + }); + } + return handle; + }, + }; +}); + +import { createWebTextArtifact } from "../domain/webContentArtifact.js"; +import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; +import { + spawnOwnedProviderProcess, + type ProviderProcessHandle, +} from "../process/ProviderProcess.js"; +import { OwnedWorkerRuntime } from "../process/OwnedWorkerRuntime.js"; +import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js"; +import { WEB_BUNDLE_WORKER_LIMITS } from "./WebBundleAnalysisWorkerProtocol.js"; + +const makeScript = (text: string): IncludedScript => ({ + script_key: `scr_${createHash("sha256").update(text).digest("hex")}`, + frame_id: "frame-1", + url: "https://app.example.test/app.js", + origin: "https://app.example.test", + cdp_hash: null, + length: Buffer.byteLength(text), + is_module: false, + language: "JavaScript", + source_map_url: null, + resource_reconciliation: { + status: "unmatched", + reason: "no_exact_sanitized_url", + }, + source: { + included: true, + artifact: createWebTextArtifact(text, "application/javascript"), + }, +}); + +const ownedSourceLauncher = ( + failCleanupOnce = false, +): typeof spawnOwnedProviderProcess => { + let failCleanup = failCleanupOnce; + return async (request) => { + const loader = import.meta.resolve("tsx"); + const server = new URL( + "./WebBundleAnalysisWorkerServer.ts", + import.meta.url, + ); + const analyzer = new URL("../domain/webBundleAnalyzer.ts", import.meta.url); + const program = ` + const { serveWebBundleAnalysisWorker } = await import(${JSON.stringify(server.href)}); + const { analyzeCapturedWebScript } = await import(${JSON.stringify(analyzer.href)}); + await serveWebBundleAnalysisWorker(analyzeCapturedWebScript); + `; + const spawned = await spawnOwnedProviderProcess({ + ...request, + arguments: [ + `--max-old-space-size=${String(WEB_BUNDLE_WORKER_LIMITS.heapMb)}`, + "--expose-gc", + "--import", + loader, + "--input-type=module", + "-e", + program, + ], + }); + closeFailure.root = request.cwd; + return { + ...spawned, + cleanup: async () => { + if (failCleanup) { + failCleanup = false; + return { + cleaned: false, + reason: "controlled first process cleanup failure", + }; + } + return spawned.cleanup === undefined + ? cleanupOwnedProcessGroup(spawned.ownership) + : spawned.cleanup(); + }, + }; + }; +}; + +it("retains completed facts and resolves a result-file lease through another cleanup consumer", async () => { + const children: ProviderProcessHandle[] = []; + const launch = ownedSourceLauncher(true); + const worker = new WebBundleAnalysisWorker( + WEB_BUNDLE_WORKER_LIMITS.heapMb, + async (request) => { + const spawned = await launch(request); + children.push(spawned.process); + return spawned; + }, + ); + closeFailure.resultCloseAttempts = 0; + closeFailure.rootRemovedBeforeResultClose = false; + closeFailure.enabled = true; + try { + const result = await worker.analyze( + makeScript("fetch('/retained');"), + 10_000, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected retained result cleanup failure"); + expect(result.error.completed).toMatchObject({ + status: "analyzed", + script_key: makeScript("fetch('/retained');").script_key, + observations: { + endpoints: [{ value: "/retained", mechanism: "call:fetch" }], + }, + }); + expect(result.error.error.cleanupPrimary).toBeInstanceOf( + ProviderCleanupError, + ); + expect(result.error.error.cleanupIncomplete).toBe(true); + expect(closeFailure.resultCloseAttempts).toBe(1); + expect(closeFailure.root).toBeDefined(); + await access(closeFailure.root ?? ""); + expect(children).toHaveLength(1); + + await OwnedWorkerRuntime.retryPending(); + expect(result.error.error.cleanupIncomplete).toBe(false); + expect(closeFailure.resultCloseAttempts).toBeGreaterThanOrEqual(2); + expect(closeFailure.rootRemovedBeforeResultClose).toBe(false); + await expect(access(closeFailure.root ?? "")).rejects.toThrow(); + } finally { + await worker.close().catch(() => undefined); + await OwnedWorkerRuntime.retryPending().catch(() => undefined); + } +}); diff --git a/src/browser/WebBundleAnalysisWorkerServer.test.ts b/src/browser/WebBundleAnalysisWorkerServer.test.ts new file mode 100644 index 0000000000..fdd9159182 --- /dev/null +++ b/src/browser/WebBundleAnalysisWorkerServer.test.ts @@ -0,0 +1,231 @@ +import { createHash, randomUUID } from "node:crypto"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { expect, it } from "vitest"; + +import { createWebTextArtifact } from "../domain/webContentArtifact.js"; +import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js"; +import { + webBundleWorkerMessageSchema, + type WebBundleWorkerMessage, +} from "./WebBundleAnalysisWorkerProtocol.js"; +import { WEB_BUNDLE_WORKER_LIMITS } from "./WebBundleAnalysisWorkerProtocol.js"; + +const makeScript = (text: string): IncludedScript => ({ + script_key: `scr_${createHash("sha256").update(text).digest("hex")}`, + frame_id: "frame-1", + url: "https://app.example.test/app.js", + origin: "https://app.example.test", + cdp_hash: null, + length: Buffer.byteLength(text), + is_module: false, + language: "JavaScript", + source_map_url: null, + resource_reconciliation: { + status: "unmatched", + reason: "no_exact_sanitized_url", + }, + source: { + included: true, + artifact: createWebTextArtifact(text, "application/javascript"), + }, +}); + +const sourceDescriptor = (script: IncludedScript) => { + const bytes = Buffer.from(JSON.stringify(script)); + return { + bytes, + descriptor: { + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }; +}; + +const writeSource = async ( + root: string, + id: string, + bytes: Buffer, +): Promise => { + await writeFile(join(root, `${id}.source`), bytes, { + flag: "wx", + mode: 0o600, + }); +}; + +const startServer = async ( + root: string, +): Promise<{ + readonly process: ChildProcessWithoutNullStreams; + readonly next: () => Promise; +}> => { + const loader = import.meta.resolve("tsx"); + const serverUrl = new URL( + "./WebBundleAnalysisWorkerServer.ts", + import.meta.url, + ).href; + const analyzerUrl = new URL("../domain/webBundleAnalyzer.ts", import.meta.url) + .href; + const program = ` + const { serveWebBundleAnalysisWorker } = await import(${JSON.stringify(serverUrl)}); + const { analyzeCapturedWebScript } = await import(${JSON.stringify(analyzerUrl)}); + await serveWebBundleAnalysisWorker(analyzeCapturedWebScript); + `; + const child = spawn( + process.execPath, + [ + "--max-old-space-size=128", + "--import", + loader, + "--input-type=module", + "-e", + program, + ], + { cwd: root, stdio: ["pipe", "pipe", "pipe"] }, + ); + const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); + const iterator = lines[Symbol.asyncIterator](); + return { + process: child, + next: async () => { + const line = await iterator.next(); + if (line.done) { + const stderr = await readStream(child.stderr); + throw new Error(`Worker exited before a control frame: ${stderr}`); + } + const raw: unknown = JSON.parse(line.value); + return webBundleWorkerMessageSchema.parse(raw); + }, + }; +}; + +const readStream = async (stream: NodeJS.ReadableStream): Promise => { + let output = ""; + for await (const chunk of stream) output += String(chunk); + return output; +}; + +const send = async ( + worker: ChildProcessWithoutNullStreams, + request: unknown, +): Promise => { + const frame = `${JSON.stringify(request)}\n`; + await new Promise((resolve, reject) => { + worker.stdin.write(frame, (error) => + error === null || error === undefined ? resolve() : reject(error), + ); + }); +}; + +it("bounds and binds per-script worker jobs, then reuses one worker for the next script", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-web-worker-server-test-")); + let worker: ChildProcessWithoutNullStreams | undefined; + try { + const server = await startServer(root); + worker = server.process; + const ready = await server.next(); + expect(ready).toMatchObject({ kind: "ready" }); + if (ready.kind !== "ready") throw new Error("Expected worker ready frame"); + expect(ready.heap_limit_bytes).not.toBeNull(); + expect(ready.heap_limit_bytes).toBeLessThan(512 * 1024 * 1024); + expect(WEB_BUNDLE_WORKER_LIMITS).toMatchObject({ + protocolBytes: 64 * 1024, + sourceBytes: 8 * 32 * 1024 * 1024, + resultBytes: 8 * 1024 * 1024, + }); + + const script = makeScript("fetch('/users');"); + const input = sourceDescriptor(script); + const missingId = randomUUID(); + await writeSource(root, randomUUID(), input.bytes); + await send(worker, { + kind: "analyze", + id: missingId, + source: input.descriptor, + result_budget_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes, + }); + const missing = await server.next(); + expect(missing).toMatchObject({ + kind: "failure", + id: missingId, + reason: "io", + }); + + const digestId = randomUUID(); + await writeSource(root, digestId, input.bytes); + await send(worker, { + kind: "analyze", + id: digestId, + source: { ...input.descriptor, sha256: "0".repeat(64) }, + result_budget_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes, + }); + expect(await server.next()).toMatchObject({ + kind: "failure", + id: digestId, + reason: "input", + }); + + const budgetId = randomUUID(); + await writeSource(root, budgetId, input.bytes); + await send(worker, { + kind: "analyze", + id: budgetId, + source: input.descriptor, + result_budget_bytes: 1, + }); + expect(await server.next()).toMatchObject({ + kind: "phase", + id: budgetId, + phase: "analyze", + }); + expect(await server.next()).toMatchObject({ + kind: "failure", + id: budgetId, + reason: "resource", + }); + + const nextScript = makeScript("fetch('/orders');"); + const nextInput = sourceDescriptor(nextScript); + const nextId = randomUUID(); + await writeSource(root, nextId, nextInput.bytes); + await send(worker, { + kind: "analyze", + id: nextId, + source: nextInput.descriptor, + result_budget_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes, + }); + expect(await server.next()).toMatchObject({ + kind: "phase", + id: nextId, + phase: "analyze", + }); + const complete = await server.next(); + expect(complete).toMatchObject({ kind: "complete", id: nextId }); + if (complete.kind !== "complete") + throw new Error("Expected result descriptor"); + const result = await readFile(join(root, `${nextId}.result`)); + expect(result.length).toBe(complete.result.bytes); + expect(createHash("sha256").update(result).digest("hex")).toBe( + complete.result.sha256, + ); + expect(JSON.parse(result.toString("utf8"))).toMatchObject({ + status: "analyzed", + script_key: nextScript.script_key, + }); + } finally { + if (worker !== undefined) { + worker.stdin.end(); + if (worker.exitCode === null && worker.signalCode === null) { + const exited = once(worker, "exit"); + const timeout = setTimeout(() => worker?.kill("SIGKILL"), 2_000); + await exited; + clearTimeout(timeout); + } + } + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/browser/WebBundleAnalysisWorkerServer.ts b/src/browser/WebBundleAnalysisWorkerServer.ts new file mode 100644 index 0000000000..4c3e701c8a --- /dev/null +++ b/src/browser/WebBundleAnalysisWorkerServer.ts @@ -0,0 +1,235 @@ +import { createHash } from "node:crypto"; +import { open } from "node:fs/promises"; +import { join } from "node:path"; +import { StringDecoder } from "node:string_decoder"; +import { getHeapStatistics } from "node:v8"; +import { withArtifactFileHandle } from "../artifacts/ArtifactFileOperation.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { jsonParts } from "../domain/jsonSerialization.js"; +import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js"; +import { + webBundleScriptAnalysisSchema, + type WebBundleScriptAnalysis, +} from "../domain/webBundleScriptAnalysis.js"; +import { javaScriptRuntime } from "../domain/runtimeVersion.js"; +import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + WEB_BUNDLE_WORKER_LIMITS as LIMITS, + includedWebScriptSchema, + webBundleWorkerRequestSchema, + webBundleWorkerMessageSchema, + type WebBundleWorkerMessage, +} from "./WebBundleAnalysisWorkerProtocol.js"; + +export interface WebBundleScriptAnalyzer { + (script: IncludedScript): WebBundleScriptAnalysis; +} + +class WebBundleWorkerInputError extends Error {} +class WebBundleWorkerOutputError extends Error {} + +const send = async (input: WebBundleWorkerMessage): Promise => { + const value = webBundleWorkerMessageSchema.parse(input); + const frame = `${JSON.stringify(value)}\n`; + if (Buffer.byteLength(frame) > LIMITS.protocolBytes) + throw new RangeError( + "Web bundle worker control frame exceeds its byte budget", + ); + await new Promise((resolve, reject) => { + process.stdout.write(frame, (error) => + error === null || error === undefined ? resolve() : reject(error), + ); + }); +}; + +async function* boundedControlLines(): AsyncGenerator { + const decoder = new StringDecoder("utf8"); + let pending = ""; + for await (const chunk of process.stdin) { + const decoded = decoder.write(chunk); + let start = 0; + for (;;) { + const newline = decoded.indexOf("\n", start); + if (newline < 0) { + pending += decoded.slice(start); + if (Buffer.byteLength(pending) > LIMITS.protocolBytes) + throw new RangeError( + "Web bundle worker request exceeds its byte budget", + ); + break; + } + const line = `${pending}${decoded.slice(start, newline)}`.replace( + /\r$/u, + "", + ); + pending = ""; + if (Buffer.byteLength(line) > LIMITS.protocolBytes) + throw new RangeError( + "Web bundle worker request exceeds its byte budget", + ); + yield line; + start = newline + 1; + } + } + pending += decoder.end(); + if (pending !== "") { + if (Buffer.byteLength(pending) > LIMITS.protocolBytes) + throw new RangeError("Web bundle worker request exceeds its byte budget"); + yield pending; + } +} + +/** Serve sequential script jobs from the private root owned by the worker runtime. */ +export const serveWebBundleAnalysisWorker = async ( + analyze: WebBundleScriptAnalyzer, +): Promise => { + const root = process.cwd(); + const resources = new ArtifactResourceScope(); + const requests = boundedControlLines(); + await send({ + kind: "ready", + heap_limit_bytes: + javaScriptRuntime(process.versions).id === "node" + ? getHeapStatistics().heap_size_limit + : null, + }); + try { + for (;;) { + const next = await requests.next(); + if (next.done) break; + if (Buffer.byteLength(next.value) > LIMITS.protocolBytes) + throw new RangeError( + "Web bundle worker request exceeds its byte budget", + ); + const raw: unknown = JSON.parse(next.value); + const request = webBundleWorkerRequestSchema.parse(raw); + const continueServing = await analyzeJob( + root, + request, + analyze, + resources, + ); + if (!continueServing) break; + if ( + process.memoryUsage().heapUsed > + getHeapStatistics().heap_size_limit / 2 + ) + global.gc?.(); + } + } finally { + await requests.return(undefined); + } +}; + +const analyzeJob = async ( + root: string, + request: ReturnType, + analyze: WebBundleScriptAnalyzer, + resources: ArtifactResourceScope, +): Promise => { + try { + const sourcePath = join(root, `${request.id}.source`); + const source = await readStableArtifact( + sourcePath, + LIMITS.sourceBytes, + undefined, + undefined, + resources, + ); + if ( + source.bytes.length !== request.source.bytes || + source.sha256 !== request.source.sha256 + ) + throw new WebBundleWorkerInputError( + "Web bundle worker source descriptor does not match its file", + ); + let script: IncludedScript; + try { + const decoded: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(source.bytes), + ); + script = includedWebScriptSchema.parse(decoded); + } catch (cause) { + throw new WebBundleWorkerInputError( + `Web bundle worker source is not a valid included script: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + await send({ kind: "phase", id: request.id, phase: "analyze" }); + let result: WebBundleScriptAnalysis; + try { + result = webBundleScriptAnalysisSchema.parse(analyze(script)); + } catch (cause) { + throw new WebBundleWorkerOutputError( + `Web bundle analyzer returned an invalid result: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + if (result.status !== "analyzed") + throw new WebBundleWorkerOutputError( + "Web bundle analyzer did not produce a completed per-script result", + ); + const resultPath = join(root, `${request.id}.result`); + const file = await open(resultPath, "wx", 0o600); + const hash = createHash("sha256"); + let bytes = 0; + await withArtifactFileHandle( + resultPath, + file, + resources, + undefined, + async (handle) => { + for (const part of jsonParts(result)) { + const chunk = Buffer.from(part); + bytes += chunk.length; + if (bytes > request.result_budget_bytes) + throw new RangeError( + "Web bundle worker result exceeds its admitted byte budget", + ); + hash.update(chunk); + let offset = 0; + while (offset < chunk.length) { + const written = await handle.write( + chunk, + offset, + chunk.length - offset, + ); + if (written.bytesWritten === 0) + throw new Error( + "Web bundle worker result write made no progress", + ); + offset += written.bytesWritten; + } + } + }, + ); + await send({ + kind: "complete", + id: request.id, + result: { bytes, sha256: hash.digest("hex") }, + }); + return true; + } catch (cause: unknown) { + const resource = cause instanceof RangeError; + const input = cause instanceof WebBundleWorkerInputError; + const fatalCleanupFailure = + cause instanceof ArtifactReaderFailure && + cause.cleanupLeases !== undefined && + cause.cleanupLeases.length > 0; + const rawMessage = cause instanceof Error ? cause.message : String(cause); + await send({ + kind: "failure", + id: request.id, + reason: resource + ? "resource" + : input + ? "input" + : cause instanceof WebBundleWorkerOutputError || + cause instanceof TypeError + ? "output" + : "io", + message: + rawMessage.length > 2048 ? `${rawMessage.slice(0, 2048)}…` : rawMessage, + }); + return !fatalCleanupFailure; + } +}; diff --git a/src/cli/browserCommands.ts b/src/cli/browserCommands.ts index cd4cde56e4..5585218a67 100644 --- a/src/cli/browserCommands.ts +++ b/src/cli/browserCommands.ts @@ -158,6 +158,10 @@ const registerBundleAnalysis = ( .boolean() .default(false) .describe("Fetch source maps referenced by captured scripts"), + maxHeapMb: analyzeWebBundleInputSchema.shape.max_heap_mb, + analysisTimeoutMs: analyzeWebBundleInputSchema.shape.analysis_timeout_ms, + totalAnalysisTimeoutMs: + analyzeWebBundleInputSchema.shape.total_analysis_timeout_ms, }), run: ({ args, options }) => withCommandCancellation((signal) => @@ -170,6 +174,15 @@ const registerBundleAnalysis = ( observation_ms: options.observationMs, include_script_sources: true, fetch_source_maps: options.fetchSourceMaps, + ...(options.maxHeapMb === undefined + ? {} + : { max_heap_mb: options.maxHeapMb }), + ...(options.analysisTimeoutMs === undefined + ? {} + : { analysis_timeout_ms: options.analysisTimeoutMs }), + ...(options.totalAnalysisTimeoutMs === undefined + ? {} + : { total_analysis_timeout_ms: options.totalAnalysisTimeoutMs }), }; const parsed = analyzeWebBundleInputSchema.safeParse(input); if (!parsed.success) diff --git a/src/composition/webBundleAnalysisWorkerProcess.ts b/src/composition/webBundleAnalysisWorkerProcess.ts new file mode 100644 index 0000000000..967bd623f5 --- /dev/null +++ b/src/composition/webBundleAnalysisWorkerProcess.ts @@ -0,0 +1,17 @@ +import { analyzeCapturedWebScript } from "../domain/webBundleAnalyzer.js"; +import { + serveWebBundleAnalysisWorker, + type WebBundleScriptAnalyzer, +} from "../browser/WebBundleAnalysisWorkerServer.js"; + +const analyze: WebBundleScriptAnalyzer = (script) => + analyzeCapturedWebScript(script); + +try { + await serveWebBundleAnalysisWorker(analyze); +} catch (cause: unknown) { + process.stderr.write( + `${cause instanceof Error ? cause.message : String(cause)}\n`, + ); + process.exitCode = 1; +} diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 85312d7773..8105a574ee 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -231,6 +231,7 @@ import type { RecordedCrashPartialObservation, } from "./native/recordedCrash.js"; import type { WebPageInspection } from "./browserObservationSchemas.js"; +import type { WebBundleAnalysis } from "./webBundleAnalysis.js"; import type { WebExecution } from "./webExecution.js"; import type { WebEventListeners } from "./webEventListeners.js"; @@ -252,6 +253,7 @@ export type AnalysisPartialObservation = | ElectronActivePartialObservation | BrowserScenarioPartialObservation | WebPageInspection + | WebBundleAnalysis | WebExecution | WebEventListeners | JavaScriptRuntimeObservation diff --git a/src/domain/webBundleAnalysis.ts b/src/domain/webBundleAnalysis.ts index b4e4c11f8f..4eef3cf949 100644 --- a/src/domain/webBundleAnalysis.ts +++ b/src/domain/webBundleAnalysis.ts @@ -8,6 +8,33 @@ import { webTextArtifactSchema } from "./webContentArtifact.js"; export const analyzeWebBundleInputSchema = inspectWebPageWithSourceInputSchema.safeExtend({ fetch_source_maps: z.boolean().default(false), + max_heap_mb: z + .number() + .int() + .min(128) + .max(16_384) + .optional() + .describe( + "Maximum V8 old-space heap in MiB for the isolated inert-script analyzer; defaults to 1024. This is not a total process RSS limit. Bun ignores V8 flags; Windows Bun uses an owned process commit limit plus a 512 MiB runtime reserve, while other Bun hosts have no enforced memory limit.", + ), + analysis_timeout_ms: z + .number() + .int() + .min(1) + .max(2_147_483_647) + .optional() + .describe( + "Maximum startup, analysis and result transfer time per captured script, in milliseconds; defaults to 30000. Timed-out scripts are explicit unknowns and other scripts may still be analyzed.", + ), + total_analysis_timeout_ms: z + .number() + .int() + .min(1) + .max(2_147_483_647) + .optional() + .describe( + "Maximum time for all captured-script analysis, in milliseconds; defaults to 300000. Capture and optional source-map fetching have separate budgets. Unstarted scripts remain explicit unknowns.", + ), }); export type AnalyzeWebBundleInput = z.infer; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 291978c507..c1ad169ff7 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -2,8 +2,14 @@ import { describe, expect, it } from "vitest"; import { webPageInspectionSchema } from "./browserObservationSchemas.js"; import { analyzeJavaScriptStaticSource } from "./javascript/javascriptStaticAnalysis.js"; -import { analyzeCapturedWebBundle } from "./webBundleAnalyzer.js"; +import { + analyzeCapturedWebBundle, + analyzeCapturedWebScript, +} from "./webBundleAnalyzer.js"; +import { buildCapturedWebBundleAnalysis } from "./webBundleAnalyzerResult.js"; import { webBundleAnalysisSchema } from "./webBundleAnalysis.js"; +import { isIncludedScript } from "./webBundleAnalyzerInspection.js"; +import { webBundleScriptAnalysisSchema } from "./webBundleScriptAnalysis.js"; import { createWebTextArtifact } from "./webContentArtifact.js"; const origin = "https://app.example.test"; @@ -1019,6 +1025,364 @@ const inspection = (source: string) => limitations: [], }); +describe("per-script web bundle analysis", () => { + it("keeps parser failures as analyzed outcomes with their own coverage", () => { + const captured = inspection("function broken( {"); + const script = captured.scripts.items.find(isIncludedScript); + if (script === undefined) + throw new Error("Missing included script fixture"); + + expect(analyzeCapturedWebScript(script)).toMatchObject({ + status: "analyzed", + script_key: script.script_key, + parse_status: "failed", + visited_ast_nodes: 0, + observations: { + edges: [], + routes: [], + endpoints: [], + webmcp_declarations: [], + }, + }); + }); + + it("merges out-of-order worker outcomes in capture order and preserves exact unavailable keys", () => { + const firstCapture = inspection("import './first.js'; fetch('/first');"); + const secondCapture = inspection("import './second.js'; fetch('/second');"); + const first = firstCapture.scripts.items.find(isIncludedScript); + const second = secondCapture.scripts.items.find(isIncludedScript); + if (first === undefined || second === undefined) + throw new Error("Missing included script fixtures"); + const captured = firstCapture; + captured.scripts.items.push({ ...second, script_key: "second-script" }); + captured.scripts.total += 1; + + const result = buildCapturedWebBundleAnalysis( + captured, + { + status: "not_requested", + requested: 0, + processed: 0, + items: [], + }, + [ + { + status: "unavailable", + script_key: "second-script", + reason: "deadline", + detail: "Worker deadline expired.", + }, + analyzeCapturedWebScript(first), + ], + ); + + expect( + result.observations.chunks.edges.map(({ specifier }) => specifier), + ).toEqual(["./first.js"]); + expect(result.observations.endpoints.map(({ value }) => value)).toEqual([ + "/first", + ]); + expect(result.unknowns).toContainEqual({ + dimension: "javascript_analysis", + reason: expect.stringContaining("deadline"), + affected_script_keys: ["second-script"], + }); + expect(result.completeness).toMatchObject({ + status: "partial", + parsed_scripts: 1, + parse_failures: 0, + }); + }); + + it("does not count explicitly unstarted scripts as submitted and reports source budget omissions", () => { + const captured = inspection("fetch('/started');"); + const script = captured.scripts.items.find(isIncludedScript); + if (script === undefined) + throw new Error("Missing included script fixture"); + const later = { ...script, script_key: "not-started-script" }; + captured.scripts.items.push(later); + captured.scripts.total += 1; + captured.completeness.excluded.push({ + section: "script_sources", + reason: "resource_budget_exhausted", + count: 2, + }); + + const result = buildCapturedWebBundleAnalysis( + captured, + { + status: "not_requested", + requested: 0, + processed: 0, + items: [], + }, + [ + analyzeCapturedWebScript(script), + { + status: "unavailable", + script_key: "not-started-script", + reason: "not_started", + }, + ], + ); + + expect(result.capture.scripts_analyzed).toBe(1); + expect(result.completeness.status).toBe("partial"); + expect(result.unknowns).toContainEqual({ + dimension: "script_source", + reason: expect.stringContaining("omitted 2 script source artifacts"), + affected_script_keys: [], + }); + expect(result.unknowns).toContainEqual({ + dimension: "javascript_analysis", + reason: expect.stringContaining("not_started"), + affected_script_keys: ["not-started-script"], + }); + }); +}); + +describe("unavailable web bundle analysis outcomes", () => { + it("preserves distinct unavailable reasons and details with their exact script keys", () => { + const captured = inspection("fetch('/started');"); + const first = captured.scripts.items.find(isIncludedScript); + if (first === undefined) throw new Error("Missing included script fixture"); + const keys = ["deadline-second", "deadline-different", "resource-failure"]; + for (const script_key of keys) + captured.scripts.items.push({ ...first, script_key }); + captured.scripts.total += keys.length; + + const result = buildCapturedWebBundleAnalysis( + captured, + { + status: "not_requested", + requested: 0, + processed: 0, + items: [], + }, + [ + { + status: "unavailable", + script_key: first.script_key, + reason: "not_started", + detail: "Total analysis deadline expired after 50 ms.", + }, + { + status: "unavailable", + script_key: "deadline-second", + reason: "not_started", + detail: "Total analysis deadline expired after 50 ms.", + }, + { + status: "unavailable", + script_key: "deadline-different", + reason: "deadline", + detail: "Per-script deadline expired after 10 ms.", + }, + { + status: "unavailable", + script_key: "resource-failure", + reason: "resource_constraint", + detail: "Worker result exceeded its byte budget.", + }, + ], + ); + + expect( + result.unknowns.filter( + ({ dimension }) => dimension === "javascript_analysis", + ), + ).toEqual([ + { + dimension: "javascript_analysis", + reason: + 'Analysis was unavailable for captured scripts (not_started); findings from those scripts are unknown. Worker detail: "Total analysis deadline expired after 50 ms."', + affected_script_keys: [first.script_key, "deadline-second"], + }, + { + dimension: "javascript_analysis", + reason: + 'Analysis was unavailable for captured scripts (deadline); findings from those scripts are unknown. Worker detail: "Per-script deadline expired after 10 ms."', + affected_script_keys: ["deadline-different"], + }, + { + dimension: "javascript_analysis", + reason: + 'Analysis was unavailable for captured scripts (resource_constraint); findings from those scripts are unknown. Worker detail: "Worker result exceeded its byte budget."', + affected_script_keys: ["resource-failure"], + }, + ]); + }); +}); + +it("requires every result reference to belong to its script key", () => { + const captured = inspection( + "import './module.js'; const routes = [{ path: '/known' }]; fetch('/api'); form.get('candidate'); document.modelContext.registerTool({ name: 'lookup' });", + ); + const script = captured.scripts.items.find(isIncludedScript); + if (script === undefined) throw new Error("Missing included script fixture"); + const outcome = analyzeCapturedWebScript(script); + if (outcome.status !== "analyzed") + throw new Error("Expected a completed script analysis fixture"); + const result = outcome; + expect(webBundleScriptAnalysisSchema.parse(result)).toEqual(result); + expect(result.observations.edges.length).toBeGreaterThan(0); + expect(result.observations.routes.length).toBeGreaterThan(0); + expect(result.observations.endpoints.length).toBeGreaterThan(0); + expect(result.observations.webmcp_declarations.length).toBeGreaterThan(0); + expect(result.inferences.length).toBeGreaterThan(0); + + const wrongEdgeSource = { + ...result, + observations: { + ...result.observations, + edges: result.observations.edges.map((edge, index) => + index === 0 ? { ...edge, from_script_key: "another-script" } : edge, + ), + }, + }; + const wrongEdgeLocation = { + ...result, + observations: { + ...result.observations, + edges: result.observations.edges.map((edge, index) => + index === 0 + ? { + ...edge, + location: { ...edge.location, script_key: "another-script" }, + } + : edge, + ), + }, + }; + const wrongFindingLocation = { + ...result, + observations: { + ...result.observations, + endpoints: result.observations.endpoints.map((finding, index) => + index === 0 + ? { + ...finding, + location: { + ...finding.location, + script_key: "another-script", + }, + } + : finding, + ), + }, + }; + const wrongRouteLocation = { + ...result, + observations: { + ...result.observations, + routes: result.observations.routes.map((finding, index) => + index === 0 + ? { + ...finding, + location: { + ...finding.location, + script_key: "another-script", + }, + } + : finding, + ), + }, + }; + const wrongWebMcpLocation = { + ...result, + observations: { + ...result.observations, + webmcp_declarations: result.observations.webmcp_declarations.map( + (declaration, index) => + index === 0 + ? { + ...declaration, + location: { + ...declaration.location, + script_key: "another-script", + }, + } + : declaration, + ), + }, + }; + const wrongInferenceBasis = { + ...result, + inferences: result.inferences.map((inference, inferenceIndex) => + inferenceIndex === 0 + ? { + ...inference, + basis: inference.basis.map((basis, basisIndex) => + basisIndex === 0 + ? { ...basis, script_key: "another-script" } + : basis, + ), + } + : inference, + ), + }; + + expect(webBundleScriptAnalysisSchema.safeParse(wrongEdgeSource).success).toBe( + false, + ); + expect( + webBundleScriptAnalysisSchema.safeParse(wrongEdgeLocation).success, + ).toBe(false); + expect( + webBundleScriptAnalysisSchema.safeParse(wrongFindingLocation).success, + ).toBe(false); + expect( + webBundleScriptAnalysisSchema.safeParse(wrongRouteLocation).success, + ).toBe(false); + expect( + webBundleScriptAnalysisSchema.safeParse(wrongWebMcpLocation).success, + ).toBe(false); + expect( + webBundleScriptAnalysisSchema.safeParse(wrongInferenceBasis).success, + ).toBe(false); +}); + +it("rejects a failed parse that claims visited nodes or derived facts", () => { + const malformed = + inspection("function broken( {").scripts.items.find(isIncludedScript); + const valid = + inspection("fetch('/api');").scripts.items.find(isIncludedScript); + if (malformed === undefined || valid === undefined) + throw new Error("Missing parse result fixtures"); + const failedOutcome = analyzeCapturedWebScript(malformed); + const parsedOutcome = analyzeCapturedWebScript(valid); + if ( + failedOutcome.status !== "analyzed" || + parsedOutcome.status !== "analyzed" + ) + throw new Error("Expected completed script analysis fixtures"); + const failed = failedOutcome; + const parsed = parsedOutcome; + + expect(webBundleScriptAnalysisSchema.parse(failed)).toEqual(failed); + expect( + webBundleScriptAnalysisSchema.safeParse({ + ...failed, + visited_ast_nodes: 1, + }).success, + ).toBe(false); + expect( + webBundleScriptAnalysisSchema.safeParse({ + ...failed, + widened_return_references: true, + }).success, + ).toBe(false); + expect( + webBundleScriptAnalysisSchema.safeParse({ + ...failed, + observations: { + ...failed.observations, + endpoints: parsed.observations.endpoints, + }, + }).success, + ).toBe(false); +}); + it("recognizes expression-free template literals as route properties", () => { const result = analyzeCapturedWebBundle( inspection(` diff --git a/src/domain/webBundleAnalyzer.ts b/src/domain/webBundleAnalyzer.ts index ddc4dc80d1..2fa6cc4f20 100644 --- a/src/domain/webBundleAnalyzer.ts +++ b/src/domain/webBundleAnalyzer.ts @@ -3,9 +3,38 @@ import { analyzeScript, emptyAccumulator, isIncludedScript, + type IncludedScript, } from "./webBundleAnalyzerInspection.js"; -import { buildWebBundleAnalysis } from "./webBundleAnalyzerResult.js"; +import { buildCapturedWebBundleAnalysis } from "./webBundleAnalyzerResult.js"; import type { WebBundleAnalysis } from "./webBundleAnalysis.js"; +import { + webBundleScriptAnalysisSchema, + type WebBundleScriptAnalysis, +} from "./webBundleScriptAnalysis.js"; + +/** Analyze one selected captured script without executing its source. */ +export const analyzeCapturedWebScript = ( + script: IncludedScript, +): WebBundleScriptAnalysis => { + const accumulator = emptyAccumulator(); + analyzeScript(script, accumulator); + const failed = accumulator.failedScripts.length > 0; + const recovered = accumulator.recoveredScripts.length > 0; + return webBundleScriptAnalysisSchema.parse({ + status: "analyzed", + script_key: script.script_key, + parse_status: failed ? "failed" : recovered ? "recovered" : "parsed", + visited_ast_nodes: accumulator.visitedNodes, + widened_return_references: accumulator.widenedScripts.length > 0, + observations: { + edges: accumulator.edges, + routes: accumulator.routes, + endpoints: accumulator.endpoints, + webmcp_declarations: accumulator.webMcp, + }, + inferences: accumulator.inferences, + }); +}; /** Analyze selected captured JavaScript source without execution. */ export const analyzeCapturedWebBundle = ( @@ -17,13 +46,10 @@ export const analyzeCapturedWebBundle = ( items: [], }, ): WebBundleAnalysis => { - const accumulator = emptyAccumulator(); const sourceScripts = inspection.scripts.items.filter(isIncludedScript); - for (const script of sourceScripts) analyzeScript(script, accumulator); - return buildWebBundleAnalysis( + return buildCapturedWebBundleAnalysis( inspection, - sourceScripts, sourceMaps, - accumulator, + sourceScripts.map(analyzeCapturedWebScript), ); }; diff --git a/src/domain/webBundleAnalyzerResult.ts b/src/domain/webBundleAnalyzerResult.ts index 903f5d6864..94bf162ee9 100644 --- a/src/domain/webBundleAnalyzerResult.ts +++ b/src/domain/webBundleAnalyzerResult.ts @@ -8,17 +8,82 @@ import type { AnalysisAccumulator, IncludedScript, } from "./webBundleAnalyzerInspection.js"; +import { + webBundleScriptAnalysisSchema, + type WebBundleScriptAnalysis, +} from "./webBundleScriptAnalysis.js"; -export const buildWebBundleAnalysis = ( +export const buildCapturedWebBundleAnalysis = ( inspection: WebPageInspection, - sourceScripts: IncludedScript[], sourceMaps: WebBundleAnalysis["observations"]["source_maps"], - accumulator: AnalysisAccumulator, + rawResults: readonly WebBundleScriptAnalysis[], ): WebBundleAnalysis => { + const results = rawResults.map((result) => + webBundleScriptAnalysisSchema.parse(result), + ); + const byKey = new Map(); + for (const result of results) { + if (byKey.has(result.script_key)) + throw new TypeError( + `Duplicate script analysis result: ${result.script_key}`, + ); + byKey.set(result.script_key, result); + } + const sourceScripts = inspection.scripts.items.filter( + (script): script is IncludedScript => script.source.included, + ); + const expectedKeys = new Set( + sourceScripts.map(({ script_key }) => script_key), + ); + for (const key of byKey.keys()) + if (!expectedKeys.has(key)) + throw new TypeError(`Unexpected script analysis result: ${key}`); + const orderedResults: WebBundleScriptAnalysis[] = sourceScripts.map( + (script) => + byKey.get(script.script_key) ?? { + status: "unavailable", + script_key: script.script_key, + reason: "worker_failure", + detail: "No analysis result was returned for this captured script.", + }, + ); + const unavailableAnalyses = orderedResults.filter( + ( + result, + ): result is Extract< + WebBundleScriptAnalysis, + { readonly status: "unavailable" } + > => result.status === "unavailable", + ); + const accumulator = mergeScriptResults( + orderedResults.filter( + ( + result, + ): result is Extract< + WebBundleScriptAnalysis, + { readonly status: "analyzed" } + > => result.status === "analyzed", + ), + ); + const attemptedScripts = orderedResults.filter( + (result) => + !(result.status === "unavailable" && result.reason === "not_started"), + ).length; const unavailable = inspection.scripts.items .filter((script) => !script.source.included) .map(({ script_key }) => script_key); + const unavailableSourceReasons = [ + ...new Set( + inspection.scripts.items.flatMap((script) => + script.source.included ? [] : [script.source.reason], + ), + ), + ].sort(); const omittedScripts = captureBudgetOmission(inspection, "scripts"); + const omittedSourceScripts = captureBudgetOmission( + inspection, + "script_sources", + ); const omittedSourceMaps = sourceMaps.status === "not_requested" ? undefined @@ -30,11 +95,17 @@ export const buildWebBundleAnalysis = ( accumulator.recoveredScripts.length > 0 || accumulator.widenedScripts.length > 0 || unavailable.length > 0 || + unavailableAnalyses.length > 0 || sourceMapIncomplete || omittedScripts !== undefined || + omittedSourceScripts !== undefined || omittedSourceMaps !== undefined; return webBundleAnalysisSchema.parse({ - capture: buildCaptureObservation(inspection, sourceScripts), + capture: buildCaptureObservation( + inspection, + sourceScripts, + attemptedScripts, + ), observations: { chunks: { nodes: sourceScripts.map(chunkNode), @@ -48,11 +119,14 @@ export const buildWebBundleAnalysis = ( inferences: accumulator.inferences, unknowns: buildUnknowns({ unavailable, + unavailableSourceReasons, failedScripts: accumulator.failedScripts, recoveredScripts: accumulator.recoveredScripts, widenedScripts: accumulator.widenedScripts, + unavailableAnalyses, sourceMaps, omittedScripts, + omittedSourceScripts, omittedSourceMaps, }), completeness: buildCompleteness({ @@ -65,13 +139,56 @@ export const buildWebBundleAnalysis = ( }); }; +const mergeScriptResults = ( + results: readonly Extract< + WebBundleScriptAnalysis, + { readonly status: "analyzed" } + >[], +): AnalysisAccumulator => { + const accumulator: AnalysisAccumulator = { + edges: [], + routes: [], + endpoints: [], + webMcp: [], + inferences: [], + seen: new Set(), + visitedNodes: 0, + parsedScripts: 0, + failedScripts: [], + recoveredScripts: [], + widenedScripts: [], + }; + for (const result of results) { + accumulator.visitedNodes += result.visited_ast_nodes; + if (result.parse_status === "failed") { + accumulator.failedScripts.push(result.script_key); + continue; + } + accumulator.parsedScripts += 1; + if (result.parse_status === "recovered") + accumulator.recoveredScripts.push(result.script_key); + if (result.widened_return_references) + accumulator.widenedScripts.push(result.script_key); + accumulator.edges.push(...result.observations.edges); + accumulator.routes.push(...result.observations.routes); + accumulator.endpoints.push(...result.observations.endpoints); + accumulator.webMcp.push(...result.observations.webmcp_declarations); + accumulator.inferences.push(...result.inferences); + } + return accumulator; +}; + const buildCaptureObservation = ( inspection: WebPageInspection, sourceScripts: IncludedScript[], + attemptedScripts: number, ) => ({ target_url: inspection.target.url, scripts_observed: inspection.scripts.total, - scripts_analyzed: sourceScripts.length, + // This preserves the historical meaning: source scripts submitted for + // analysis, including worker/deadline failures; explicitly unstarted suffixes + // are excluded. Successfully parsed scripts are counted separately below. + scripts_analyzed: attemptedScripts, source_artifacts: sourceScripts.map(sourceArtifact), }); @@ -93,11 +210,17 @@ const chunkNode = (script: IncludedScript) => { interface UnknownsInput { readonly unavailable: string[]; + readonly unavailableSourceReasons: readonly string[]; readonly failedScripts: readonly string[]; readonly recoveredScripts: readonly string[]; readonly widenedScripts: readonly string[]; + readonly unavailableAnalyses: readonly Extract< + WebBundleScriptAnalysis, + { readonly status: "unavailable" } + >[]; readonly sourceMaps: WebBundleAnalysis["observations"]["source_maps"]; readonly omittedScripts: number | null | undefined; + readonly omittedSourceScripts: number | null | undefined; readonly omittedSourceMaps: number | null | undefined; } @@ -111,12 +234,27 @@ const buildUnknowns = (input: UnknownsInput): WebBundleAnalysis["unknowns"] => [ affected_script_keys: [], }, ]), + ...(input.omittedSourceScripts === undefined + ? [] + : [ + { + dimension: "script_source", + reason: captureBudgetReason( + "script source artifacts", + input.omittedSourceScripts, + ), + affected_script_keys: [], + }, + ]), ...(input.unavailable.length === 0 ? [] : [ { dimension: "script_source" as const, - reason: "Source artifact was not captured", + reason: + input.unavailableSourceReasons.length === 0 + ? "Source artifact was not captured" + : `Source artifacts were not captured: ${input.unavailableSourceReasons.join("; ")}`, affected_script_keys: input.unavailable, }, ]), @@ -129,6 +267,15 @@ const buildUnknowns = (input: UnknownsInput): WebBundleAnalysis["unknowns"] => [ affected_script_keys: [...input.failedScripts], }, ]), + ...(input.unavailableAnalyses.length === 0 + ? [] + : groupUnavailableAnalyses(input.unavailableAnalyses).map( + ({ reason, detail, affectedScriptKeys }) => ({ + dimension: "javascript_analysis" as const, + reason: analysisUnavailableReason(reason, detail), + affected_script_keys: affectedScriptKeys, + }), + )), ...(input.widenedScripts.length === 0 ? [] : [ @@ -179,7 +326,7 @@ const buildUnknowns = (input: UnknownsInput): WebBundleAnalysis["unknowns"] => [ const captureBudgetOmission = ( inspection: WebPageInspection, - section: "scripts" | "source_maps", + section: "scripts" | "script_sources" | "source_maps", ): number | null | undefined => { const exclusions = inspection.completeness.excluded.filter( (exclusion) => @@ -191,6 +338,42 @@ const captureBudgetOmission = ( return exclusions.reduce((sum, { count }) => sum + (count ?? 0), 0); }; +const groupUnavailableAnalyses = ( + unavailable: UnknownsInput["unavailableAnalyses"], +): { + readonly reason: (typeof unavailable)[number]["reason"]; + readonly detail: string | undefined; + readonly affectedScriptKeys: string[]; +}[] => { + const groups = new Map< + (typeof unavailable)[number]["reason"], + Map + >(); + for (const { reason, detail, script_key } of unavailable) { + let byDetail = groups.get(reason); + if (byDetail === undefined) { + byDetail = new Map(); + groups.set(reason, byDetail); + } + const keys = byDetail.get(detail) ?? []; + keys.push(script_key); + byDetail.set(detail, keys); + } + return [...groups].flatMap(([reason, byDetail]) => + [...byDetail].map(([detail, affectedScriptKeys]) => ({ + reason, + detail, + affectedScriptKeys, + })), + ); +}; + +const analysisUnavailableReason = ( + reason: UnknownsInput["unavailableAnalyses"][number]["reason"], + detail: string | undefined, +): string => + `Analysis was unavailable for captured scripts (${reason}); findings from those scripts are unknown.${detail === undefined ? "" : ` Worker detail: ${JSON.stringify(detail)}`}`; + const captureBudgetReason = (label: string, count: number | null): string => count === null ? `Capture omitted an unknown number of ${label} because its resource budget was exhausted.` diff --git a/src/domain/webBundleScriptAnalysis.ts b/src/domain/webBundleScriptAnalysis.ts new file mode 100644 index 0000000000..bed846dd92 --- /dev/null +++ b/src/domain/webBundleScriptAnalysis.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; + +import { webBundleAnalysisSchema } from "./webBundleAnalysis.js"; + +const observationsSchema = z.strictObject({ + edges: webBundleAnalysisSchema.shape.observations.shape.chunks.shape.edges, + routes: webBundleAnalysisSchema.shape.observations.shape.routes, + endpoints: webBundleAnalysisSchema.shape.observations.shape.endpoints, + webmcp_declarations: + webBundleAnalysisSchema.shape.observations.shape.webmcp_declarations, +}); + +/** Serializable result for one captured script, suitable for worker transport. */ +export const webBundleScriptAnalysisSchema = z + .discriminatedUnion("status", [ + z.strictObject({ + status: z.literal("analyzed"), + script_key: z.string(), + parse_status: z.enum(["parsed", "recovered", "failed"]), + visited_ast_nodes: z.number().int().min(0), + widened_return_references: z.boolean(), + observations: observationsSchema, + inferences: webBundleAnalysisSchema.shape.inferences, + }), + z.strictObject({ + status: z.literal("unavailable"), + script_key: z.string(), + reason: z.enum([ + "worker_failure", + "deadline", + "resource_constraint", + "cancelled", + "not_started", + ]), + detail: z.string().optional(), + }), + ]) + .superRefine((result, context) => { + if (result.status !== "analyzed") return; + + const checkScriptKey = ( + scriptKey: string, + path: (string | number)[], + ): void => { + if (scriptKey !== result.script_key) + context.addIssue({ + code: "custom", + path, + message: "Per-script analysis evidence must name its owning script", + }); + }; + + result.observations.edges.forEach((edge, index) => { + checkScriptKey(edge.from_script_key, [ + "observations", + "edges", + index, + "from_script_key", + ]); + checkScriptKey(edge.location.script_key, [ + "observations", + "edges", + index, + "location", + "script_key", + ]); + }); + for (const collection of ["routes", "endpoints"] as const) + result.observations[collection].forEach((finding, index) => + checkScriptKey(finding.location.script_key, [ + "observations", + collection, + index, + "location", + "script_key", + ]), + ); + result.observations.webmcp_declarations.forEach((declaration, index) => + checkScriptKey(declaration.location.script_key, [ + "observations", + "webmcp_declarations", + index, + "location", + "script_key", + ]), + ); + result.inferences.forEach((inference, inferenceIndex) => + inference.basis.forEach((basis, basisIndex) => + checkScriptKey(basis.script_key, [ + "inferences", + inferenceIndex, + "basis", + basisIndex, + "script_key", + ]), + ), + ); + + if (result.parse_status === "failed") { + const hasFindings = + result.observations.edges.length > 0 || + result.observations.routes.length > 0 || + result.observations.endpoints.length > 0 || + result.observations.webmcp_declarations.length > 0 || + result.inferences.length > 0; + if ( + result.visited_ast_nodes !== 0 || + result.widened_return_references || + hasFindings + ) + context.addIssue({ + code: "custom", + path: ["parse_status"], + message: + "A failed script parse cannot carry visited nodes or derived findings", + }); + } + }); + +export type WebBundleScriptAnalysis = z.infer< + typeof webBundleScriptAnalysisSchema +>; diff --git a/src/process/OwnedWorkerRuntime.test.ts b/src/process/OwnedWorkerRuntime.test.ts index 4f8eeb311f..6b9f67a223 100644 --- a/src/process/OwnedWorkerRuntime.test.ts +++ b/src/process/OwnedWorkerRuntime.test.ts @@ -243,3 +243,101 @@ it("binds a late cleanup projection to its released root epoch after runtime reu await runtime.close(); } }); + +it("routes shared cleanup retry through the registered higher-level owner", async () => { + let failCleanup = true; + let allowOwnerClose = false; + let delegateCalls = 0; + let runtime: OwnedWorkerRuntime; + runtime = new OwnedWorkerRuntime( + runtimeOptions({ + launcher: async (options) => { + const spawned = await spawnOwnedProviderProcess(options); + return { + ...spawned, + cleanup: async () => { + if (failCleanup) + return { cleaned: false, reason: "controlled delegated cleanup" }; + return spawned.cleanup === undefined + ? cleanupOwnedProcessGroup(spawned.ownership) + : spawned.cleanup(); + }, + }; + }, + retryCleanup: async () => { + delegateCalls += 1; + if (!allowOwnerClose) + throw new Error("higher-level owner still retains a descriptor"); + await runtime.close(); + }, + }), + ); + await runtime.start(startOptions()); + const rootPath = runtime.rootPath; + try { + await expect(runtime.stop()).resolves.toMatchObject({ + status: "incomplete", + }); + await expect(OwnedWorkerRuntime.retryPending()).rejects.toThrow( + "higher-level owner still retains a descriptor", + ); + expect(delegateCalls).toBe(1); + // A retained supervisor is not permission to bypass its dependent owner. + await expect(runtime.start(startOptions())).rejects.toThrow( + "higher-level owner still retains a descriptor", + ); + expect(delegateCalls).toBe(2); + expect(runtime.rootPath).toBe(rootPath); + await access(rootPath as string); + + allowOwnerClose = true; + failCleanup = false; + await OwnedWorkerRuntime.retryPending(); + + expect(delegateCalls).toBe(3); + expect(runtime.rootPath).toBeUndefined(); + await expect(access(rootPath as string)).rejects.toThrow(); + } finally { + allowOwnerClose = true; + failCleanup = false; + await runtime.close(); + } +}); + +it("runs deferred delegated cleanup before registering a pending start acquisition", async () => { + let runtime: OwnedWorkerRuntime; + let delegatedClose: Promise | undefined; + let delegateTimedOut = false; + runtime = new OwnedWorkerRuntime( + runtimeOptions({ + retryCleanup: async () => { + await new Promise((resolve) => setImmediate(resolve)); + delegatedClose = runtime.close(); + let timer: NodeJS.Timeout | undefined; + const outcome = await Promise.race([ + delegatedClose.then(() => "closed" as const), + new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), 250); + }), + ]); + if (timer !== undefined) clearTimeout(timer); + delegateTimedOut = outcome === "timeout"; + }, + }), + ); + try { + await runtime.start(startOptions()); + const stopped = await runtime.stop(); + expect(stopped.status).not.toBe("incomplete"); + runtime.retainForCleanup(); + + await expect(runtime.start(startOptions())).rejects.toThrow( + "Owned worker runtime is closing", + ); + expect(delegateTimedOut).toBe(false); + await delegatedClose; + expect(runtime.rootPath).toBeUndefined(); + } finally { + await runtime.close(); + } +}); diff --git a/src/process/OwnedWorkerRuntime.ts b/src/process/OwnedWorkerRuntime.ts index 150ecc01ee..cda243d405 100644 --- a/src/process/OwnedWorkerRuntime.ts +++ b/src/process/OwnedWorkerRuntime.ts @@ -14,6 +14,8 @@ export interface OwnedWorkerRuntimeOptions { readonly rootPrefix: string; readonly launcher?: typeof spawnOwnedProviderProcess; readonly supervisorOptions?: ProviderProcessSupervisorOptions; + /** Let a higher-level owner release its dependent resources before this root. */ + readonly retryCleanup?: () => Promise; } export class OwnedWorkerRuntimeCleanupError extends Error { @@ -95,6 +97,7 @@ export class OwnedWorkerRuntime { #supervisor: ProviderProcessSupervisor | undefined; #processId: number | null = null; #acquisition: Promise | undefined; + #cleanupRetryAttempt: Promise | undefined; #stopAttempt: Promise | undefined; #closeAttempt: Promise | undefined; #closing = false; @@ -171,7 +174,20 @@ export class OwnedWorkerRuntime { if (this.#terminalCloseRequested || this.#closing) return Promise.reject(new Error("Owned worker runtime is closing")); if (this.#acquisition !== undefined) return this.#acquisition; - if (this.#supervisor !== undefined) return Promise.resolve(); + if ( + this.#supervisor !== undefined && + !OwnedWorkerRuntime.#pending.has(this) + ) + return Promise.resolve(); + if ( + this.options.retryCleanup !== undefined && + OwnedWorkerRuntime.#pending.has(this) + ) + return this.#retryOwnedCleanup().then(() => { + if (this.#terminalCloseRequested || this.#closing) + throw new Error("Owned worker runtime is closing"); + return this.start(spawnOptions, initialize); + }); const ownRetry = OwnedWorkerRuntime.#pending.has(this) ? this.#retryPending() : Promise.resolve(); @@ -226,6 +242,8 @@ export class OwnedWorkerRuntime { } async #retryPending(): Promise { + if (this.options.retryCleanup !== undefined) + return this.#retryOwnedCleanup(); if (this.#terminalCloseRequested) return this.close(); this.#closing = true; if (this.#closeAttempt === undefined) { @@ -251,6 +269,27 @@ export class OwnedWorkerRuntime { } } + #retryOwnedCleanup(): Promise { + if (this.#cleanupRetryAttempt !== undefined) + return this.#cleanupRetryAttempt; + const attempt = Promise.resolve() + .then(() => this.options.retryCleanup?.()) + .then(() => { + if (OwnedWorkerRuntime.#pending.has(this)) + throw this.#cleanupError( + "Higher-level cleanup retry returned while ownership remains pending", + ); + }); + this.#cleanupRetryAttempt = attempt; + void attempt + .finally(() => { + if (this.#cleanupRetryAttempt === attempt) + this.#cleanupRetryAttempt = undefined; + }) + .catch(() => undefined); + return attempt; + } + async #start( spawnOptions: Omit, initialize: diff --git a/tests/acceptance/browser/webBundleContainmentCli.test.ts b/tests/acceptance/browser/webBundleContainmentCli.test.ts new file mode 100644 index 0000000000..59d6159072 --- /dev/null +++ b/tests/acceptance/browser/webBundleContainmentCli.test.ts @@ -0,0 +1,98 @@ +import { expect } from "vitest"; +import { z } from "zod"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const capturedSource = ` + fetch("/api/orders"); + navigator.modelContext.registerTool({ + name: "search_orders", + description: "Search orders", + inputSchema: { + type: "object", + properties: { query: { type: "string" } } + } + }); +`; + +cliTest( + "retains an aggregate web-analysis timeout and analyzes the next CLI request", + async ({ cli }) => { + const browser = await startFakeCdpBrowser({ + commandResult: ({ method }) => + method === "Debugger.getScriptSource" + ? { scriptSource: capturedSource } + : undefined, + }); + const baseArguments = [ + "analyze-web-bundle", + browser.endpoint, + "allowed-page", + "--allowed-origins", + browser.allowedOrigin, + "--observation-ms", + "0", + "--json", + ]; + try { + const interrupted = await cli.run({ + arguments: [ + ...baseArguments, + "--max-heap-mb", + "128", + "--analysis-timeout-ms", + "1", + "--total-analysis-timeout-ms", + "1", + ], + timeoutMs: 30_000, + }); + expect( + interrupted.exitCode, + interrupted.stdout + interrupted.stderr, + ).toBe(1); + const timeout = z + .object({ + code: z.literal("provider_timeout"), + details: z.object({ partial_observation: z.unknown() }), + }) + .parse(interrupted.json); + const partial = parseEvidence(timeout.details.partial_observation); + expect(partial.parameters).toMatchObject({ + max_heap_mb: 128, + analysis_timeout_ms: 1, + total_analysis_timeout_ms: 1, + }); + expect(partial.normalized_result).toMatchObject({ + completeness: { status: "partial", parse_failures: 0 }, + unknowns: expect.arrayContaining([ + expect.objectContaining({ affected_script_keys: expect.any(Array) }), + ]), + }); + + const recovered = await cli.run({ + arguments: baseArguments, + timeoutMs: 30_000, + }); + expect(recovered.exitCode, recovered.stdout + recovered.stderr).toBe(0); + expect(recovered.json).toMatchObject({ + normalized_result: { + observations: { + endpoints: [expect.objectContaining({ value: "/api/orders" })], + webmcp_declarations: [ + expect.objectContaining({ + name: "search_orders", + trust: "page-declared-untrusted", + }), + ], + }, + }, + }); + expect(browser.activeConnectionCount()).toBe(0); + } finally { + await browser.close(); + } + }, +); diff --git a/tests/boundary/mcp/webBundleContainmentMcp.test.ts b/tests/boundary/mcp/webBundleContainmentMcp.test.ts new file mode 100644 index 0000000000..c4367ea8fe --- /dev/null +++ b/tests/boundary/mcp/webBundleContainmentMcp.test.ts @@ -0,0 +1,149 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, expect, it } from "vitest"; +import { z } from "zod"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { ok as resultOk } from "../../../src/domain/result.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; + +const capturedSource = ` + fetch("/api/orders"); + navigator.modelContext.registerTool({ + name: "search_orders", + description: "Search orders", + inputSchema: { + type: "object", + properties: { query: { type: "string" } } + } + }); +`; + +const resources: Array<{ close(): Promise }> = []; +const browsers: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all(resources.splice(0).map((resource) => resource.close())); + await Promise.all(browsers.splice(0).map((browser) => browser.close())); +}); + +it("retains timeout Evidence and recovers on the same MCP server", async () => { + const browser = await startFakeCdpBrowser({ + commandResult: ({ method }) => + method === "Debugger.getScriptSource" + ? { scriptSource: capturedSource } + : undefined, + }); + browsers.push(browser); + const session = createTestBinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(resultOk(null)), + })); + const server = createServer( + { kind: "session", session }, + { + browserObservation: new CdpBrowserProvider(), + availabilityPolicy: () => ({ + processCaptureEnabled: false, + investigationInputRoots: 0, + }), + }, + ); + const client = new Client({ + name: "web-bundle-containment-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server, session); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const interrupted = await client.callTool({ + name: "analyze_web_bundle", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + max_heap_mb: 128, + analysis_timeout_ms: 1, + total_analysis_timeout_ms: 1, + }, + }); + const timeout = parseMcpToolError(interrupted).error; + expect(timeout.code).toBe("provider_timeout"); + const timeoutDetails = z + .object({ partial_observation: z.unknown() }) + .passthrough() + .parse(timeout.details); + const partialReference = z + .object({ + kind: z.literal("retained-evidence"), + evidence_id: z.string(), + }) + .parse(timeoutDetails.partial_observation); + + const retainedReply = await client.callTool({ + name: "get_evidence_bundle", + arguments: { + detail: "complete", + }, + }); + expect(retainedReply.isError, JSON.stringify(retainedReply.content)).not.toBe( + true, + ); + const retainedBundle = toolContract("get_evidence_bundle").outputSchema.parse( + retainedReply.structuredContent, + ).result; + if ("kind" in retainedBundle) + throw new Error("Expected complete retained Evidence"); + const retained = parseEvidence( + retainedBundle.records.find( + (record) => record.evidence_id === partialReference.evidence_id, + ), + ); + expect(retained.evidence_id).toBe(partialReference.evidence_id); + expect(retained.parameters).toMatchObject({ + max_heap_mb: 128, + analysis_timeout_ms: 1, + total_analysis_timeout_ms: 1, + }); + expect(retained.normalized_result).toMatchObject({ + completeness: { status: "partial", parse_failures: 0 }, + unknowns: expect.arrayContaining([ + expect.objectContaining({ affected_script_keys: expect.any(Array) }), + ]), + }); + + const recovered = await client.callTool({ + name: "analyze_web_bundle", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }, + }); + expect(recovered.isError, JSON.stringify(recovered.content)).not.toBe(true); + expect(recovered.structuredContent).toMatchObject({ + normalized_result: { + observations: { + endpoints: [expect.objectContaining({ value: "/api/orders" })], + webmcp_declarations: [ + expect.objectContaining({ + name: "search_orders", + trust: "page-declared-untrusted", + }), + ], + }, + }, + }); + await client.ping(); + expect(browser.activeConnectionCount()).toBe(0); +}, 60_000);