mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 09:52:27 +00:00
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:
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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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));
|
||||
@@ -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 }) => [
|
||||
|
||||
Reference in new issue
Block a user