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
This commit is contained in:
morluto authored and GitHub committed 2026-11-11 18:37:09 +08:00
1 parent a16f5f4b5d
commit 4a3bc37b76
13 files changed
+853 -137

No files matched your search

@@ -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>;
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");
@@ -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({
@@ -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);
+20 -15
View File
@@ -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";
};
@@ -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<typeof import("playwright-core")._electron.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 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<typeof import("playwright-core")._electron.launch>
>;
let acknowledge = (_application: Application): void => undefined;
let started = (): void => undefined;
const launching = new Promise<Application>((resolve) => {
acknowledge = resolve;
});
const launchStarted = new Promise<void>((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<void>((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 () => {
+92 -66
View File
@@ -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 <T>(
inspect: () => Promise<T> | undefined,
signal: AbortSignal | undefined,
resources: ArtifactResourceScope,
): Promise<T | undefined> => {
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<ElectronActiveObservationResult, AnalysisError>,
signal: AbortSignal | undefined,
): Result<ElectronActiveObservationResult, AnalysisError> => {
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<Record<string, string | undefined>>,
runId: string,
+28
View File
@@ -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]);
};
+143 -1
View File
@@ -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<void> => {
});
};
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(
+60 -14
View File
@@ -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<WebBundleScriptAnalysis, AnalysisError>;
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(
@@ -10,13 +10,18 @@ const fixture = vi.hoisted(() => {
let started: Promise<void>;
let stopped: Promise<void>;
let gate: Promise<void>;
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") {
+4 -7
View File
@@ -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<string, unknown> }
| { 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,
)
@@ -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));
+14 -15
View File
@@ -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 }) => [