From 4a3bc37b763428ebacbc78e64a216ce438def0d6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:37:09 +0800 Subject: [PATCH] fix(browser): preserve physical failures through later cancellation (#10046) * fix(browser): retain physical failures through late cancellation * fix(browser): preserve process inspection cancellation and ownership * fix(process): preserve independently aborted inspection failures --- .../PlaywrightBrowserScenarioProvider.test.ts | 90 ++++- .../PlaywrightBrowserScenarioProvider.ts | 8 +- .../PlaywrightElectronActiveActions.test.ts | 69 +++- .../PlaywrightElectronActiveActions.ts | 35 +- .../PlaywrightElectronActiveProvider.test.ts | 315 +++++++++++++++++- .../PlaywrightElectronActiveProvider.ts | 158 +++++---- src/browser/ProcessInspectionCleanup.ts | 28 ++ src/browser/WebBundleAnalysisWorker.test.ts | 144 +++++++- src/browser/WebBundleAnalysisWorker.ts | 74 +++- .../DarwinCompilationRetirement.test.ts | 19 +- src/process/DarwinProcessRunTokenReader.ts | 11 +- src/process/ProcessInspectionCancellation.ts | 10 + src/process/ProcessOwnershipObservation.ts | 29 +- 13 files changed, 853 insertions(+), 137 deletions(-) create mode 100644 src/browser/ProcessInspectionCleanup.ts create mode 100644 src/process/ProcessInspectionCancellation.ts diff --git a/src/browser/PlaywrightBrowserScenarioProvider.test.ts b/src/browser/PlaywrightBrowserScenarioProvider.test.ts index 7cb0b4506f..f4b6382932 100644 --- a/src/browser/PlaywrightBrowserScenarioProvider.test.ts +++ b/src/browser/PlaywrightBrowserScenarioProvider.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest"; import type { BrowserScenarioSessionPort } from "./BrowserScenarioSessionPort.js"; import { PlaywrightBrowserScenarioProvider } from "./PlaywrightBrowserScenarioProvider.js"; +import { PlaywrightScenarioSession } from "./PlaywrightScenarioSession.js"; import { PlaywrightScenarioEvents } from "./PlaywrightScenarioEvents.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; import { @@ -187,7 +188,10 @@ class FakeSession implements BrowserScenarioSessionPort { async perform(_action: BrowserScenarioAction, signal?: AbortSignal) { this.performCalls += 1; this.onPerform?.(); - if (signal?.aborted === true) throw new Error("request cancelled"); + if (signal?.aborted === true) + throw new AnalysisCancelledError("capture_browser_scenario", { + cause: signal.reason, + }); if (this.failures.action !== undefined) throw this.failures.action; } @@ -377,6 +381,90 @@ it("compares retained network observations despite a failed subresource", async } }); +it.each( + ["physical", "cancelled"].flatMap((outcome) => + [false, true].map((cleanupFails) => ({ outcome, cleanupFails })), + ), +)( + "retains actual action rejection precedence for $outcome with cleanup failure=$cleanupFails", + async ({ outcome, cleanupFails }) => { + const caller = new AbortController(); + const reason = { caller: "after the action boundary settled" }; + const physical = new Error("physical page wait failure"); + const page = { + waitForTimeout: () => { + if (outcome === "physical") return Promise.reject(physical); + caller.abort(reason); + return Promise.resolve(); + }, + }; + const session = new FakeSession("launch"); + let observed: unknown; + session.perform = (action, signal) => { + const operation = Reflect.apply( + PlaywrightScenarioSession.prototype.perform, + { opened: { page }, storage: undefined }, + [action, signal], + ) as Promise; + void operation.catch((cause: unknown) => { + observed = cause; + if (outcome === "physical") caller.abort(reason); + }); + return operation; + }; + let denied = cleanupFails; + session.close = async () => { + session.closeCalls += 1; + if (denied) throw new Error("controlled cleanup failure"); + return "terminated-owned-process"; + }; + const provider = new PlaywrightBrowserScenarioProvider(async () => session); + try { + const result = await provider.captureScenario(scenario({ actions: 2 }), { + signal: caller.signal, + }); + if (result.ok) + throw new Error( + "Expected cancellation with retained action observations", + ); + if (outcome === "physical") expect(observed).toBe(physical); + else expect(observed).toBeInstanceOf(AnalysisCancelledError); + const partial = result.error.partialObservation; + if ( + partial === undefined || + !("kind" in partial) || + partial.kind !== "browser-scenario-observation" + ) + throw new Error("Expected retained browser scenario observations"); + const { capture } = partial; + expect(capture.steps[1]).toMatchObject({ + status: outcome === "physical" ? "failed" : "cancelled", + ...(outcome === "physical" ? { error: physical.message } : {}), + }); + expect(capture.steps[2]).toMatchObject({ + status: "cancelled", + error: + outcome === "physical" + ? "not executed after an earlier action failure" + : "not executed after scenario request cancellation", + }); + expect(session.captureCalls).toBe(1); + expect(session.closeCalls).toBe(1); + denied = false; + await provider.close(); + let primary = result.error; + while (primary.cleanupPrimary !== undefined) + primary = primary.cleanupPrimary; + expect(primary).toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(primary.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + denied = false; + await provider.close(); + } + }, +); + describe("PlaywrightBrowserScenarioProvider", () => { it("reports response-body provenance uncertainty without marking event coverage missing", async () => { const session = new FakeSession("launch"); diff --git a/src/browser/PlaywrightBrowserScenarioProvider.ts b/src/browser/PlaywrightBrowserScenarioProvider.ts index b5d77c5106..e867e1e73b 100644 --- a/src/browser/PlaywrightBrowserScenarioProvider.ts +++ b/src/browser/PlaywrightBrowserScenarioProvider.ts @@ -212,13 +212,17 @@ const executeStep = async (input: { await session.perform(action, signal); } catch (cause: unknown) { outcome = { - status: requestCancelled() ? "cancelled" : "failed", + status: + cause instanceof AnalysisCancelledError || + (cause instanceof AnalysisError && cause.userCategory === "cancelled") + ? "cancelled" + : "failed", error: session.redactError(cause), }; } } const artifacts = - outcome.status === "cancelled" + outcome.status === "cancelled" || requestCancelled() ? unavailableArtifacts(requested, "scenario request cancelled") : await session.capture(requested, signal); return createStep({ diff --git a/src/browser/PlaywrightElectronActiveActions.test.ts b/src/browser/PlaywrightElectronActiveActions.test.ts index c9c4ec8302..e171badb94 100644 --- a/src/browser/PlaywrightElectronActiveActions.test.ts +++ b/src/browser/PlaywrightElectronActiveActions.test.ts @@ -190,6 +190,60 @@ it("preserves local Playwright action diagnostics", async () => { }); }); +it("keeps a completed action failure primary when cancellation arrives before its result is consumed", async () => { + const controller = new AbortController(); + const primary = new Error("selected locator failed"); + const page = { + locator: () => ({ + click: () => { + const rejected = Promise.reject(primary); + void rejected.catch(() => + queueMicrotask(() => controller.abort("late cancellation")), + ); + return rejected; + }, + }), + }; + const result = await runElectronActions( + { windows: () => [page], browserWindow: fixtureBrowserWindow } as never, + electronActiveObservationInputSchema.parse({ + executable_path: "/opt/electron", + application_path: "/opt/app/main.js", + actions: [{ step_id: "failed", kind: "click", selector: "#selected" }], + }), + { signal: controller.signal }, + new ArtifactResourceScope(), + ); + expect(controller.signal.aborted).toBe(true); + expect(result.actions[0]).toMatchObject({ + status: "failed", + error: primary.message, + }); +}); + +it.each(["raw action failure", ""])( + "preserves a non-Error action rejection %j", + async (reason) => { + const page = { + locator: () => ({ click: async () => Promise.reject(reason) }), + }; + const result = await runElectronActions( + { windows: () => [page], browserWindow: fixtureBrowserWindow } as never, + electronActiveObservationInputSchema.parse({ + executable_path: "/opt/electron", + application_path: "/opt/app/main.js", + actions: [{ step_id: "failed", kind: "click", selector: "#selected" }], + }), + {}, + new ArtifactResourceScope(), + ); + expect(result.actions[0]).toMatchObject({ + status: "failed", + error: reason, + }); + }, +); + it("retains complete action errors and Electron window text", async () => { const longError = `locator failed ${"detail ".repeat(300)}`; const input = electronActiveObservationInputSchema.parse({ @@ -623,9 +677,12 @@ it("observes an operation abandoned by an already-cancelled signal", async () => } }); -it.each(["click", "renderer-crash"] as const)( - "keeps the selected Page's native identity and completed %s through handle cleanup failure", - async (kind) => { +it.each([ + { kind: "click", rejection: new Error("handle disposal failed") }, + { kind: "renderer-crash", rejection: "" }, +] as const)( + "keeps the selected Page's native identity and completed $kind through handle cleanup failure", + async ({ kind, rejection }) => { const selectedEffect = vi.fn(); const page = { locator: () => ({ @@ -638,7 +695,7 @@ it.each(["click", "renderer-crash"] as const)( const pages = [other, page]; const dispose = vi .fn() - .mockRejectedValueOnce(new Error("handle disposal failed")) + .mockRejectedValueOnce(rejection) .mockResolvedValue(undefined); const nativeWindow = { id: 99, @@ -681,6 +738,10 @@ it.each(["click", "renderer-crash"] as const)( { target: null, status: "cancelled" }, ]); expect(outcome.cleanupError?.cleanupIncomplete).toBe(true); + expect(outcome.cleanupError?.cause).toBe(rejection); + expect(outcome.cleanupError?.diagnostics?.reason).toBe( + rejection instanceof Error ? rejection.message : rejection, + ); expect(resources.hasPending).toBe(true); await resources.close(); expect(dispose).toHaveBeenCalledTimes(2); diff --git a/src/browser/PlaywrightElectronActiveActions.ts b/src/browser/PlaywrightElectronActiveActions.ts index 3826e64824..854077a924 100644 --- a/src/browser/PlaywrightElectronActiveActions.ts +++ b/src/browser/PlaywrightElectronActiveActions.ts @@ -8,10 +8,11 @@ import type { ArtifactResourceOwner, } from "../artifacts/ArtifactResourceScope.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; +import { describeValidationFailure } from "../domain/validationFailure.js"; import { electronActiveTimelineEventSchema, electronActiveProcessMetricSchema, @@ -148,21 +149,29 @@ export const runElectronActions = async ( options, }); } catch (cause: unknown) { - stoppedBy = options.signal?.aborted ? "cancelled" : "failed"; - actionError = safeActionErrorMessage(cause); + stoppedBy = + cause instanceof AnalysisCancelledError || + (cause instanceof AnalysisError && cause.userCategory === "cancelled") + ? "cancelled" + : "failed"; + actionError = describeValidationFailure(cause); } - const outcome = - actionError === null - ? { status: "completed" as const, error: null } - : { status: stoppedBy ?? "failed", error: actionError }; - actions.push({ + const actionRecord = { step_id: action.step_id, kind: action.kind, window_index: windowIndex, target, - ...outcome, elapsed_ms: Math.max(0, Math.round(performance.now() - actionStartedAt)), - }); + }; + const result: ElectronActions[number] = + actionError === null + ? { ...actionRecord, status: "completed", error: null } + : { + ...actionRecord, + status: stoppedBy ?? "failed", + error: actionError, + }; + actions.push(result); if (handleOwner !== undefined) { cleanupError = await releaseActionWindow( resources, @@ -225,7 +234,7 @@ const releaseActionWindow = async ( "playwright-electron-active", released.lease.cleanupResources ?? [owner.resource], { - reason: safeActionErrorMessage(released.cause), + reason: describeValidationFailure(released.cause), }, { cause: released.cause, operation: OPERATION }, ); @@ -508,7 +517,3 @@ export const readFinalApplicationState = async ( }, }); }; - -const safeActionErrorMessage = (cause: unknown): string => { - return cause instanceof Error ? cause.message : "Electron action failed"; -}; diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index f17f6ec38a..ca45fa60b1 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -1,4 +1,9 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { primaryAnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; +import { ExecFileOutputCancelledError } from "../process/ExecFileOutput.js"; import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { writeFile } from "node:fs/promises"; @@ -28,6 +33,282 @@ afterEach(() => { vi.unstubAllEnvs(); }); +it.each([false, true])( + "retains late cancellation beside a failed capture (cleanup fails: %s)", + async (cleanupFails) => { + stubVerifiedElectronCleanup(); + stubCompletedCapture(); + const controller = new AbortController(); + const reason = { caller: "cancel during final cleanup" }; + const failure = new AnalysisOutputError( + "capture_electron_scenario", + "final snapshot unavailable", + ); + vi.spyOn(electronActions, "readFinalApplicationState").mockRejectedValue( + failure, + ); + let denied = cleanupFails; + const application = { + process: () => ({ pid: 424242 }), + windows: () => [], + close: vi.fn(async () => { + controller.abort(reason); + if (denied) throw new Error("transport cleanup denied"); + }), + }; + const provider = new PlaywrightElectronActiveProvider( + {}, + async () => + application as unknown as Awaited< + ReturnType + >, + ); + try { + const result = await provider.capture( + { + ...electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: process.execPath, + }), + application_root: dirname(process.execPath), + }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected capture failure"); + expect(primaryAnalysisError(result.error)).toBe(failure); + expect( + failure.secondaryFailures.some( + (secondary) => secondary.cause === reason, + ), + ).toBe(true); + expect(result.error.partialObservation).toMatchObject({ + kind: "electron-active-observation", + }); + denied = false; + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(primaryAnalysisError(result.error)).toBe(failure); + } finally { + denied = false; + await provider.close(); + } + }, +); + +it.each(["prepare", "baseline"] as const)( + "classifies cancellation from ownership %s without masking physical failure", + async (stage) => { + for (const kind of ["reason", "native-abort", "physical"] as const) { + const controller = new AbortController(); + const reason = { cancelled: stage }; + const physical = new Error("ownership inspection failed"); + const launch = vi.fn(async () => { + throw new Error("must not launch"); + }); + const fail = async () => { + controller.abort(reason); + if (kind === "native-abort") + throw new ExecFileOutputCancelledError(reason); + throw kind === "reason" ? reason : physical; + }; + const spy = + stage === "prepare" + ? vi + .spyOn(ownershipObservation, "prepareProcessOwnershipInspection") + .mockImplementation(fail) + : vi + .spyOn( + ownershipObservation.systemProcessOwnershipHost, + "captureBaseline", + ) + .mockImplementation(fail); + const provider = new PlaywrightElectronActiveProvider({}, launch); + try { + const result = await provider.capture( + { + ...electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: process.execPath, + }), + application_root: dirname(process.execPath), + }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected inspection failure"); + expect(launch).not.toHaveBeenCalled(); + if (kind === "physical") { + expect(result.error.userCategory).not.toBe("cancelled"); + expect(primaryAnalysisError(result.error).cause).toBe(physical); + expect( + primaryAnalysisError(result.error).secondaryFailures.some( + (failure) => failure.cause === reason, + ), + ).toBe(true); + } else { + expect(result.error.userCategory).toBe("cancelled"); + expect(result.error.cause).toBe(reason); + } + } finally { + spy.mockRestore(); + await provider.close(); + } + } + }, +); + +it("preserves an independent AbortError from actual ownership baseline inspection", async () => { + const controller = new AbortController(); + const reason = { caller: "cancel" }; + const physical = new DOMException( + "independent inspector failure", + "AbortError", + ); + const host = ownershipObservation.createSystemProcessOwnershipHost( + "linux", + {}, + { + execFileOutput: async () => { + controller.abort(reason); + throw physical; + }, + }, + ); + const prepare = vi + .spyOn(ownershipObservation, "prepareProcessOwnershipInspection") + .mockResolvedValue(); + const baseline = vi + .spyOn(ownershipObservation.systemProcessOwnershipHost, "captureBaseline") + .mockImplementation((signal) => host.captureBaseline!(signal)); + const provider = new PlaywrightElectronActiveProvider({}); + try { + const result = await provider.capture( + { + ...electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: process.execPath, + }), + application_root: dirname(process.execPath), + }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected inspection failure"); + const primary = primaryAnalysisError(result.error); + expect(primary.userCategory).not.toBe("cancelled"); + expect(primary.cause).toBe(physical); + expect( + primary.secondaryFailures.some((failure) => failure.cause === reason), + ).toBe(true); + } finally { + baseline.mockRestore(); + prepare.mockRestore(); + await provider.close(); + } +}); + +it("retains failed ownership-helper cleanup until provider close retries it", async () => { + const controller = new AbortController(); + const reason = { caller: "cancel" }; + const cleanup = new ProviderCleanupError( + "process-ownership", + ["helper-root"], + {}, + { operation: "prepare_owned_process" }, + ); + const owner = { + close: vi.fn(async () => { + cleanup.resolveCleanup(); + }), + }; + const prepare = vi + .spyOn(ownershipObservation, "prepareProcessOwnershipInspection") + .mockImplementation(async () => { + controller.abort(reason); + throw new DarwinProcessOwnershipInspectionError("helper cleanup failed", { + cancellation: { reason }, + cleanupFailure: cleanup, + cleanupOwner: owner, + }); + }); + const launch = vi.fn(async () => { + throw new Error("must not launch"); + }); + const provider = new PlaywrightElectronActiveProvider({}, launch); + try { + const result = await provider.capture( + { + ...electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: process.execPath, + }), + application_root: dirname(process.execPath), + }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected failed cleanup"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toContain("helper-root"); + await provider.close(); + expect(owner.close).toHaveBeenCalledOnce(); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + prepare.mockRestore(); + await provider.close(); + } +}); + +it("joins a late launch acknowledgement and its cleanup after shutdown", async () => { + stubVerifiedElectronCleanup(); + stubCompletedCapture(); + type Application = Awaited< + ReturnType + >; + let acknowledge = (_application: Application): void => undefined; + let started = (): void => undefined; + const launching = new Promise((resolve) => { + acknowledge = resolve; + }); + const launchStarted = new Promise((resolve) => { + started = resolve; + }); + const application = { + process: () => ({ pid: 424242 }), + windows: () => [], + close: vi.fn(async () => undefined), + }; + const provider = new PlaywrightElectronActiveProvider({}, async () => { + started(); + return launching; + }); + const controller = new AbortController(); + const reason = { caller: "cancel pending launch" }; + const capture = provider.capture( + { + ...electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: process.execPath, + }), + application_root: dirname(process.execPath), + }, + { signal: controller.signal }, + ); + await launchStarted; + controller.abort(reason); + let closed = false; + const closing = provider.close().then(() => { + closed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(closed).toBe(false); + acknowledge(application as unknown as Application); + const result = await capture; + await closing; + expect(application.close).toHaveBeenCalledOnce(); + expect(closed).toBe(true); + if (result.ok) throw new Error("Expected launch cancellation"); + expect(result.error.userCategory).toBe("cancelled"); + expect(result.error.cause).toBe(reason); +}); + it("passes the selected environment and ownership token to Electron launch", async () => { vi.stubEnv("LANG", "ambient-language"); vi.stubEnv("XAUTHORITY", "/ambient/.Xauthority"); @@ -672,7 +953,7 @@ it("observes an initial state read started after cancellation and rejected by cl } }); -it("keeps a failure observed with cancellation as the cancellation cause", async () => { +it("keeps a producer failure and its output beside concurrent cancellation", async () => { stubVerifiedElectronCleanup(); const state = { windows: [], @@ -682,10 +963,24 @@ it("keeps a failure observed with cancellation as the cancellation cause", async }; vi.spyOn(electronActions, "readApplicationState").mockResolvedValue(state); const controller = new AbortController(); - const failure = new Error("final state read failed"); + const failure = new AnalysisOutputError( + "capture_electron_scenario", + "final state read failed", + { + diagnostics: { producer: "final snapshot" }, + capturedOutput: { + stdout: "retained renderer output", + stderr: "renderer diagnostic", + exit_code: null, + signal: null, + truncated: false, + }, + }, + ); + const reason = { caller: "cancel during final read" }; vi.spyOn(electronActions, "readFinalApplicationState").mockImplementation( async () => { - controller.abort(new Error("caller-reason")); + controller.abort(reason); throw failure; }, ); @@ -697,13 +992,19 @@ it("keeps a failure observed with cancellation as the cancellation cause", async const result = await captureWithController(controller, application, []); - if (result.ok) throw new Error("Expected the capture to be cancelled"); + if (result.ok) throw new Error("Expected the capture to fail"); + expect(result.error).toBe(failure); expect(result.error).toMatchObject({ - _tag: "BrowserObservationError", - reason: "cancelled", + _tag: "AnalysisOutputError", + diagnostics: { producer: "final snapshot" }, + capturedOutput: { stdout: "retained renderer output" }, partialObservation: { kind: "electron-active-observation" }, }); - expect(result.error.cause).toBe(failure); + expect( + result.error.secondaryFailures.some( + (secondary) => secondary.cause === reason, + ), + ).toBe(true); }); it("reports an expired final state grace period as the caller's cancellation", async () => { diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index ed9cf23117..10dfd82a62 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -1,9 +1,12 @@ +import { retainProcessInspectionCleanup } from "./ProcessInspectionCleanup.js"; +import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; import { runWithExecutionLimits } from "./PlaywrightElectronExecution.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { ElectronCaptureCleanup } from "./ElectronCaptureCleanup.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { electronFuseFailure } from "../electron/ElectronFuseFailures.js"; import { createHash, randomUUID } from "node:crypto"; @@ -20,7 +23,10 @@ import type { ElectronActiveObservationPort, } from "../application/javascript/ElectronActiveObservationPort.js"; import type { ElectronActiveObservationResult } from "../domain/javascript/electronActiveObservation.js"; -import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisError, + primaryAnalysisError, +} from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -31,6 +37,7 @@ import type { } from "../process/ProcessOwnership.js"; import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js"; import { + isProcessOwnershipInspectionCancellation, observeOwnedProcessLineage, prepareProcessOwnershipInspection, systemProcessOwnershipHost, @@ -122,7 +129,11 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati try { if (options.signal?.aborted === true) throw new BrowserObservationError(OPERATION, "cancelled"); - await prepareProcessOwnershipInspection(options.signal); + await inspectOwnership( + () => prepareProcessOwnershipInspection(options.signal), + options.signal, + this.#resources, + ); const startupDeadline = performance.now() + STARTUP_TIMEOUT_MS; paths = { executable: input.executable_path, @@ -151,8 +162,11 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati await preflight; throw interruption; } - const captureBaseline = - await systemProcessOwnershipHost.captureBaseline?.(options.signal); + const captureBaseline = await inspectOwnership( + () => systemProcessOwnershipHost.captureBaseline?.(options.signal), + options.signal, + this.#resources, + ); if (options.signal?.aborted) throw new BrowserObservationError(OPERATION, "cancelled"); const applicationArgs = [ @@ -220,11 +234,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati options.signal, ); capture = projectElectronActiveCapture(paths, actions, state); - // Preserve a cancellation already observed before finalization so a - // later cleanup failure keeps its cause. Late cancellation is checked below. - outcome = options.signal?.aborted - ? err(cancelledCapture(capture, options.signal.reason)) - : ok(capture); + outcome = ok(capture); } catch (cause: unknown) { if (paths !== undefined && initialState !== undefined) { const retainedCapture = projectElectronActiveCapture( @@ -240,43 +250,30 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati ], }; } - if (cause instanceof AnalysisError && cause.cleanupIncomplete) { - if (capture !== undefined) - cause.retainPartialObservation({ - kind: "electron-active-observation", - capture, - }); - outcome = err(cause); - } else if (options.signal?.aborted === true) - outcome = err( - new BrowserObservationError(OPERATION, "cancelled", { - ...cancellationCause(cause, options.signal), - ...(capture === undefined - ? {} - : { - partialObservation: { - kind: "electron-active-observation" as const, - capture, - }, - }), - }), - ); - else { - const error = providerError(cause); - if (capture !== undefined) - error.retainPartialObservation({ - kind: "electron-active-observation", - capture, - }); - outcome = err(error); - } + const error = + options.signal?.aborted === true && + cause instanceof AnalysisError && + !cause.cleanupIncomplete && + (cause instanceof AnalysisCancelledError || + cause.userCategory === "cancelled") + ? new BrowserObservationError(OPERATION, "cancelled", { + cause: options.signal.reason, + diagnostics: cause.diagnostics, + capturedOutput: cause.capturedOutput, + }) + : providerError(cause); + if (capture !== undefined) + error.retainPartialObservation({ + kind: "electron-active-observation", + capture, + }); + outcome = err(error); } if (application !== undefined) { const owner = new ElectronCaptureCleanup(application, ownership, lineage); const released = await owner.release(this.#resources); const cleanup = owner.processResult; - if (outcome.ok && options.signal?.aborted === true) - outcome = err(cancelledCapture(outcome.value, options.signal.reason)); + outcome = captureOutcomeWithCancellation(outcome, options.signal); if ( released.processes.kind === "failed" || released.transport.kind === "failed" @@ -347,12 +344,63 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati } // Cleanup failures returned above keep precedence; a completed capture // observed under late cancellation is retained as partial Evidence. - if (outcome.ok && options.signal?.aborted === true) - return err(cancelledCapture(outcome.value, options.signal.reason)); - return outcome; + return captureOutcomeWithCancellation(outcome, options.signal); } } +// The process inspector can reject with the signal reason or a native AbortError. +// Normalize only its own boundary so unrelated physical errors remain primary. +const inspectOwnership = async ( + inspect: () => Promise | undefined, + signal: AbortSignal | undefined, + resources: ArtifactResourceScope, +): Promise => { + try { + return await inspect(); + } catch (cause: unknown) { + if (cause instanceof DarwinProcessOwnershipInspectionError) { + const primary = + cause.cancellation === undefined + ? providerError(cause) + : new BrowserObservationError(OPERATION, "cancelled", { + cause: cause.cancellation.reason, + }); + throw retainProcessInspectionCleanup( + cause, + primary, + resources, + OPERATION, + ); + } + if ( + isProcessOwnershipInspectionCancellation(cause, signal) && + !(cause instanceof AnalysisError && cause.cleanupIncomplete) + ) + throw new BrowserObservationError(OPERATION, "cancelled", { + cause: signal?.reason, + }); + throw cause; + } +}; + +/** Keep existing failures and their cleanup owners when cancellation arrives later. */ +const captureOutcomeWithCancellation = ( + outcome: Result, + signal: AbortSignal | undefined, +): Result => { + if (signal?.aborted !== true) return outcome; + if (outcome.ok) return err(cancelledCapture(outcome.value, signal.reason)); + const primary = primaryAnalysisError(outcome.error); + if (primary.userCategory === "cancelled" && primary.cause === signal.reason) + return outcome; + return err( + analysisErrorWithCancellation( + outcome.error, + new AnalysisCancelledError(OPERATION, { cause: signal.reason }), + ), + ); +}; + const cancelledCapture = ( capture: ElectronActiveObservationResult, reason: unknown, @@ -362,28 +410,6 @@ const cancelledCapture = ( partialObservation: { kind: "electron-active-observation", capture }, }); -/** - * A cancellation wrapper stands in for the caller's abort reason and keeps its - * producer diagnostics; any other failure observed with cancellation stays the - * primary cause. - */ -const cancellationCause = ( - cause: unknown, - signal: AbortSignal, -): { - readonly cause: unknown; - readonly diagnostics?: AnalysisError["diagnostics"]; -} => - cause instanceof AnalysisCancelledError || - (cause instanceof AnalysisError && cause.userCategory === "cancelled") - ? { - cause: signal.reason, - ...(cause.diagnostics === undefined - ? {} - : { diagnostics: cause.diagnostics }), - } - : { cause }; - export const safeElectronEnvironment = ( environment: Readonly>, runId: string, diff --git a/src/browser/ProcessInspectionCleanup.ts b/src/browser/ProcessInspectionCleanup.ts new file mode 100644 index 0000000000..37750179f3 --- /dev/null +++ b/src/browser/ProcessInspectionCleanup.ts @@ -0,0 +1,28 @@ +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; + +/** Adopt the inspector's retained helper and keep its original cleanup status live. */ +export const retainProcessInspectionCleanup = ( + cause: unknown, + primary: AnalysisError, + resources: ArtifactResourceScope, + operation: string, +): AnalysisError => { + if (!(cause instanceof DarwinProcessOwnershipInspectionError)) return primary; + if (cause.cleanupOwner !== undefined) { + const owner = cause.cleanupOwner; + resources.retain({ + kind: "lease", + resource: "process ownership inspector", + lease: { release: () => owner.close() }, + }); + } + if (cause.cleanupFailure === undefined) return primary; + return analysisErrorWithCleanupFailure( + primary, + cause.cleanupFailure, + operation, + ).bindCleanupDependencies([cause.cleanupFailure]); +}; diff --git a/src/browser/WebBundleAnalysisWorker.test.ts b/src/browser/WebBundleAnalysisWorker.test.ts index ee1cf292fa..4b75d5eec2 100644 --- a/src/browser/WebBundleAnalysisWorker.test.ts +++ b/src/browser/WebBundleAnalysisWorker.test.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { access } from "node:fs/promises"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { createWebTextArtifact } from "../domain/webContentArtifact.js"; import type { IncludedScript } from "../domain/webBundleAnalyzerState.js"; @@ -11,10 +11,14 @@ import { AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; import { + primaryAnalysisError, resolvedAnalysisError, type AnalysisError, } from "../domain/analysisErrorBase.js"; import { javaScriptRuntime } from "../domain/runtimeVersion.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; +import { ExecFileOutputCancelledError } from "../process/ExecFileOutput.js"; import { webBundleHeapExhaustedReason, webBundleHeapExhaustedRemediation, @@ -22,6 +26,7 @@ import { } from "../domain/webBundleAnalysis.js"; import { javaScriptWorkerMemoryLimitBytes } from "../process/JavaScriptWorkerMemoryLimit.js"; import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; +import * as ownershipObservation from "../process/ProcessOwnershipObservation.js"; import { spawnOwnedProviderProcess, type ProviderProcessHandle, @@ -297,6 +302,143 @@ const waitForExit = async (child: ProviderProcessHandle): Promise => { }); }; +it("preserves physical ownership inspection failure when cancellation follows", async () => { + const controller = new AbortController(); + const reason = { cancelled: "late" }; + const physical = new Error("inspection physically failed"); + const prepare = vi + .spyOn(ownershipObservation, "prepareProcessOwnershipInspection") + .mockImplementation(async () => { + controller.abort(reason); + throw physical; + }); + const worker = new WebBundleAnalysisWorker(); + try { + const result = await worker.analyze( + makeScript("0"), + 5_000, + controller.signal, + ); + const error = failureOf(result); + expect(error).toBeInstanceOf(AnalysisOutputError); + expect(error.cause).toBe(physical); + expect( + error.secondaryFailures.some((failure) => failure.cause === reason), + ).toBe(true); + } finally { + prepare.mockRestore(); + await worker.close(); + } +}); + +it.each(["reason", "native-abort"] as const)( + "retains %s cancellation from ownership inspection", + async (kind) => { + const controller = new AbortController(); + const reason = { caller: "cancel" }; + const prepare = vi + .spyOn(ownershipObservation, "prepareProcessOwnershipInspection") + .mockImplementation(async () => { + controller.abort(reason); + throw kind === "reason" + ? reason + : new ExecFileOutputCancelledError(reason); + }); + const worker = new WebBundleAnalysisWorker(); + try { + const result = await worker.analyze( + makeScript("0"), + 5_000, + controller.signal, + ); + const error = failureOf(result); + expect(error).toBeInstanceOf(AnalysisCancelledError); + expect(error.cause).toBe(reason); + } finally { + prepare.mockRestore(); + await worker.close(); + } + }, +); + +it("retains ownership-helper cleanup until worker close retries it", async () => { + const controller = new AbortController(); + const reason = { caller: "cancel" }; + const cleanup = new ProviderCleanupError( + "process-ownership", + ["helper-root"], + {}, + { operation: "prepare_owned_process" }, + ); + const owner = { + close: vi.fn(async () => { + cleanup.resolveCleanup(); + }), + }; + const prepare = vi + .spyOn(ownershipObservation, "prepareProcessOwnershipInspection") + .mockImplementation(async () => { + controller.abort(reason); + throw new DarwinProcessOwnershipInspectionError("helper cleanup failed", { + cancellation: { reason }, + cleanupFailure: cleanup, + cleanupOwner: owner, + }); + }); + const worker = new WebBundleAnalysisWorker(); + try { + const result = await worker.analyze( + makeScript("0"), + 5_000, + controller.signal, + ); + const error = failureOf(result); + expect(error.cleanupIncomplete).toBe(true); + expect(error.cleanupResources).toContain("helper-root"); + await worker.close(); + expect(owner.close).toHaveBeenCalledOnce(); + expect(error.cleanupIncomplete).toBe(false); + } finally { + prepare.mockRestore(); + await worker.close(); + } +}); + +it("retains failed helper cleanup without caller cancellation", async () => { + const cleanup = new ProviderCleanupError( + "process-ownership", + ["helper-root"], + {}, + { operation: "prepare_owned_process" }, + ); + const owner = { + close: vi.fn(async () => { + cleanup.resolveCleanup(); + }), + }; + const physical = new DarwinProcessOwnershipInspectionError( + "inspection failed", + { cleanupFailure: cleanup, cleanupOwner: owner }, + ); + const prepare = vi + .spyOn(ownershipObservation, "prepareProcessOwnershipInspection") + .mockRejectedValue(physical); + const worker = new WebBundleAnalysisWorker(); + try { + const result = await worker.analyze(makeScript("0"), 5_000); + const error = failureOf(result); + expect(primaryAnalysisError(error).cause).toBe(physical); + expect(error.cleanupIncomplete).toBe(true); + expect(error.cleanupResources).toContain("helper-root"); + await worker.close(); + expect(owner.close).toHaveBeenCalledOnce(); + expect(error.cleanupIncomplete).toBe(false); + } finally { + prepare.mockRestore(); + await worker.close(); + } +}); + it("reuses one real owned worker for completed facts from consecutive scripts", async () => { const children: ProviderProcessHandle[] = []; const worker = new WebBundleAnalysisWorker( diff --git a/src/browser/WebBundleAnalysisWorker.ts b/src/browser/WebBundleAnalysisWorker.ts index 4e0da8e59e..4b10f4741e 100644 --- a/src/browser/WebBundleAnalysisWorker.ts +++ b/src/browser/WebBundleAnalysisWorker.ts @@ -1,3 +1,5 @@ +import { retainProcessInspectionCleanup } from "./ProcessInspectionCleanup.js"; +import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; import type { JavaScriptSemanticPrecisionInput } from "../domain/javascript/javascriptSemanticPrecision.js"; import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js"; import { WorkerFrameSignal } from "../process/WorkerFrameSignal.js"; @@ -55,7 +57,10 @@ import { } from "../process/RuntimeControlUnavailableError.js"; import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js"; import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; -import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js"; +import { + isProcessOwnershipInspectionCancellation, + prepareProcessOwnershipInspection, +} from "../process/ProcessOwnershipObservation.js"; import { javaScriptWorkerMemoryLimitBytes } from "../process/JavaScriptWorkerMemoryLimit.js"; import { DEFAULT_WEB_BUNDLE_HEAP_MB, @@ -283,6 +288,7 @@ export class WebBundleAnalysisWorker { this.#activeJob = undefined; this.#activeTerminalResponseObserved = false; let result: Result; + let ownershipInspectionFailure: AnalysisError | undefined; let completed: WebBundleScriptAnalysis | null = null; let continuation: "result_capacity_exhausted" | undefined; let resultDescriptor: WebBundleResultDescriptor | undefined; @@ -309,7 +315,41 @@ export class WebBundleAnalysisWorker { throw admitted.error; } const resultBudget = admitted.bytes; - await prepareProcessOwnershipInspection(signal); + try { + await prepareProcessOwnershipInspection(signal); + } catch (cause: unknown) { + if (cause instanceof DarwinProcessOwnershipInspectionError) { + const primary = + cause.cancellation === undefined + ? new AnalysisOutputError( + OPERATION, + "Process ownership inspection failed", + { cause }, + ) + : new AnalysisCancelledError(OPERATION, { + cause: cause.cancellation.reason, + }); + ownershipInspectionFailure = retainProcessInspectionCleanup( + cause, + primary, + this.#files, + OPERATION, + ); + } else if ( + (cause instanceof AnalysisError && cause.cleanupIncomplete) || + !isProcessOwnershipInspectionCancellation(cause, signal) + ) { + ownershipInspectionFailure = + cause instanceof AnalysisError + ? cause + : new AnalysisOutputError( + OPERATION, + "Process ownership inspection failed", + { cause }, + ); + } + throw cause; + } deadline = new ProviderStartupDeadline(timeoutMs, signal); this.#activeDeadline = deadline; await this.#start(deadline); @@ -458,25 +498,31 @@ export class WebBundleAnalysisWorker { : undefined; const observedFailure = this.#observedFailure === undefined - ? undefined + ? ownershipInspectionFailure : this.#projectObservedFailure(); if (observedFailure !== undefined && interrupted !== undefined) observedFailure.retainSecondaryFailure(interrupted); const primaryCause = artifactPrimary === undefined ? cause : artifactPrimary.cause; const primary = - interrupted !== undefined && observedFailure !== undefined - ? observedFailure - : (interrupted ?? - (primaryCause instanceof RuntimeControlUnavailableError - ? runtimeControlCapabilityError(PROVIDER, OPERATION, primaryCause) - : primaryCause instanceof AnalysisError - ? primaryCause - : new AnalysisOutputError( + ownershipInspectionFailure !== undefined + ? ownershipInspectionFailure + : interrupted !== undefined && observedFailure !== undefined + ? observedFailure + : (interrupted ?? + (primaryCause instanceof RuntimeControlUnavailableError + ? runtimeControlCapabilityError( + PROVIDER, OPERATION, - "Owned web-script analysis failed", - { cause: primaryCause }, - ))); + primaryCause, + ) + : primaryCause instanceof AnalysisError + ? primaryCause + : new AnalysisOutputError( + OPERATION, + "Owned web-script analysis failed", + { cause: primaryCause }, + ))); const failure = cause instanceof ArtifactFileCleanupFailure ? this.#cleanupFailure( diff --git a/src/process/DarwinCompilationRetirement.test.ts b/src/process/DarwinCompilationRetirement.test.ts index 3441aeea79..23a978ad45 100644 --- a/src/process/DarwinCompilationRetirement.test.ts +++ b/src/process/DarwinCompilationRetirement.test.ts @@ -10,13 +10,18 @@ const fixture = vi.hoisted(() => { let started: Promise; let stopped: Promise; let gate: Promise; - let mode: "failure" | "abort" | "cleanup"; + let mode: "failure" | "abort" | "cleanup" | "abort-error"; let failRemoval = false; let calls = 0; const compilerFailure = new Error("controlled compiler failure"); + const compilerAbortError = new DOMException( + "independent compiler failure", + "AbortError", + ); return { compilerFailure, - configure(selected: "failure" | "abort" | "cleanup") { + compilerAbortError, + configure(selected: "failure" | "abort" | "cleanup" | "abort-error") { failRemoval = selected === "cleanup"; mode = selected; calls = 0; @@ -60,6 +65,7 @@ const fixture = vi.hoisted(() => { options.signal?.addEventListener("abort", aborted, { once: true }); start(); await gate; + if (mode === "abort-error") throw compilerAbortError; if (mode !== "failure") throw options.signal?.reason; throw compilerFailure; } @@ -89,7 +95,7 @@ import { DarwinProcessOwnershipInspectionError, } from "./DarwinProcessRunTokenReader.js"; -it.each(["failure", "abort", "cleanup"] as const)( +it.each(["failure", "abort", "cleanup", "abort-error"] as const)( "handles a %s outcome for a waiter joining retirement", async (mode) => { fixture.configure(mode); @@ -118,9 +124,12 @@ it.each(["failure", "abort", "cleanup"] as const)( cause: expect.objectContaining({ message: "first caller left" }), }); else await expect(first).rejects.toThrow("first caller left"); - if (mode === "failure") { + if (mode === "failure" || mode === "abort-error") { await expect(second).rejects.toMatchObject({ - cause: fixture.compilerFailure, + cause: + mode === "failure" + ? fixture.compilerFailure + : fixture.compilerAbortError, }); expect(fixture.calls).toBe(1); } else if (mode === "cleanup") { diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index 5546fc770a..31aa7d8abe 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -1,3 +1,4 @@ +import { isProcessOwnershipInspectionCancellation } from "./ProcessInspectionCancellation.js"; import { ProcessAdmissionError } from "./ProcessAdmissionError.js"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { rmSync } from "node:fs"; @@ -360,11 +361,6 @@ const abortReason = (signal: AbortSignal): unknown => { return error; }; -const isExpectedAbort = (cause: unknown, signal: AbortSignal): boolean => - signal.aborted && - (cause === signal.reason || - (cause instanceof Error && cause.name === "AbortError")); - type CompilationWaitOutcome = | { readonly kind: "completed"; readonly result: Result } | { readonly kind: "cancelled"; readonly reason: unknown }; @@ -573,7 +569,8 @@ export const createDarwinProcessRunTokenReader = ( env: compilerEnvironment, }); } catch (cause: unknown) { - if (isExpectedAbort(cause, signal)) throw cause; + if (isProcessOwnershipInspectionCancellation(cause, signal)) + throw cause; throw compilerInspectionError(cause, compilerTimeoutMs, compiler); } if (signal.aborted || closed) throw abortReason(signal); @@ -652,7 +649,7 @@ export const createDarwinProcessRunTokenReader = ( if ( (failure instanceof DarwinProcessOwnershipInspectionError && failure.cleanupFailure !== undefined) || - !isExpectedAbort( + !isProcessOwnershipInspectionCancellation( operation.primaryFailure?.cause, operation.controller.signal, ) diff --git a/src/process/ProcessInspectionCancellation.ts b/src/process/ProcessInspectionCancellation.ts new file mode 100644 index 0000000000..51fcf4b1d3 --- /dev/null +++ b/src/process/ProcessInspectionCancellation.ts @@ -0,0 +1,10 @@ +/** Recognize only cancellation attributed to this exact inspection signal. */ +export const isProcessOwnershipInspectionCancellation = ( + cause: unknown, + signal: AbortSignal | undefined, +): boolean => + signal?.aborted === true && + (cause === signal.reason || + (cause instanceof Error && + cause.name === "AbortError" && + cause.cause === signal.reason)); diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 5ec980fce0..1baf66d9a6 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -1,3 +1,5 @@ +import { isProcessOwnershipInspectionCancellation } from "./ProcessInspectionCancellation.js"; +export { isProcessOwnershipInspectionCancellation } from "./ProcessInspectionCancellation.js"; import { ProcessAdmissionError } from "./ProcessAdmissionError.js"; import { readFile, readlink, stat as statPath } from "node:fs/promises"; import { performance } from "node:perf_hooks"; @@ -1162,14 +1164,6 @@ export const reobserveUnavailableTokenFacts = async ( return current; }; -const isExpectedAbort = ( - cause: unknown, - signal: AbortSignal | undefined, -): boolean => - signal?.aborted === true && - (cause === signal.reason || - (cause instanceof Error && cause.name === "AbortError")); - /** Create the operating-system process inspector for an explicit host context. */ export const createSystemProcessOwnershipHost = ( platform: NodeJS.Platform = process.platform, @@ -1263,7 +1257,8 @@ export const createSystemProcessOwnershipHost = ( ...(signal === undefined ? {} : { signal }), }); } catch (cause: unknown) { - if (isExpectedAbort(cause, signal)) throw cause; + if (isProcessOwnershipInspectionCancellation(cause, signal)) + throw cause; return undefined; } }; @@ -1335,7 +1330,8 @@ export const createSystemProcessOwnershipHost = ( ...(signal === undefined ? {} : { signal }), }); } catch (cause: unknown) { - if (isExpectedAbort(cause, signal)) throw cause; + if (isProcessOwnershipInspectionCancellation(cause, signal)) + throw cause; return [pid, readableIdentity(parsed)] as const; } signal?.throwIfAborted(); @@ -1403,7 +1399,8 @@ export const createSystemProcessOwnershipHost = ( ...(signal === undefined ? {} : { signal }), }); } catch (cause: unknown) { - if (isExpectedAbort(cause, signal)) throw cause; + if (isProcessOwnershipInspectionCancellation(cause, signal)) + throw cause; return [pid, readableIdentity(parsed)] as const; } signal?.throwIfAborted(); @@ -1427,7 +1424,8 @@ export const createSystemProcessOwnershipHost = ( readableIdentity(verified, uids[1], environmentRestriction), ] as const; } catch (cause: unknown) { - if (isExpectedAbort(cause, signal)) throw cause; + if (isProcessOwnershipInspectionCancellation(cause, signal)) + throw cause; if ( cause instanceof Error && "code" in cause && @@ -1481,7 +1479,7 @@ export const createSystemProcessOwnershipHost = ( try { return await darwinTokens.identities(processes, signal); } catch (cause: unknown) { - if (isExpectedAbort(cause, signal)) throw cause; + if (isProcessOwnershipInspectionCancellation(cause, signal)) throw cause; const reason = errorMessage(cause); return new Map( processes.map(({ pid }) => [ @@ -1495,7 +1493,7 @@ export const createSystemProcessOwnershipHost = ( try { return await listProcesses(signal); } catch (cause: unknown) { - if (isExpectedAbort(cause, signal)) throw cause; + if (isProcessOwnershipInspectionCancellation(cause, signal)) throw cause; const failure = execFileOutputFailure(cause); if ( spawnCapacityExhausted(failure) || @@ -1628,7 +1626,8 @@ export const createSystemProcessOwnershipHost = ( try { return (await darwinTokens?.read(processes, signal)) ?? new Map(); } catch (cause: unknown) { - if (isExpectedAbort(cause, signal)) throw cause; + if (isProcessOwnershipInspectionCancellation(cause, signal)) + throw cause; void cause; return new Map( processes.map(({ pid }) => [