fix(runtime): retain Inspector and Electron cleanup ownership

This commit is contained in:
morluto
2026-10-19 14:03:28 +08:00
parent 7f4e7d5577
commit 87ab054ea3
23 changed files with 1901 additions and 321 deletions
+6
View File
@@ -33,6 +33,12 @@ encoded path separators, ASAR directories or links, and nonexistent paths are
rejected. The endpoint exposes every eligible target and its local path
metadata.
Cancellation stops further file authorization after an already-started filesystem
operation settles. ASAR descriptor-close failures remain typed incomplete cleanup
with an exact retryable owner; they are not treated as a missing or unauthorized
page. A later authorization and provider shutdown retry retained descriptors
before reporting their release.
## Workflow
`list_electron_targets` returns every eligible target served by the supplied endpoint in one inline array.
+9
View File
@@ -152,6 +152,15 @@ and uncollected scripts, but scripts collected before attachment may be
missing. Absence from the observation window never proves that a script or
behavior does not occur.
Cancellation stops new target-file authorization after the current filesystem
operation settles. Cancellation diagnostics retain the examined target prefix;
unexamined targets remain unknown. Observation cancellation keeps its original
reason and any completed Evidence. Finalization waits for REA's bounded socket
close (up to two seconds for graceful and forced confirmation). A failed close
retains its exact transport owner and blocks replacement connections until
cleanup succeeds; CLI finalization and MCP shutdown also retry retained cleanup.
Closing REA's connection does not stop the caller-owned runtime.
## Static correlation
Pass the resulting observation Evidence and an
@@ -205,3 +205,42 @@ it("retains completed target facts when cancellation arrives before Evidence pub
});
}
});
it.each(["list", "inspect"] as const)(
"preserves a pre-abort reason before Electron %s provider work",
async (kind) => {
const reason = new Error(`stop Electron ${kind}`);
const controller = new AbortController();
controller.abort(reason);
let providerCalls = 0;
const provider: ElectronObservationPort = {
identity: () => identity,
listTargets: async () => {
providerCalls += 1;
return err(new AnalysisCancelledError("list_electron_targets"));
},
inspectPage: async () => {
providerCalls += 1;
return err(new AnalysisCancelledError("inspect_electron_page"));
},
};
const result =
kind === "list"
? await listElectronTargets(
provider,
{ cdp_endpoint: input.cdp_endpoint },
{ signal: controller.signal },
)
: await inspectElectronPage(provider, input, {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
}
expect(providerCalls).toBe(0);
},
);
@@ -29,7 +29,11 @@ export const listElectronTargets = async (
const ready = requireProvider(provider, "list_electron_targets");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("list_electron_targets"));
return err(
new AnalysisCancelledError("list_electron_targets", {
cause: options.signal.reason,
}),
);
const result = await ready.value.listTargets(input, options);
if (!result.ok) return result;
const report = electronTargetListSchema.safeParse(result.value);
@@ -61,7 +65,11 @@ export const inspectElectronPage = async (
const ready = requireProvider(provider, "inspect_electron_page");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("inspect_electron_page"));
return err(
new AnalysisCancelledError("inspect_electron_page", {
cause: options.signal.reason,
}),
);
const result = await ready.value.inspectPage(input, options);
if (!result.ok) {
const partial = result.error.partialObservation;
@@ -13,6 +13,8 @@ import type { Result } from "../../domain/result.js";
/** Provider-neutral boundary for passive Node/Electron Inspector observation. */
export interface JavaScriptRuntimeObservationPort {
/** Retry retained Inspector and associated file cleanup during shutdown. */
close?(): Promise<void>;
identity(): ProviderIdentity;
listTargets(
input: ListJavaScriptRuntimeTargetsInput,
@@ -271,3 +271,49 @@ it("reports non-JSON Inspector output before late cancellation", async () => {
error: { _tag: "AnalysisOutputError", partialObservation: undefined },
});
});
it.each(["list", "observe"] as const)(
"preserves a pre-abort reason before Inspector %s provider work",
async (kind) => {
const reason = new Error(`stop Inspector ${kind}`);
const controller = new AbortController();
controller.abort(reason);
let providerCalls = 0;
const provider: JavaScriptRuntimeObservationPort = {
identity: () => ({ id: "test", name: "Test", version: "1" }),
listTargets: async () => {
providerCalls += 1;
return err(
new AnalysisCancelledError("list_javascript_runtime_targets"),
);
},
observe: async () => {
providerCalls += 1;
return err(new AnalysisCancelledError("observe_javascript_runtime"));
},
};
const result =
kind === "list"
? await listJavaScriptRuntimeTargets(
provider,
{ inspector_endpoint: "http://127.0.0.1:9229" },
{ signal: controller.signal },
)
: await observeJavaScriptRuntime(
provider,
{
inspector_endpoint: "http://127.0.0.1:9229",
target_id: "target",
observation_ms: 0,
},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
}
expect(providerCalls).toBe(0);
},
);
@@ -28,7 +28,11 @@ export const listJavaScriptRuntimeTargets = async (
const ready = requireProvider(provider, "list_javascript_runtime_targets");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("list_javascript_runtime_targets"));
return err(
new AnalysisCancelledError("list_javascript_runtime_targets", {
cause: options.signal.reason,
}),
);
const result = await ready.value.listTargets(input, options);
if (!result.ok) return result;
const report = javascriptRuntimeTargetListSchema.safeParse(result.value);
@@ -58,7 +62,11 @@ export const observeJavaScriptRuntime = async (
const ready = requireProvider(provider, "observe_javascript_runtime");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("observe_javascript_runtime"));
return err(
new AnalysisCancelledError("observe_javascript_runtime", {
cause: options.signal.reason,
}),
);
const result = await ready.value.observe(input, options);
if (!result.ok) {
const partial = result.error.partialObservation;
+41 -6
View File
@@ -5,6 +5,7 @@ import type {
InspectElectronPageInput,
} from "../domain/javascript/electronObservation.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { CdpConnection, CdpEvent } from "./CdpConnection.js";
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
@@ -153,7 +154,11 @@ const authorizeInitialFrames = async (
context.sessionId,
context.signal,
);
const frames = await captureFrames(result, state.completeness);
const frames = await captureFrames(
result,
state.completeness,
context.signal,
);
if (frames[0] === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
return frames;
@@ -219,7 +224,7 @@ const assertStableMainFrame = async (
context.sessionId,
context.signal,
);
const afterPath = await mainFilePath(result);
const afterPath = await mainFilePath(result, context.signal);
if (afterPath === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
if (state.navigationDuringCapture || afterPath !== expectedPath)
@@ -239,6 +244,7 @@ const navigatedFrameId = (event: CdpEvent): string | undefined => {
export const captureFrames = async (
result: unknown,
completeness: CdpCaptureCompleteness,
signal?: AbortSignal,
): Promise<ElectronPageInspection["frames"]> => {
const frameTree = recordValue(requiredRecord(result).frameTree);
if (frameTree === undefined)
@@ -247,13 +253,18 @@ export const captureFrames = async (
for (const tree of walkFrameTrees(frameTree, () =>
completeness.exclude("frames", "invalid_protocol_value"),
)) {
throwIfElectronInspectionCancelled(signal);
const frame = recordValue(tree.frame);
const frameId = cdpStringValue(frame?.id);
if (frame === undefined || frameId === undefined) {
completeness.exclude("frames", "invalid_protocol_value");
continue;
}
const path = await authorizedElectronFile(cdpStringValue(frame.url) ?? "");
const path = await authorizedElectronFile(
cdpStringValue(frame.url) ?? "",
signal,
"inspect_electron_page",
);
if (path === undefined) {
completeness.exclude("frames", "out_of_target_scope");
continue;
@@ -285,8 +296,11 @@ const captureResources = async (
for (const resource of frameTreeResources(root, (count) =>
completeness.exclude("resources", "invalid_protocol_value", count),
)) {
throwIfElectronInspectionCancelled(context.signal);
const path = await authorizedElectronFile(
cdpStringValue(resource.url) ?? "",
context.signal,
"inspect_electron_page",
);
if (path === undefined) {
completeness.exclude("resources", "out_of_target_scope");
@@ -336,12 +350,19 @@ const captureDom = async (
const nodes: ElectronPageInspection["dom"]["nodes"] = [];
let total = 0;
for (const document of snapshotDocuments(result.documents, invalid)) {
throwIfElectronInspectionCancelled(context.signal);
const documentUrl = cdpSnapshotString(strings, document.documentURL);
if (documentUrl === undefined) {
invalid(1);
continue;
}
if ((await authorizedElectronFile(documentUrl)) === undefined) {
if (
(await authorizedElectronFile(
documentUrl,
context.signal,
"inspect_electron_page",
)) === undefined
) {
completeness.exclude("dom", "out_of_target_scope");
continue;
}
@@ -376,10 +397,24 @@ const captureDom = async (
return { total_nodes: total, nodes };
};
const mainFilePath = async (result: unknown): Promise<string | undefined> => {
const mainFilePath = async (
result: unknown,
signal?: AbortSignal,
): Promise<string | undefined> => {
const frameTree = recordValue(requiredRecord(result).frameTree);
const frame = recordValue(frameTree?.frame);
return await authorizedElectronFile(cdpStringValue(frame?.url) ?? "");
return await authorizedElectronFile(
cdpStringValue(frame?.url) ?? "",
signal,
"inspect_electron_page",
);
};
const throwIfElectronInspectionCancelled = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError("inspect_electron_page", {
cause: signal.reason,
});
};
const report = async (
+208 -19
View File
@@ -15,11 +15,11 @@ import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
discoverCdpEndpoint,
hasCdpTargetWebSocket,
type CdpEndpointDiscovery,
type CdpEndpointTarget,
} from "./CdpEndpoint.js";
import {
@@ -28,7 +28,11 @@ import {
type CdpTargetSession,
} from "./CdpTargetSession.js";
import { inspectCdpElectronPage } from "./CdpElectronInspection.js";
import { authorizedElectronFile } from "./ElectronFileScope.js";
import {
authorizedElectronFile,
mapElectronFileAuthorizationFailure,
retryElectronFileCleanup,
} from "./ElectronFileScope.js";
import { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
@@ -41,9 +45,58 @@ import {
/** Passive Electron provider for local file pages exposed by loopback CDP. */
export class CdpElectronProvider implements ElectronObservationPort {
readonly #owner = new CdpTargetSessionOwner();
#activeOperations = 0;
#closing = false;
#closePromise: Promise<void> | undefined;
readonly #idleWaiters = new Set<() => void>();
close(): Promise<void> {
return this.#owner.close();
async close(): Promise<void> {
if (this.#closePromise === undefined) {
this.#closing = true;
const attempt = this.#closeAfterOperations();
let tracked: Promise<void>;
tracked = attempt.catch((cause: unknown) => {
if (this.#closePromise === tracked) this.#closePromise = undefined;
throw cause;
});
this.#closePromise = tracked;
}
await this.#closePromise;
}
async #closeAfterOperations(): Promise<void> {
while (this.#activeOperations > 0)
await new Promise<void>((resolve) => this.#idleWaiters.add(resolve));
const outcomes = await Promise.allSettled([
this.#owner.close(),
retryElectronFileCleanup(),
]);
const failures = outcomes.flatMap((outcome) =>
outcome.status === "rejected" ? [outcome.reason as unknown] : [],
);
const mapped = failures.map(
(failure) =>
mapElectronFileAuthorizationFailure(
failure,
CDP_ELECTRON_PROVIDER_IDENTITY.id,
"inspect_electron_page",
) ?? failure,
);
if (mapped.length === 1) throw mapped[0];
if (mapped.length > 1) {
const typed = mapped.filter(
(failure): failure is AnalysisError => failure instanceof AnalysisError,
);
throw new ProviderCleanupError(
CDP_ELECTRON_PROVIDER_IDENTITY.id,
typed.flatMap((failure) => failure.cleanupResources),
{ reason: "Electron transport and file cleanup remain incomplete" },
{
operation: "inspect_electron_page",
cause: new AggregateError(mapped),
},
).bindCleanupDependencies(typed);
}
}
identity(): ProviderIdentity {
@@ -54,30 +107,58 @@ export class CdpElectronProvider implements ElectronObservationPort {
input: ListElectronTargetsInput,
options: ExecutionOptions = {},
): Promise<Result<ElectronTargetList, AnalysisError>> {
return await this.#withAdmission(
"list_electron_targets",
async () => await this.#listTargets(input, options),
);
}
async #listTargets(
input: ListElectronTargetsInput,
options: ExecutionOptions,
): Promise<Result<ElectronTargetList, AnalysisError>> {
const allowed: ElectronTargetList["targets"] = [];
let discovery: CdpEndpointDiscovery | undefined;
let unsupportedUrl = 0;
let nonPage = 0;
let unconnectable = 0;
let examined = 0;
try {
const discovery = await discoverCdpEndpoint(
discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
"list_electron_targets",
options.signal,
);
const allowed = [];
let unsupportedUrl = 0;
let nonPage = 0;
let unconnectable = 0;
throwIfElectronOperationCancelled(
options.signal,
"list_electron_targets",
);
for (const target of discovery.targets) {
throwIfElectronOperationCancelled(
options.signal,
"list_electron_targets",
);
if (target.type !== "page") {
examined += 1;
nonPage += 1;
continue;
}
const path = await authorizedElectronFile(target.url);
const path = await authorizedElectronFile(
target.url,
options.signal,
"list_electron_targets",
);
if (path === undefined) {
examined += 1;
unsupportedUrl += 1;
continue;
}
if (!hasCdpTargetWebSocket(discovery, target)) {
examined += 1;
unconnectable += 1;
continue;
}
examined += 1;
allowed.push({
target_id: target.id,
type: target.type,
@@ -89,6 +170,10 @@ export class CdpElectronProvider implements ElectronObservationPort {
allowed.sort((left, right) =>
compareUnicodeCodePoints(left.target_id, right.target_id),
);
throwIfElectronOperationCancelled(
options.signal,
"list_electron_targets",
);
return ok(
electronTargetListSchema.parse({
browser: discovery.version,
@@ -113,13 +198,65 @@ export class CdpElectronProvider implements ElectronObservationPort {
}),
);
} catch (cause: unknown) {
return err(providerError(cause, "list_electron_targets"));
const fileFailure = mapElectronFileAuthorizationFailure(
cause,
CDP_ELECTRON_PROVIDER_IDENTITY.id,
"list_electron_targets",
{
completed_prefix: {
browser_version: discovery?.version ?? null,
examined_targets: examined,
discovered_targets: discovery?.targets.length ?? null,
allowed_targets: allowed,
excluded: {
unsupported_url: unsupportedUrl,
non_page: nonPage,
},
unconnectable_targets: unconnectable,
coverage:
"partial: target authorization stopped; remaining targets are unknown",
},
},
);
if (fileFailure !== undefined) return err(fileFailure);
return err(
options.signal?.aborted === true
? new AnalysisCancelledError("list_electron_targets", {
cause: options.signal.reason,
diagnostics: {
completed_prefix: {
browser_version: discovery?.version ?? null,
examined_targets: examined,
discovered_targets: discovery?.targets.length ?? null,
allowed_targets: allowed,
excluded: {
unsupported_url: unsupportedUrl,
non_page: nonPage,
},
unconnectable_targets: unconnectable,
coverage:
"partial: target authorization stopped; remaining targets are unknown",
},
},
})
: providerError(cause, "list_electron_targets"),
);
}
}
async inspectPage(
input: InspectElectronPageInput,
options: ExecutionOptions = {},
): Promise<Result<ElectronPageInspection, AnalysisError>> {
return await this.#withAdmission(
"inspect_electron_page",
async () => await this.#inspectPage(input, options),
);
}
async #inspectPage(
input: InspectElectronPageInput,
options: ExecutionOptions,
): Promise<Result<ElectronPageInspection, AnalysisError>> {
let targetSession: CdpTargetSession | undefined;
let cleanupFailure: AnalysisError | undefined;
@@ -130,7 +267,11 @@ export class CdpElectronProvider implements ElectronObservationPort {
"inspect_electron_page",
options.signal,
);
const target = await authorizeTarget(discovery.targets, input.target_id);
const target = await authorizeTarget(
discovery.targets,
input.target_id,
options.signal,
);
targetSession = await openCdpTargetSession(
discovery,
target,
@@ -241,31 +382,79 @@ export class CdpElectronProvider implements ElectronObservationPort {
if (finalCancellation !== undefined) return err(finalCancellation);
return result;
}
async #withAdmission<Value>(
operation: "list_electron_targets" | "inspect_electron_page",
work: () => Promise<Result<Value, AnalysisError>>,
): Promise<Result<Value, AnalysisError>> {
if (this.#closing)
return err(
new ProviderAdapterError(CDP_ELECTRON_PROVIDER_IDENTITY.id, operation, {
diagnostics: { reason: "provider_closed" },
userMessage: "Electron provider is closed to new observations.",
}),
);
this.#activeOperations += 1;
try {
return await work();
} catch (cause: unknown) {
return err(providerError(cause, operation));
} finally {
this.#activeOperations -= 1;
if (this.#activeOperations === 0) {
for (const resolve of this.#idleWaiters) resolve();
this.#idleWaiters.clear();
}
}
}
}
const authorizeTarget = async (
targets: readonly CdpEndpointTarget[],
targetId: string,
signal?: AbortSignal,
): Promise<CdpEndpointTarget> => {
throwIfElectronOperationCancelled(signal, "inspect_electron_page");
const target = targets.find(({ id }) => id === targetId);
if (target === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_found");
if (
target.type !== "page" ||
(await authorizedElectronFile(target.url)) === undefined
)
if (target.type !== "page")
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
const path = await authorizedElectronFile(
target.url,
signal,
"inspect_electron_page",
);
throwIfElectronOperationCancelled(signal, "inspect_electron_page");
if (path === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
return target;
};
const throwIfElectronOperationCancelled = (
signal: AbortSignal | undefined,
operation: "list_electron_targets" | "inspect_electron_page",
): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
};
const providerError = (
cause: unknown,
operation: BrowserObservationOperation,
): AnalysisError =>
cause instanceof BrowserObservationError && cause.operation !== operation
operation: "list_electron_targets" | "inspect_electron_page",
): AnalysisError => {
const fileFailure = mapElectronFileAuthorizationFailure(
cause,
CDP_ELECTRON_PROVIDER_IDENTITY.id,
operation,
);
if (fileFailure !== undefined) return fileFailure;
return cause instanceof BrowserObservationError &&
cause.operation !== operation
? new BrowserObservationError(operation, cause.reason, { cause })
: cause instanceof AnalysisError
? cause
: new ProviderAdapterError(CDP_ELECTRON_PROVIDER_IDENTITY.id, operation, {
cause,
});
};
+6 -1
View File
@@ -40,7 +40,12 @@ export const captureElectronScripts = async (
: undefined;
try {
for (const script of input.scripts) {
const path = await authorizedElectronFile(script.rawUrl);
throwIfCallerCancelled(input.signal);
const path = await authorizedElectronFile(
script.rawUrl,
input.signal,
"inspect_electron_page",
);
if (path === undefined) {
input.completeness.exclude("scripts", "out_of_target_scope");
continue;
+14 -1
View File
@@ -9,6 +9,7 @@ import type { CdpEndpointTarget } from "./CdpEndpoint.js";
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
import { authorizedElectronFile } from "./ElectronFileScope.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
/** Inventory local worker targets without attaching to them. */
export const captureElectronWorkers = async (input: {
@@ -42,6 +43,7 @@ export const captureElectronWorkers = async (input: {
return [];
}
for (const entry of targetInfos) {
throwIfElectronWorkerCaptureCancelled(input.signal);
const target = recordValue(entry);
if (target === undefined) {
input.completeness.exclude("workers", "invalid_protocol_value");
@@ -58,7 +60,11 @@ export const captureElectronWorkers = async (input: {
input.completeness.exclude("workers", "out_of_target_scope");
continue;
}
const path = await authorizedElectronFile(cdpStringValue(target.url) ?? "");
const path = await authorizedElectronFile(
cdpStringValue(target.url) ?? "",
input.signal,
"inspect_electron_page",
);
if (path === undefined) {
input.completeness.exclude("workers", "out_of_target_scope");
continue;
@@ -86,6 +92,13 @@ export const captureElectronWorkers = async (input: {
);
};
const throwIfElectronWorkerCaptureCancelled = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError("inspect_electron_page", {
cause: signal.reason,
});
};
const boundedTargetField = (value: unknown): string | null => {
const field = cdpStringValue(value);
return field === undefined || field === "" ? null : field;
@@ -0,0 +1,297 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, expect, it, vi } from "vitest";
const handleState = vi.hoisted(() => ({
archive: undefined as string | undefined,
failCloseCount: 0,
closeController: undefined as AbortController | undefined,
closeReason: undefined as unknown,
readController: undefined as AbortController | undefined,
readReason: undefined as unknown,
statController: undefined as AbortController | undefined,
statReason: undefined as unknown,
readFailure: undefined as Error | undefined,
events: [] as string[],
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
if (String(args[0]) !== handleState.archive) return handle;
handleState.events.push("open");
return new Proxy(handle, {
get(target, property, receiver) {
if (property === "stat" && handleState.statController !== undefined)
return async () => {
const result = await target.stat();
handleState.statController?.abort(handleState.statReason);
handleState.statController = undefined;
return result;
};
if (property === "read" && handleState.readFailure !== undefined)
return async () => {
const failure = handleState.readFailure;
handleState.readFailure = undefined;
throw failure;
};
if (property === "read" && handleState.readController !== undefined)
return async (...args: Parameters<typeof target.read>) => {
const response = await target.read(...args);
handleState.readController?.abort(handleState.readReason);
handleState.readController = undefined;
return response;
};
if (property === "close")
return async () => {
handleState.events.push("close");
if (handleState.closeController !== undefined) {
handleState.closeController.abort(handleState.closeReason);
handleState.closeController = undefined;
}
if (handleState.failCloseCount > 0) {
handleState.failCloseCount -= 1;
throw new Error("controlled ASAR descriptor close failure");
}
return await target.close();
};
return Reflect.get(target, property, receiver) as unknown;
},
});
},
};
});
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { CdpElectronProvider } from "./CdpElectronProvider.js";
import {
authorizedElectronFile,
mapElectronFileAuthorizationFailure,
retryElectronFileCleanup,
} from "./ElectronFileScope.js";
const roots: string[] = [];
afterEach(async () => {
await retryElectronFileCleanup();
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
handleState.archive = undefined;
handleState.failCloseCount = 0;
handleState.closeController = undefined;
handleState.closeReason = undefined;
handleState.readController = undefined;
handleState.readReason = undefined;
handleState.statController = undefined;
handleState.statReason = undefined;
handleState.readFailure = undefined;
handleState.events = [];
});
const asarPage = async (): Promise<string> => {
const root = await mkdtemp(join(tmpdir(), "rea-electron-file-scope-"));
roots.push(root);
const archive = join(root, "app.asar");
const json = Buffer.from(
JSON.stringify({ files: { "index.html": { size: 1, offset: "0" } } }),
);
const archiveBytes = Buffer.alloc(16 + json.length + 1);
const headerBytes = 8 + json.length;
archiveBytes.writeUInt32LE(4, 0);
archiveBytes.writeUInt32LE(headerBytes, 4);
archiveBytes.writeUInt32LE(headerBytes - 4, 8);
archiveBytes.writeUInt32LE(json.length, 12);
json.copy(archiveBytes, 16);
archiveBytes[16 + json.length] = 0;
await writeFile(archive, archiveBytes);
handleState.archive = archive;
return pathToFileURL(join(archive, "index.html")).href;
};
it("retains a failed ASAR close through denial and retries before reopening", async () => {
const location = await asarPage();
const missing = pathToFileURL(
join(dirname(fileURLToPath(location)), "missing.js"),
).href;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(missing, undefined, "list_electron_targets");
} catch (cause: unknown) {
failure = cause;
}
if (!(failure instanceof ArtifactReaderFailure))
throw new Error("Expected an ASAR descriptor cleanup failure");
expect(failure.cleanup?.resources).toEqual([handleState.archive]);
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"list_electron_targets",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeUndefined();
await expect(
authorizedElectronFile(missing, undefined, "list_electron_targets"),
).resolves.toBeUndefined();
expect(handleState.events).toEqual([
"open",
"close",
"close",
"open",
"close",
]);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("keeps cancellation primary when ASAR close fails, then retries the same handle", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = new Error("cancel during ASAR close");
handleState.closeController = controller;
handleState.closeReason = reason;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(
location,
controller.signal,
"inspect_electron_page",
);
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(reason);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(handleState.events).toEqual([
"open",
"close",
"close",
"open",
"close",
]);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("keeps an earlier read failure primary over a later abort and close failure", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = new Error("late cancellation after read failure");
const readFailure = new Error("controlled ASAR header read failure");
handleState.closeController = controller;
handleState.closeReason = reason;
handleState.failCloseCount = 1;
handleState.readFailure = readFailure;
let failure: unknown;
try {
await authorizedElectronFile(location, controller.signal);
} catch (cause: unknown) {
failure = cause;
}
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).not.toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(readFailure);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("preserves an arbitrary cancellation reason observed during ASAR read", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = { source: "caller", token: 17 };
handleState.readController = controller;
handleState.readReason = reason;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(location, controller.signal);
} catch (cause: unknown) {
failure = cause;
}
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(reason);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("preserves cancellation observed after ASAR stat when final close fails", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = { source: "caller", stage: "descriptor-stat" };
handleState.statController = controller;
handleState.statReason = reason;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(location, controller.signal);
} catch (cause: unknown) {
failure = cause;
}
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(reason);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("retries a failed ASAR descriptor lease during provider shutdown", async () => {
const location = await asarPage();
handleState.failCloseCount = 1;
await expect(authorizedElectronFile(location)).rejects.toBeInstanceOf(
ArtifactReaderFailure,
);
await new CdpElectronProvider().close();
expect(handleState.events).toEqual(["open", "close", "close"]);
});
+175 -35
View File
@@ -2,16 +2,117 @@ import type { Stats } from "node:fs";
import { open, realpath, stat } from "node:fs/promises";
import { isAbsolute, join, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
ArtifactFileCleanupFailure,
assertArtifactReadActive,
bindArtifactFileCleanup,
withArtifactFileHandle,
} from "../artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
admitAsarHeader,
MAX_ASAR_HEADER_BYTES,
} from "../artifacts/AsarHeader.js";
type ElectronFileOperation =
| "inspect_electron_page"
| "list_electron_targets"
| "observe_javascript_runtime"
| "list_javascript_runtime_targets";
// ASAR descriptor ownership outlives an individual provider call; every later
// authorization retries retained close leases before admitting new work.
const resources = new ArtifactResourceScope();
/** Resolve one local file URL while rejecting remote hosts and encoded separators. */
export const authorizedElectronFile = async (
value: string,
signal?: AbortSignal,
operation: ElectronFileOperation = "inspect_electron_page",
): Promise<string | undefined> => {
try {
return await resources.run(
async () => await resolveAuthorizedElectronFile(value, signal),
);
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
throw cause;
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
throw cause;
}
};
/** Retry retained ASAR descriptor cleanup during provider shutdown. */
export const retryElectronFileCleanup = async (): Promise<void> =>
await resources.retryCleanup();
/** Translate a retained ASAR descriptor failure at its provider boundary. */
export const mapElectronFileAuthorizationFailure = (
cause: unknown,
providerId: string,
operation: ElectronFileOperation,
diagnostics?: Readonly<Record<string, JsonValue>>,
): AnalysisError | undefined => {
if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined)
return undefined;
const physical =
cause instanceof ArtifactFileCleanupFailure
? cause.fileCleanupFailure
: undefined;
const physicalPrimary =
physical?.outcome.kind === "failed" ? physical.outcome.cause : undefined;
const cancellationReason =
physical?.completionCancellation === undefined
? physicalPrimary instanceof ArtifactReaderFailure &&
physicalPrimary.reason === "cancelled"
? physicalPrimary.cancellation === undefined
? physicalPrimary.cause
: physicalPrimary.cancellation.reason
: undefined
: physical.completionCancellation.reason;
const hasCancellation =
physical?.completionCancellation !== undefined ||
(physicalPrimary instanceof ArtifactReaderFailure &&
physicalPrimary.reason === "cancelled");
const primary = hasCancellation
? new AnalysisCancelledError(operation, {
cause: cancellationReason,
...(diagnostics === undefined ? {} : { diagnostics }),
})
: physicalPrimary instanceof AnalysisError
? physicalPrimary
: physicalPrimary === undefined
? undefined
: new ProviderAdapterError(providerId, operation, {
cause: physicalPrimary,
});
const cleanup = new ProviderCleanupError(
providerId,
cause.cleanup.resources,
{ reason: cause.cleanup.reason },
{ operation, cause },
);
bindArtifactFileCleanup(cause, cleanup);
if (primary === undefined) return cleanup;
const composed = analysisErrorWithCleanupFailure(primary, cleanup, operation);
bindArtifactFileCleanup(cause, composed);
return composed;
};
const resolveAuthorizedElectronFile = async (
value: string,
signal?: AbortSignal,
): Promise<string | undefined> => {
throwIfAborted(signal);
if (/%(?:2f|5c)/iu.test(value)) return undefined;
let url: URL;
try {
@@ -38,13 +139,21 @@ export const authorizedElectronFile = async (
}
if (!isAbsolute(path) || path.includes("\0")) return undefined;
try {
if ((await stat(path)).isFile()) return await realpath(path);
const metadata = await stat(path);
throwIfAborted(signal);
if (metadata.isFile()) {
const canonical = await realpath(path);
throwIfAborted(signal);
return canonical;
}
} catch (cause: unknown) {
throwIfAborted(signal);
// Electron serves packaged pages from inside app.asar, which the host
// filesystem cannot stat; resolve those through the archive header below.
void cause;
}
return await authorizedAsarMember(path);
throwIfAborted(signal);
return await authorizedAsarMember(path, signal);
};
/**
@@ -54,9 +163,11 @@ export const authorizedElectronFile = async (
*/
const authorizedAsarMember = async (
path: string,
signal?: AbortSignal,
): Promise<string | undefined> => {
const segments = path.split(sep);
for (let index = 1; index < segments.length - 1; index += 1) {
throwIfAborted(signal);
if (!(segments[index] ?? "").toLowerCase().endsWith(".asar")) continue;
const archive = segments.slice(0, index + 1).join(sep);
const member = segments.slice(index + 1);
@@ -64,13 +175,20 @@ const authorizedAsarMember = async (
return undefined;
try {
const metadata = await stat(archive);
throwIfAborted(signal);
if (!metadata.isFile()) continue;
const canonical = await realpath(archive);
const files = await asarFiles(canonical, metadata);
throwIfAborted(signal);
const files = await asarFiles(canonical, metadata, signal, resources);
throwIfAborted(signal);
return isAsarRegularFile(files, member)
? join(canonical, ...member)
: undefined;
} catch (cause: unknown) {
if (cause instanceof ArtifactFileCleanupFailure) throw cause;
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
throw cause;
throwIfAborted(signal);
// An unreadable or malformed archive cannot authorize its members.
void cause;
return undefined;
@@ -98,44 +216,62 @@ const archiveIdentity = (metadata: ArchiveIdentity): string =>
const asarFiles = async (
archive: string,
metadata: ArchiveIdentity,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
): Promise<unknown> => {
throwIfAborted(signal);
const identity = archiveIdentity(metadata);
const cached = headerCache.get(archive);
if (cached?.identity === identity) return cached.files;
const handle = await open(archive, "r");
let files: unknown;
let jsonBytes: number;
try {
const opened = await handle.stat();
if (!opened.isFile() || archiveIdentity(opened) !== identity)
throw new Error("ASAR identity changed before header read");
const admitted = await admitAsarHeader(handle);
jsonBytes = admitted.jsonBytes;
const json = Buffer.alloc(jsonBytes);
let offset = 0;
while (offset < json.length) {
const part = await handle.read(
json,
offset,
json.length - offset,
16 + offset,
const admitted = await withArtifactFileHandle(
archive,
handle,
resources,
signal,
async (file) => {
let jsonBytes: number;
throwIfAborted(signal);
const opened = await file.stat();
throwIfAborted(signal);
if (!opened.isFile() || archiveIdentity(opened) !== identity)
throw new Error("ASAR identity changed before header read");
const header = await admitAsarHeader(file);
throwIfAborted(signal);
jsonBytes = header.jsonBytes;
const json = Buffer.alloc(jsonBytes);
let offset = 0;
while (offset < json.length) {
throwIfAborted(signal);
const part = await file.read(
json,
offset,
json.length - offset,
16 + offset,
);
throwIfAborted(signal);
if (part.bytesRead === 0) throw new Error("Truncated ASAR JSON header");
offset += part.bytesRead;
}
const afterRead = await file.stat();
throwIfAborted(signal);
if (archiveIdentity(afterRead) !== identity)
throw new Error("ASAR identity changed during header read");
const parsed: unknown = JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(json),
);
if (part.bytesRead === 0) throw new Error("Truncated ASAR JSON header");
offset += part.bytesRead;
}
if (archiveIdentity(await handle.stat()) !== identity)
throw new Error("ASAR identity changed during header read");
const parsed: unknown = JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(json),
);
if (!isRecord(parsed) || !isRecord(parsed.files))
throw new Error("ASAR header has no file tree");
files = parsed.files;
} finally {
await handle.close();
}
if (!isRecord(parsed) || !isRecord(parsed.files))
throw new Error("ASAR header has no file tree");
return { files: parsed.files, jsonBytes };
},
);
throwIfAborted(signal);
headerCache.delete(archive);
headerCache.set(archive, { identity, files, bytes: jsonBytes });
headerCache.set(archive, {
identity,
files: admitted.files,
bytes: admitted.jsonBytes,
});
const retainedBytes = () =>
[...headerCache.values()].reduce((sum, entry) => sum + entry.bytes, 0);
while (
@@ -143,7 +279,7 @@ const asarFiles = async (
retainedBytes() > MAX_ASAR_HEADER_BYTES
)
headerCache.delete(headerCache.keys().next().value ?? archive);
return files;
return admitted.files;
};
const isAsarRegularFile = (
@@ -166,3 +302,7 @@ const isAsarRegularFile = (
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const throwIfAborted = (signal?: AbortSignal): void => {
assertArtifactReadActive(signal);
};
+21 -10
View File
@@ -14,6 +14,7 @@ import {
} from "./domain/javascript/javascriptRuntimeObservation.js";
import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js";
import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
import { finishAnalysisCommand } from "./cli/finishAnalysisCommand.js";
import type { JsonValue } from "./domain/jsonValue.js";
import type { Logger } from "pino";
import { withCommandCancellation } from "./cli/commandCancellation.js";
@@ -62,12 +63,17 @@ export const registerJavaScriptRuntimeObservationCommands = (
input,
),
);
const result = await listJavaScriptRuntimeTargets(
context.provider,
parsed.data,
{ signal },
return await finishAnalysisCommand(
() =>
listJavaScriptRuntimeTargets(context.provider, parsed.data, {
signal,
}),
{ close: () => context.provider.close?.() ?? Promise.resolve() },
context.provider.identity().id,
"list_javascript_runtime_targets",
signal,
"V8 Inspector resources",
);
return result.ok ? result.value : cliError(result.error);
},
),
),
@@ -104,12 +110,17 @@ export const registerJavaScriptRuntimeObservationCommands = (
input,
),
);
const result = await observeJavaScriptRuntime(
context.provider,
parsed.data,
{ signal },
return await finishAnalysisCommand(
() =>
observeJavaScriptRuntime(context.provider, parsed.data, {
signal,
}),
{ close: () => context.provider.close?.() ?? Promise.resolve() },
context.provider.identity().id,
"observe_javascript_runtime",
signal,
"V8 Inspector resources",
);
return result.ok ? result.value : cliError(result.error);
},
),
),
@@ -0,0 +1,290 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, expect, it, vi } from "vitest";
const fileAccess = vi.hoisted(() => ({
path: undefined as string | undefined,
extraPath: undefined as string | undefined,
controller: undefined as AbortController | undefined,
reason: undefined as unknown,
abortOnStatCall: undefined as number | undefined,
statCalls: 0,
afterAuthorization: undefined as (() => void) | undefined,
discoveryGate: undefined as
| { readonly started: () => void; readonly wait: Promise<void> }
| undefined,
calls: [] as string[],
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
stat: async (...args: Parameters<typeof actual.stat>) => {
const result = await actual.stat(...args);
if (
String(args[0]) === fileAccess.path ||
String(args[0]) === fileAccess.extraPath
) {
fileAccess.calls.push("stat");
fileAccess.statCalls += 1;
if (fileAccess.abortOnStatCall === fileAccess.statCalls)
fileAccess.controller?.abort(fileAccess.reason);
}
return result;
},
realpath: async (...args: Parameters<typeof actual.realpath>) => {
if (
String(args[0]) === fileAccess.path ||
String(args[0]) === fileAccess.extraPath
)
fileAccess.calls.push("realpath");
return await actual.realpath(...args);
},
};
});
vi.mock("../browser/CdpEndpoint.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../browser/CdpEndpoint.js")>();
return {
...actual,
discoverCdpEndpoint: async (
...args: Parameters<typeof actual.discoverCdpEndpoint>
) => {
const gate = fileAccess.discoveryGate;
if (gate !== undefined) {
fileAccess.discoveryGate = undefined;
gate.started();
await gate.wait;
}
return await actual.discoverCdpEndpoint(...args);
},
hasCdpTargetWebSocket: (
...args: Parameters<typeof actual.hasCdpTargetWebSocket>
) => {
const available = actual.hasCdpTargetWebSocket(...args);
fileAccess.afterAuthorization?.();
fileAccess.afterAuthorization = undefined;
return available;
},
};
});
import { CdpElectronProvider } from "../browser/CdpElectronProvider.js";
import { authorizedElectronFile } from "../browser/ElectronFileScope.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { listElectronTargetsInputSchema } from "../domain/javascript/electronObservation.js";
import { V8InspectorProvider } from "./V8InspectorProvider.js";
import { startFakeCdpBrowser } from "../../tests/fixtures/fakeCdpBrowser.js";
import { startFakeV8Inspector } from "../../tests/fixtures/inspector/fakeV8Inspector.js";
const roots: string[] = [];
const fixtures: { close(): Promise<void> }[] = [];
const record = (value: unknown): Record<string, unknown> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
afterEach(async () => {
fileAccess.path = undefined;
fileAccess.extraPath = undefined;
fileAccess.controller = undefined;
fileAccess.reason = undefined;
fileAccess.abortOnStatCall = undefined;
fileAccess.statCalls = 0;
fileAccess.afterAuthorization = undefined;
fileAccess.discoveryGate = undefined;
fileAccess.calls = [];
await Promise.all(fixtures.splice(0).map((fixture) => fixture.close()));
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
const targetFile = async (): Promise<string> => {
const root = await mkdtemp(join(tmpdir(), "rea-inspector-cancel-"));
roots.push(root);
const path = join(root, "entry.js");
await writeFile(path, "// inert target file\n");
fileAccess.path = path;
return path;
};
it("stops Inspector target authorization after the current stat settles", async () => {
const path = await targetFile();
const extraPath = join(roots[0]!, "second.js");
await writeFile(extraPath, "// second inert target file\n");
fileAccess.extraPath = extraPath;
const fake = await startFakeV8Inspector({
targetUrl: pathToFileURL(path).href,
additionalTargetUrl: pathToFileURL(extraPath).href,
additionalTargetCount: 1,
});
fixtures.push(fake);
const controller = new AbortController();
const reason = new Error("cancel after target stat");
fileAccess.reason = reason;
fileAccess.controller = controller;
fileAccess.abortOnStatCall = 2;
const result = await new V8InspectorProvider().listTargets(
{ inspector_endpoint: fake.endpoint },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected Inspector listing cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(result.error.diagnostics).toMatchObject({
completed_target_inventory: {
complete: false,
targets_examined: 1,
limitations: expect.arrayContaining([
"Cancellation stopped target authorization; unexamined targets are unknown, not excluded.",
]),
},
});
const inventory = record(
record(result.error.diagnostics)?.completed_target_inventory,
);
expect(inventory?.targets_discovered).toBeGreaterThan(0);
expect(inventory?.targets).toHaveLength(1);
expect(inventory?.targets_discovered).toBe(3);
expect(fileAccess.calls).toEqual(["stat", "realpath", "stat"]);
});
it("stops Electron target authorization after the current stat settles", async () => {
const path = await targetFile();
const fake = await startFakeCdpBrowser({
electronFileUrl: pathToFileURL(path).href,
additionalElectronPageCount: 2,
});
fixtures.push(fake);
const controller = new AbortController();
const reason = new Error("cancel after Electron target stat");
fileAccess.reason = reason;
fileAccess.controller = controller;
fileAccess.abortOnStatCall = 1;
const result = await new CdpElectronProvider().listTargets(
listElectronTargetsInputSchema.parse({ cdp_endpoint: fake.endpoint }),
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected Electron listing cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(result.error.diagnostics).toMatchObject({
completed_prefix: {
coverage:
"partial: target authorization stopped; remaining targets are unknown",
},
});
const prefix = record(record(result.error.diagnostics)?.completed_prefix);
const examined = prefix?.examined_targets;
const discovered = prefix?.discovered_targets;
expect(typeof examined).toBe("number");
expect(typeof discovered).toBe("number");
if (typeof examined !== "number" || typeof discovered !== "number")
throw new Error("Expected target-prefix coverage diagnostics");
expect(examined).toBeGreaterThan(0);
expect(discovered).toBeGreaterThan(examined);
expect(fileAccess.calls).toEqual(["stat"]);
});
it("retains a target whose authorization completes before the next abort check", async () => {
const path = await targetFile();
const fake = await startFakeCdpBrowser({
electronFileUrl: pathToFileURL(path).href,
additionalElectronPageCount: 2,
});
fixtures.push(fake);
const controller = new AbortController();
const reason = new Error("cancel after complete target authorization");
fileAccess.afterAuthorization = () => controller.abort(reason);
const result = await new CdpElectronProvider().listTargets(
listElectronTargetsInputSchema.parse({ cdp_endpoint: fake.endpoint }),
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected Electron listing cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
const prefix = record(record(result.error.diagnostics)?.completed_prefix);
const allowed = prefix?.allowed_targets;
expect(Array.isArray(allowed)).toBe(true);
if (!Array.isArray(allowed))
throw new Error("Expected completed target authorization fact");
expect(allowed).toHaveLength(1);
expect(fileAccess.afterAuthorization).toBeUndefined();
});
it("drains pending Electron discovery before shutdown cleanup completes", async () => {
const path = await targetFile();
const fake = await startFakeCdpBrowser({
electronFileUrl: pathToFileURL(path).href,
});
fixtures.push(fake);
let markStarted: () => void = () => undefined;
let releaseDiscovery: () => void = () => undefined;
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const discoveryWait = new Promise<void>((resolve) => {
releaseDiscovery = resolve;
});
fileAccess.discoveryGate = { started: markStarted, wait: discoveryWait };
const provider = new CdpElectronProvider();
const listing = provider.listTargets(
listElectronTargetsInputSchema.parse({ cdp_endpoint: fake.endpoint }),
);
await started;
let closed = false;
const closing = provider.close().then(() => {
closed = true;
});
const closedBeforeDiscovery = await Promise.race([
closing.then(() => true),
new Promise<boolean>((resolve) => setImmediate(() => resolve(false))),
]);
expect(closedBeforeDiscovery).toBe(false);
expect(closed).toBe(false);
releaseDiscovery();
const [listed] = await Promise.all([listing, closing]);
expect(closed).toBe(true);
expect(listed.ok).toBe(true);
expect(fileAccess.calls).toContain("stat");
const rejected = await provider.listTargets(
listElectronTargetsInputSchema.parse({ cdp_endpoint: fake.endpoint }),
);
expect(rejected.ok).toBe(false);
if (rejected.ok)
throw new Error("Expected closed provider admission failure");
expect(rejected.error.cleanupIncomplete).toBe(false);
expect(rejected.error.cleanupResources).toEqual([]);
});
it("keeps ordinary and malformed file-location authorization unchanged", async () => {
const path = await targetFile();
await expect(authorizedElectronFile(pathToFileURL(path).href)).resolves.toBe(
path,
);
await expect(
authorizedElectronFile("file://remote.example/share/entry.js"),
).resolves.toBeUndefined();
await expect(
authorizedElectronFile("file:///tmp/encoded%2fseparator.js"),
).resolves.toBeUndefined();
});
+19 -6
View File
@@ -22,9 +22,12 @@ export type RuntimeLocationDecision =
/** Throw the operation's typed cancellation error whenever its signal is aborted. */
export const throwIfRuntimeObservationCancelled = (
signal?: AbortSignal,
operation:
| "observe_javascript_runtime"
| "list_javascript_runtime_targets" = "observe_javascript_runtime",
): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError("observe_javascript_runtime");
throw new AnalysisCancelledError(operation, { cause: signal.reason });
};
/**
@@ -45,11 +48,14 @@ export const inspectorExclusionKey = (
export const authorizeRuntimeLocation = async (
value: string,
signal?: AbortSignal,
operation:
| "observe_javascript_runtime"
| "list_javascript_runtime_targets" = "observe_javascript_runtime",
): Promise<RuntimeLocationDecision> => {
throwIfRuntimeObservationCancelled(signal);
throwIfRuntimeObservationCancelled(signal, operation);
if (value.startsWith("file:")) {
const filePath = await authorizedElectronFile(value);
throwIfRuntimeObservationCancelled(signal);
const filePath = await authorizedElectronFile(value, signal, operation);
throwIfRuntimeObservationCancelled(signal, operation);
return filePath === undefined
? { allowed: false, reason: "not_approved" }
: { allowed: true, location: { kind: "file", file_path: filePath } };
@@ -93,6 +99,10 @@ export const projectReportedRuntimeUrl = (
export const authorizeRuntimeTargetLocation = async (
value: string,
context: { readonly type: string; readonly product: string },
signal?: AbortSignal,
operation:
| "observe_javascript_runtime"
| "list_javascript_runtime_targets" = "list_javascript_runtime_targets",
): Promise<
| {
readonly allowed: true;
@@ -100,15 +110,16 @@ export const authorizeRuntimeTargetLocation = async (
}
| { readonly allowed: false; readonly reason: BrowserExclusionReason }
> => {
throwIfRuntimeObservationCancelled(signal, operation);
if (
context.type !== "node" ||
!/^node\.js\/v/iu.test(context.product) ||
!value.startsWith("file://")
)
return authorizeRuntimeLocation(value);
return authorizeRuntimeLocation(value, signal, operation);
const reportedPath = value.slice("file://".length);
if (!reportedPath.startsWith("/") && !/^[a-z]:[/\\_]/iu.test(reportedPath))
return authorizeRuntimeLocation(value);
return authorizeRuntimeLocation(value, signal, operation);
// Node concatenates the pathname, then replaces quotes and backslashes with
// underscores. A remaining underscore cannot prove which pathname was used.
@@ -116,6 +127,8 @@ export const authorizeRuntimeTargetLocation = async (
if (reportedPath.startsWith("/")) {
const decision = await authorizeRuntimeLocation(
pathToFileURL(reportedPath).href,
signal,
operation,
);
if (decision.allowed) return decision;
}
@@ -1,93 +0,0 @@
import { expect, it } from "vitest";
import {
closeInspectorConnection,
inspectorCleanupError,
} from "./V8InspectorProvider.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js";
import { javascriptRuntimeObservationSchema } from "../domain/javascript/javascriptRuntimeObservation.js";
it("returns promptly on cancellation while a V8 socket close is hung", async () => {
const controller = new AbortController();
let closeStarted = false;
const closing = closeInspectorConnection(
{
close: () => {
closeStarted = true;
return new Promise<void>(() => undefined);
},
},
controller.signal,
);
controller.abort();
expect(closeStarted).toBe(true);
await expect(closing).resolves.toBeUndefined();
});
it("maps a rejected close to incomplete cleanup and preserves the primary failure", () => {
const primary = new Error("observation failed");
const cleanup = new Error("socket close failed");
const error = inspectorCleanupError(primary, cleanup, true);
expect(error.reason).toBe("cleanup_failed");
expect(error.cleanupIncomplete).toBe(true);
expect(error.cleanupResources).toEqual(["browser_transport"]);
expect(error.cause).toBeInstanceOf(AggregateError);
expect(error.cause).toMatchObject({ errors: [primary, cleanup] });
});
it("projects a completed runtime observation with the cleanup failure", () => {
const observation = javascriptRuntimeObservationSchema.parse({
runtime: {
product: "Node.js",
protocol_version: "1.3",
v8_version: "12.0",
},
target: {
target_id: "target-1",
protocol_type: "node",
attached: false,
location: { kind: "file", file_path: "/fixture.js" },
runtime_kind: "node",
runtime_kind_authority: "caller-declared-unverified",
},
capture: {
observation_ms: 0,
events_observed: 0,
events_retained: 0,
events_dropped: 0,
metadata_bytes_retained: 0,
truncated: false,
truncation_reasons: [],
},
scripts: {
items: [],
observed_total: 0,
excluded: { unsupported_location: 0, invalid_protocol_value: 0 },
},
execution_contexts: [],
directly_observed: ["Inspector target attached and queried."],
unavailable_without_instrumentation: [],
unknowns: [],
limitations: [],
});
const primary = new Error("observation failed");
const cleanup = new Error("socket close failed");
const error = inspectorCleanupError(primary, cleanup, true, observation);
const cause = error.cause;
expect(cause).toBeInstanceOf(AggregateError);
expect(cause).toMatchObject({ errors: [primary, cleanup] });
const projected = projectAnalysisError(error);
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
details: {
cleanup_reason: "socket close failed",
resources: ["browser_transport"],
partial_observation: observation,
},
});
});
@@ -0,0 +1,237 @@
import { createServer } from "node:http";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { expect, it, onTestFinished, vi } from "vitest";
import { WebSocketServer } from "ws";
import { CdpConnection } from "../browser/CdpConnection.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { observeJavaScriptRuntime } from "../application/javascript/JavaScriptRuntimeObservationService.js";
import { observeJavaScriptRuntimeInputSchema } from "../domain/javascript/javascriptRuntimeObservation.js";
import { V8InspectorProvider } from "./V8InspectorProvider.js";
const inspectorCloseFixture = async () => {
const root = await mkdtemp(join(tmpdir(), "rea-inspector-close-owner-"));
const scriptPath = join(root, "target.js");
await writeFile(scriptPath, "globalThis.fixture = true;\n");
const scriptUrl = pathToFileURL(scriptPath).href;
let endpoint = "";
const server = createServer((request, response) => {
response.setHeader("content-type", "application/json");
if (request.url === "/json/version")
response.end(
JSON.stringify({
Browser: "Node.js/v24.18.0",
"Protocol-Version": "1.3",
"V8-Version": "fixture",
}),
);
else if (request.url === "/json/list")
response.end(
JSON.stringify([
{
id: "fixture",
type: "node",
url: scriptUrl,
attached: false,
webSocketDebuggerUrl: endpoint.replace("http:", "ws:") + "/fixture",
},
]),
);
else response.writeHead(404).end();
});
const sockets = new WebSocketServer({ server, path: "/fixture" });
let acceptedConnections = 0;
const connectionWaiters = new Map<number, () => void>();
sockets.on("connection", (socket) => {
acceptedConnections += 1;
connectionWaiters.get(acceptedConnections)?.();
socket.on("message", (raw) => {
const command = JSON.parse(raw.toString()) as { id: number };
socket.send(JSON.stringify({ id: command.id, result: {} }));
});
});
const waitForConnections = (count: number): Promise<void> =>
acceptedConnections >= count
? Promise.resolve()
: new Promise<void>((resolve) => connectionWaiters.set(count, resolve));
let resolveCloseStarted: (() => void) | undefined;
const closeStarted = new Promise<void>((resolve) => {
resolveCloseStarted = resolve;
});
let rejectFirstClose: ((cause: Error) => void) | undefined;
let closeCalls = 0;
const closeFailures = new Set<number>([2]);
const firstClose = new Promise<void>((_resolve, reject) => {
rejectFirstClose = reject;
});
const originalClose = CdpConnection.prototype.close;
const closeSpy = vi
.spyOn(CdpConnection.prototype, "close")
.mockImplementation(function (this: CdpConnection): Promise<void> {
closeCalls += 1;
if (closeCalls === 1) {
resolveCloseStarted?.();
return firstClose;
}
if (closeFailures.delete(closeCalls))
return Promise.reject(new Error("controlled retry remains pending"));
return originalClose.call(this);
});
onTestFinished(async () => {
closeSpy.mockRestore();
if (closeCalls > 0) rejectFirstClose?.(new Error("fixture finalization"));
await new V8InspectorProvider().close();
for (const socket of sockets.clients) socket.terminate();
await new Promise<void>((resolve) => sockets.close(() => resolve()));
if (server.listening)
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Inspector fixture did not bind a TCP port");
endpoint = `http://127.0.0.1:${String(address.port)}`;
const input = observeJavaScriptRuntimeInputSchema.parse({
inspector_endpoint: endpoint,
target_id: "fixture",
observation_ms: 0,
});
return {
input,
endpoint,
closeStarted,
closeFailures,
closeSpy,
waitForConnections,
rejectFirstClose: (cause: Error) => rejectFirstClose?.(cause),
get acceptedConnections() {
return acceptedConnections;
},
};
};
it("retains cancelled final-close ownership and retries the exact transport before replacement observation", async () => {
const fixture = await inspectorCloseFixture();
const {
input,
endpoint,
closeStarted,
closeFailures,
closeSpy,
waitForConnections,
rejectFirstClose,
} = fixture;
const controller = new AbortController();
const cancellationReason = new Error("cancelled during transport close");
const observing = observeJavaScriptRuntime(new V8InspectorProvider(), input, {
signal: controller.signal,
});
await closeStarted;
controller.abort(cancellationReason);
let firstObservationSettled = false;
void observing.finally(() => {
firstObservationSettled = true;
});
await Promise.resolve();
expect(firstObservationSettled).toBe(false);
rejectFirstClose?.(new Error("controlled final socket close failure"));
const result = await observing;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cleanup-incomplete cancellation");
const error = result.error;
expect(error.cleanupIncomplete).toBe(true);
expect(error.cleanupResources).toEqual(["browser_transport"]);
expect(error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(error.cleanupPrimary?.cause).toBe(cancellationReason);
expect(error.partialObservation).toMatchObject({
predicate_type: "rea.javascript-runtime-observation",
operation: "observe_javascript_runtime",
normalized_result: {
target: { target_id: "fixture" },
},
});
expect(projectAnalysisError(error).code).toBe("cleanup_incomplete");
const blocked = await observeJavaScriptRuntime(
new V8InspectorProvider(),
input,
);
expect(blocked.ok).toBe(false);
if (blocked.ok)
throw new Error("Expected unresolved transport admission refusal");
expect(blocked.error.cleanupIncomplete).toBe(true);
expect(fixture.acceptedConnections).toBe(1);
const recovered = await observeJavaScriptRuntime(
new V8InspectorProvider(),
input,
);
expect(recovered.ok).toBe(true);
expect(error.cleanupIncomplete).toBe(false);
expect(error.cleanupResources).toEqual([]);
expect(fixture.acceptedConnections).toBe(2);
closeFailures.add(5);
const cleanupOnly = await observeJavaScriptRuntime(
new V8InspectorProvider(),
input,
);
expect(cleanupOnly.ok).toBe(false);
if (cleanupOnly.ok) throw new Error("Expected close-only cleanup failure");
expect(cleanupOnly.error.cleanupIncomplete).toBe(true);
expect(cleanupOnly.error.cleanupResources).toEqual(["browser_transport"]);
expect(cleanupOnly.error.partialObservation).toMatchObject({
predicate_type: "rea.javascript-runtime-observation",
operation: "observe_javascript_runtime",
});
expect(fixture.acceptedConnections).toBe(3);
const activeInput = observeJavaScriptRuntimeInputSchema.parse({
inspector_endpoint: endpoint,
target_id: "fixture",
observation_ms: 30_000,
});
const activeController = new AbortController();
const activeObservation = observeJavaScriptRuntime(
new V8InspectorProvider(),
activeInput,
{ signal: activeController.signal },
);
try {
await waitForConnections(4);
let shutdownSettled = false;
const shutdown = new V8InspectorProvider().close().then(() => {
shutdownSettled = true;
});
const queuedObservation = observeJavaScriptRuntime(
new V8InspectorProvider(),
input,
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(shutdownSettled).toBe(false);
expect(fixture.acceptedConnections).toBe(4);
expect(cleanupOnly.error.cleanupIncomplete).toBe(false);
expect(cleanupOnly.error.cleanupResources).toEqual([]);
activeController.abort(new Error("finish the admitted observation"));
const activeResult = await activeObservation;
expect(activeResult.ok).toBe(false);
await shutdown;
expect(shutdownSettled).toBe(true);
await waitForConnections(5);
const queuedResult = await queuedObservation;
expect(queuedResult.ok).toBe(true);
expect(fixture.acceptedConnections).toBe(5);
expect(closeSpy).toHaveBeenCalledTimes(8);
} finally {
activeController.abort(new Error("fixture finalization"));
}
});
@@ -9,6 +9,7 @@ import { WebSocketServer } from "ws";
import { z } from "zod";
import { safeParseJson } from "../domain/safeJson.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { V8InspectorProvider } from "./V8InspectorProvider.js";
const failureCases = [
@@ -184,29 +185,34 @@ it.each(failureCases)(
expect(result.ok).toBe(false);
if (result.ok)
throw new Error("Expected the Inspector operation to fail");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason,
partialObservation: {
capture: { events_observed: 2 },
scripts: {
observed_total: 1,
items: [
{
location: {
kind: "unresolved",
reported_url: reportedScriptUrl,
reason: "location-authorization-not-attempted",
},
cdp_hash: "captured-before-failure",
length: 24,
if (failure === "cancellation") {
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(controller.signal.reason);
} else {
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason,
});
}
expect(result.error.partialObservation).toMatchObject({
capture: { events_observed: 2 },
scripts: {
observed_total: 1,
items: [
{
location: {
kind: "unresolved",
reported_url: reportedScriptUrl,
reason: "location-authorization-not-attempted",
},
],
},
execution_contexts: [
{ context_key: "1", name: "main", origin: reportedScriptUrl },
cdp_hash: "captured-before-failure",
length: 24,
},
],
},
execution_contexts: [
{ context_key: "1", name: "main", origin: reportedScriptUrl },
],
});
} finally {
vi.restoreAllMocks();
+276 -125
View File
@@ -12,9 +12,11 @@ import {
type ObserveJavaScriptRuntimeInput,
} from "../domain/javascript/javascriptRuntimeObservation.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import type { AnalysisPartialObservation } from "../domain/analysisErrorBase.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -24,10 +26,19 @@ import {
scriptMetadataValues,
delayWithCancellation,
} from "../browser/CdpCaptureValues.js";
import { CdpConnection, type CdpEvent } from "../browser/CdpConnection.js";
import type { CdpConnection, CdpEvent } from "../browser/CdpConnection.js";
import {
mapElectronFileAuthorizationFailure,
retryElectronFileCleanup,
} from "../browser/ElectronFileScope.js";
import {
CdpTargetSessionOwner,
bindCdpTransportCleanup,
} from "../browser/CdpTargetSessionOwner.js";
import {
authorizeRuntimeTargetLocation,
inspectorExclusionKey,
throwIfRuntimeObservationCancelled,
} from "./JavaScriptRuntimeScope.js";
import {
createInspectorExclusionCounts,
@@ -42,6 +53,78 @@ import {
import { V8_INSPECTOR_PROVIDER_IDENTITY } from "./providerIdentity.js";
// Providers are created per application operation, so exact transport cleanup
// ownership must survive their return and gate the next Inspector connection.
const inspectorConnections = new CdpTargetSessionOwner();
let inspectorActiveOperations = 0;
let inspectorClosing = false;
let inspectorClosePromise: Promise<void> | undefined;
const inspectorIdleWaiters = new Set<() => void>();
const withInspectorAdmission = async <Value>(
operation: BrowserObservationOperation,
work: () => Promise<Value>,
): Promise<Value> => {
if (inspectorClosing && inspectorClosePromise !== undefined)
await inspectorClosePromise;
if (inspectorClosing)
throw new ProviderAdapterError(
V8_INSPECTOR_PROVIDER_IDENTITY.id,
operation,
{
diagnostics: { reason: "provider_closing" },
userMessage: "Inspector provider cleanup is in progress.",
},
);
inspectorActiveOperations += 1;
try {
return await work();
} finally {
inspectorActiveOperations -= 1;
if (inspectorActiveOperations === 0) {
for (const resolve of inspectorIdleWaiters) resolve();
inspectorIdleWaiters.clear();
}
}
};
const closeInspectorResources = async (): Promise<void> => {
inspectorClosing = true;
try {
while (inspectorActiveOperations > 0)
await new Promise<void>((resolve) => inspectorIdleWaiters.add(resolve));
const failures: AnalysisError[] = [];
try {
await inspectorConnections.retryPending();
} catch (cause: unknown) {
failures.push(providerError(cause, "observe_javascript_runtime"));
}
try {
await retryElectronFileCleanup();
} catch (cause: unknown) {
failures.push(providerError(cause, "observe_javascript_runtime"));
}
if (failures.length === 0) return;
if (failures.length === 1) throw failures[0]!;
const cleanup = new ProviderCleanupError(
V8_INSPECTOR_PROVIDER_IDENTITY.id,
failures.flatMap(({ cleanupResources }) => cleanupResources),
{ reason: "Inspector provider cleanup remains incomplete." },
{
operation: "observe_javascript_runtime",
cause: new AggregateError(
failures,
"Inspector provider cleanup owners remain unresolved.",
),
},
);
cleanup.bindCleanupDependencies(failures);
throw cleanup;
} finally {
inspectorClosing = false;
}
};
/** Maximum decoded CDP message accepted while observing one Inspector target. */
export const INSPECTOR_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
/** Aggregate retained metadata budget, including an estimate of JS object overhead. */
@@ -83,34 +166,75 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
return V8_INSPECTOR_PROVIDER_IDENTITY;
}
async close(): Promise<void> {
if (inspectorClosePromise !== undefined) return await inspectorClosePromise;
const attempt = closeInspectorResources();
inspectorClosePromise = attempt;
try {
await attempt;
} finally {
if (inspectorClosePromise === attempt) inspectorClosePromise = undefined;
}
}
async listTargets(
input: ListJavaScriptRuntimeTargetsInput,
options: ExecutionOptions = {},
): Promise<Result<JavaScriptRuntimeTargetList, AnalysisError>> {
try {
const discovery = await discoverV8Inspector(
return await withInspectorAdmission(
"list_javascript_runtime_targets",
() => this.#listTargets(input, options),
);
} catch (cause: unknown) {
return err(providerError(cause, "list_javascript_runtime_targets"));
}
}
async #listTargets(
input: ListJavaScriptRuntimeTargetsInput,
options: ExecutionOptions,
): Promise<Result<JavaScriptRuntimeTargetList, AnalysisError>> {
let discovery: Awaited<ReturnType<typeof discoverV8Inspector>> | undefined;
const allowed: AuthorizedV8InspectorTarget[] = [];
const excluded = createInspectorExclusionCounts();
let unconnectable = 0;
let targetsExamined = 0;
try {
throwIfRuntimeObservationCancelled(
options.signal,
"list_javascript_runtime_targets",
);
await retryElectronFileCleanup();
discovery = await discoverV8Inspector(
input.inspector_endpoint,
"list_javascript_runtime_targets",
options.signal,
);
const allowed: AuthorizedV8InspectorTarget[] = [];
const excluded = createInspectorExclusionCounts();
let unconnectable = 0;
for (const target of discovery.targets) {
const decision = await authorizeRuntimeTargetLocation(target.url, {
type: target.type,
product: discovery.runtime.product,
});
throwIfRuntimeObservationCancelled(
options.signal,
"list_javascript_runtime_targets",
);
const decision = await authorizeRuntimeTargetLocation(
target.url,
{ type: target.type, product: discovery.runtime.product },
options.signal,
"list_javascript_runtime_targets",
);
if (!decision.allowed) {
excluded[inspectorExclusionKey(decision.reason)] += 1;
targetsExamined += 1;
continue;
}
if (decision.location.kind === "builtin") {
excluded.unsupported_location += 1;
targetsExamined += 1;
continue;
}
if (target.webSocketUrl === null) {
unconnectable += 1;
targetsExamined += 1;
continue;
}
allowed.push({
@@ -121,10 +245,15 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
webSocketUrl: target.webSocketUrl,
location: decision.location,
});
targetsExamined += 1;
}
allowed.sort((left, right) =>
left.id < right.id ? -1 : left.id > right.id ? 1 : 0,
);
throwIfRuntimeObservationCancelled(
options.signal,
"list_javascript_runtime_targets",
);
return ok(
javascriptRuntimeTargetListSchema.parse({
runtime: discovery.runtime,
@@ -136,6 +265,37 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
}),
);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError && discovery !== undefined) {
const complete = targetsExamined === discovery.targets.length;
const limitations = describeInspectorTargetLimitations(
allowed.some((target) => target.attached === null),
);
return err(
new AnalysisCancelledError("list_javascript_runtime_targets", {
cause: cause.cause,
diagnostics: {
completed_target_inventory: {
runtime: discovery.runtime,
targets: allowed
.sort((left, right) =>
left.id < right.id ? -1 : left.id > right.id ? 1 : 0,
)
.map(projectTarget),
excluded: { ...excluded, unconnectable },
targets_examined: targetsExamined,
targets_discovered: discovery.targets.length,
complete,
limitations: complete
? limitations
: [
...limitations,
"Cancellation stopped target authorization; unexamined targets are unknown, not excluded.",
],
},
},
}),
);
}
return err(providerError(cause, "list_javascript_runtime_targets"));
}
}
@@ -143,13 +303,24 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
async observe(
input: ObserveJavaScriptRuntimeInput,
options: ExecutionOptions = {},
): Promise<Result<JavaScriptRuntimeObservation, AnalysisError>> {
try {
return await withInspectorAdmission("observe_javascript_runtime", () =>
this.#observe(input, options),
);
} catch (cause: unknown) {
return err(providerError(cause, "observe_javascript_runtime"));
}
}
async #observe(
input: ObserveJavaScriptRuntimeInput,
options: ExecutionOptions,
): Promise<Result<JavaScriptRuntimeObservation, AnalysisError>> {
let connection: CdpConnection | undefined;
let discovery: Awaited<ReturnType<typeof discoverV8Inspector>> | undefined;
let target: AuthorizedV8InspectorTarget | undefined;
let state: CaptureState | undefined;
let primaryFailure: unknown;
let failed = false;
let cleanupFailure: unknown;
let cleanupFailed = false;
let connectionClosePromise: Promise<void> | undefined;
@@ -158,13 +329,14 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
| undefined;
const closeConnection = (): Promise<void> => {
if (connection === undefined) return Promise.resolve();
connectionClosePromise ??= closeInspectorConnection(
connection,
options.signal,
);
const ownedConnection = connection;
connectionClosePromise ??= inspectorConnections.finish(ownedConnection);
return connectionClosePromise;
};
try {
throwIfRuntimeObservationCancelled(options.signal);
await inspectorConnections.retryPending();
await retryElectronFileCleanup();
discovery = await discoverV8Inspector(
input.inspector_endpoint,
"observe_javascript_runtime",
@@ -174,13 +346,16 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
discovery.targets,
input,
discovery.runtime.product,
options.signal,
);
if (input.runtime_kind !== undefined)
assertRuntimeKind(target, input.runtime_kind);
connection = await CdpConnection.connect(
throwIfRuntimeObservationCancelled(options.signal);
connection = await inspectorConnections.connect(
target.webSocketUrl,
"observe_javascript_runtime",
options.signal,
options,
"browser_transport",
{ maxPayloadBytes: INSPECTOR_MAX_PAYLOAD_BYTES },
);
const captureState = emptyCaptureState();
@@ -204,28 +379,14 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
});
outcome = ok(javascriptRuntimeObservationSchema.parse(result));
} catch (cause: unknown) {
failed = true;
primaryFailure = cause;
let error = providerError(cause, "observe_javascript_runtime");
if (
state !== undefined &&
discovery !== undefined &&
target !== undefined &&
(cause instanceof BrowserObservationError ||
options.signal?.aborted === true)
cause instanceof AnalysisCancelledError)
) {
const reason =
options.signal?.aborted === true
? "cancelled"
: cause instanceof BrowserObservationError
? cause.reason
: "protocol_error";
try {
await closeConnection();
} catch (cause: unknown) {
cleanupFailed = true;
cleanupFailure = cause;
}
try {
const partialObservation = await finalizeInspectorCapture({
input,
@@ -234,29 +395,35 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
state,
locationMode: "reported",
});
error = new BrowserObservationError(
"observe_javascript_runtime",
reason,
{
cause: error,
...(error.userMessage === undefined
? {}
: { detail: error.userMessage }),
partialObservation,
},
);
if (cause instanceof AnalysisCancelledError) {
cause.retainPartialObservation(partialObservation);
error = cause;
} else {
error = new BrowserObservationError(
"observe_javascript_runtime",
cause.reason,
{
cause: error,
...(error.userMessage === undefined
? {}
: { detail: error.userMessage }),
partialObservation,
},
);
}
} catch (projectionFailure: unknown) {
error = new BrowserObservationError(
"observe_javascript_runtime",
reason,
{
cause: new AggregateError(
[error, projectionFailure],
"Inspector failure and partial-observation projection both failed",
),
detail: `${error.message} Partial observations could not be projected.`,
},
);
if (!(cause instanceof AnalysisCancelledError))
error = new ProviderAdapterError(
V8_INSPECTOR_PROVIDER_IDENTITY.id,
"observe_javascript_runtime",
{
cause: new AggregateError(
[error, projectionFailure],
"Inspector failure and partial-observation projection both failed",
),
userMessage: `${error.message} Partial observations could not be projected.`,
},
);
}
}
outcome = err(error);
@@ -269,19 +436,42 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
cleanupFailure = cause;
}
}
if (cleanupFailed) {
return err(
inspectorCleanupError(
primaryFailure,
cleanupFailure,
failed,
outcome?.ok === true
? outcome.value
: outcome?.ok === false
? outcome.error.partialObservation
: undefined,
),
// Cancellation can arrive after capture has completed while the owner is
// waiting for its bounded transport close. Preserve it as the primary
// result even when cleanup completed normally.
if (options.signal?.aborted === true && outcome?.ok === true) {
const cancellation = new AnalysisCancelledError(
"observe_javascript_runtime",
{
cause: options.signal.reason,
partialObservation: outcome.value,
},
);
outcome = err(cancellation);
}
if (cleanupFailed) {
const cleanup =
cleanupFailure instanceof AnalysisError
? cleanupFailure
: providerError(cleanupFailure, "observe_javascript_runtime");
const primary = outcome?.ok === false ? outcome.error : undefined;
const partialObservation =
outcome?.ok === true
? outcome.value
: outcome?.ok === false
? outcome.error.partialObservation
: undefined;
if (primary === undefined) {
if (partialObservation !== undefined)
cleanup.retainPartialObservation(partialObservation);
return err(bindCdpTransportCleanup(cleanupFailure, cleanup));
}
const composed = analysisErrorWithCleanupFailure(
primary,
cleanup,
"observe_javascript_runtime",
);
return err(bindCdpTransportCleanup(cleanupFailure, composed));
}
return (
outcome ??
@@ -295,60 +485,6 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
}
}
export const closeInspectorConnection = async (
connection: Pick<CdpConnection, "close">,
signal?: AbortSignal,
): Promise<void> => {
const closing = connection.close();
// best-effort cleanup: the race below observes the close; this only prevents
// unhandled rejection when the caller abandons the close via cancellation.
void closing.catch(() => undefined);
// CdpConnection.close has its own one second transport bound. Do not make a
// cancelled caller wait for that fallback.
if (signal === undefined) return await closing;
let onAbort: (() => void) | undefined;
const cancelled = new Promise<void>((resolve) => {
onAbort = () => resolve();
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
});
try {
await Promise.race([closing, cancelled]);
} finally {
if (onAbort !== undefined) signal.removeEventListener("abort", onAbort);
}
};
export const inspectorCleanupError = (
primaryFailure: unknown,
cleanupFailure: unknown,
hasPrimaryFailure: boolean,
partialObservation?: AnalysisPartialObservation,
): BrowserObservationError => {
const cause = hasPrimaryFailure
? new AggregateError(
[primaryFailure, cleanupFailure],
"Inspector observation and cleanup both failed",
)
: cleanupFailure;
const cleanupReason =
cleanupFailure instanceof Error
? cleanupFailure.message
: String(cleanupFailure);
return new BrowserObservationError(
"observe_javascript_runtime",
"cleanup_failed",
{
cause,
cleanup: {
reason: cleanupReason,
resources: ["browser_transport"],
},
...(partialObservation === undefined ? {} : { partialObservation }),
},
);
};
const projectTarget = (target: AuthorizedV8InspectorTarget) => ({
target_id: target.id,
protocol_type: target.type,
@@ -360,17 +496,22 @@ const authorizedTarget = async (
targets: readonly V8InspectorTarget[],
input: ObserveJavaScriptRuntimeInput,
product: string,
signal?: AbortSignal,
): Promise<AuthorizedV8InspectorTarget> => {
throwIfRuntimeObservationCancelled(signal);
const target = targets.find(({ id }) => id === input.target_id);
if (target === undefined)
throw new BrowserObservationError(
"observe_javascript_runtime",
"target_not_found",
);
const decision = await authorizeRuntimeTargetLocation(target.url, {
type: target.type,
product,
});
const decision = await authorizeRuntimeTargetLocation(
target.url,
{ type: target.type, product },
signal,
"observe_javascript_runtime",
);
throwIfRuntimeObservationCancelled(signal);
if (
target.attached === true ||
!decision.allowed ||
@@ -600,6 +741,16 @@ const providerError = (
cause: unknown,
operation: BrowserObservationOperation,
): AnalysisError => {
const fileFailure =
operation === "observe_javascript_runtime" ||
operation === "list_javascript_runtime_targets"
? mapElectronFileAuthorizationFailure(
cause,
V8_INSPECTOR_PROVIDER_IDENTITY.id,
operation,
)
: undefined;
if (fileFailure !== undefined) return fileFailure;
if (cause instanceof AnalysisError) return cause;
return new ProviderAdapterError(
V8_INSPECTOR_PROVIDER_IDENTITY.id,
+2
View File
@@ -405,6 +405,8 @@ export const createServer = (
options.browserScenarioCapture?.close?.() ?? Promise.resolve(),
options.electronObservation?.close?.() ?? Promise.resolve(),
options.electronActiveObservation?.close?.() ?? Promise.resolve(),
options.javascriptRuntimeObservation?.close?.() ??
Promise.resolve(),
]),
webRuntime.provider.close?.() ?? Promise.resolve(),
artifactResources.close(),
@@ -4,13 +4,31 @@ import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { afterEach, describe, expect, test } from "vitest";
import { Cli } from "incur";
import { afterEach, describe, expect, test, vi } from "vitest";
import { observeJavaScriptRuntime } from "../../../src/application/javascript/JavaScriptRuntimeObservationService.js";
import type { JavaScriptRuntimeObservationPort } from "../../../src/application/javascript/JavaScriptRuntimeObservationPort.js";
import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider.js";
import { observeJavaScriptRuntimeInputSchema } from "../../../src/domain/javascript/javascriptRuntimeObservation.js";
import { startFakeV8Inspector } from "../../fixtures/inspector/fakeV8Inspector.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { registerJavaScriptRuntimeObservationCommands } from "../../../src/cliJavaScriptRuntimeCommands.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { ok } from "../../../src/domain/result.js";
import { silentLogger } from "../../../src/logger.js";
const runtimeFactory = vi.hoisted(() => ({
provider: undefined as JavaScriptRuntimeObservationPort | undefined,
}));
vi.mock("../../../src/composition/javascriptRuntimeObservation.js", () => ({
createJavaScriptRuntimeObservationProvider: () => {
if (runtimeFactory.provider === undefined)
throw new Error("No runtime observation provider was configured");
return runtimeFactory.provider;
},
}));
const execute = promisify(execFile);
const TIMEOUT_MS = 20_000;
@@ -20,6 +38,7 @@ describe("JavaScript runtime observation CLI parity", () => {
const temporary: string[] = [];
afterEach(async () => {
runtimeFactory.provider = undefined;
await Promise.all(resources.splice(0).map((item) => item.close()));
await Promise.all(
temporary
@@ -95,6 +114,62 @@ describe("JavaScript runtime observation CLI parity", () => {
},
TIMEOUT_MS,
);
test("waits for provider cleanup and retains completed Evidence on failure", async () => {
let closeAttempts = 0;
runtimeFactory.provider = {
identity: () => ({
id: "rea-v8-inspector",
name: "V8 Inspector",
version: "1",
}),
listTargets: async () =>
ok({
runtime: {
product: "node.js/v24",
protocol_version: "1.3",
v8_version: "13.0",
},
targets: [],
excluded: { unsupported_location: 0, unconnectable: 0 },
limitations: [],
}),
observe: async () => {
throw new Error("No runtime observation requested");
},
close: async () => {
closeAttempts += 1;
throw new ProviderCleanupError(
"rea-v8-inspector",
["browser_transport"],
{ reason: "Inspector transport remains retained" },
{ operation: "list_javascript_runtime_targets" },
);
},
};
const cli = Cli.create("rea-runtime-cleanup-test", { sync: false });
registerJavaScriptRuntimeObservationCommands(cli, silentLogger);
let stdout = "";
await cli.serve(
["list-javascript-runtime-targets", "http://127.0.0.1:9229", "--json"],
{ exit: () => {}, stdout: (chunk) => (stdout += chunk) },
);
expect(closeAttempts).toBe(1);
expect(JSON.parse(stdout)).toMatchObject({
code: "cleanup_incomplete",
details: {
operation: "list_javascript_runtime_targets",
partial_observation: {
operation: "list_javascript_runtime_targets",
normalized_result: {
runtime: { product: "node.js/v24" },
targets: [],
},
},
},
});
});
});
const runCli = async (
@@ -37,6 +37,8 @@ import { createDeferred } from "../../fixtures/binarySession.js";
import { GoBinaryService } from "../../../src/application/go/GoBinaryService.js";
import { WebNetworkCaptureService } from "../../../src/application/WebNetworkCaptureService.js";
import { AnalysisUnsupportedTargetError } from "../../../src/domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import type { JavaScriptRuntimeObservationPort } from "../../../src/application/javascript/JavaScriptRuntimeObservationPort.js";
const resources: Array<{ close(): Promise<void> }> = [];
@@ -423,6 +425,95 @@ it("closes the bound Go and web-capture services and retries failed shutdown", a
}
});
it("closes the Inspector owner with peers and retries its retained cleanup", async () => {
let inspectorCloseAttempts = 0;
let peerCloseAttempts = 0;
const cleanupFailure = new ProviderCleanupError(
"rea-v8-inspector",
["browser_transport"],
{ reason: "Inspector transport close is still pending" },
{ operation: "observe_javascript_runtime" },
);
const runtime: JavaScriptRuntimeObservationPort = {
identity: () => ({
id: "rea-v8-inspector",
name: "V8 Inspector",
version: "1",
}),
listTargets: async () => {
throw new Error("No target listing requested");
},
observe: async () => {
throw new Error("No runtime observation requested");
},
close: async () => {
inspectorCloseAttempts += 1;
if (inspectorCloseAttempts === 1) throw cleanupFailure;
},
};
const server = createServer(
{ kind: "fixed", analysis: { execute: async () => ok(null) } },
{
javascriptRuntimeObservation: runtime,
browserScenarioCapture: {
identity: () => ({
id: "scenario-fixture",
name: "Scenario fixture",
version: "1",
}),
captureScenario: async () => {
throw new Error("No scenario capture requested");
},
close: async () => {
peerCloseAttempts += 1;
},
},
},
);
resources.push(server);
await expect(server.close()).rejects.toMatchObject({
errors: expect.arrayContaining([cleanupFailure]),
});
expect(inspectorCloseAttempts).toBe(1);
expect(peerCloseAttempts).toBe(1);
await server.close();
expect(inspectorCloseAttempts).toBe(2);
expect(peerCloseAttempts).toBe(2);
});
it("does not close a caller-owned Inspector observation provider", async () => {
let closeAttempts = 0;
const runtime: JavaScriptRuntimeObservationPort = {
identity: () => ({
id: "rea-v8-inspector",
name: "V8 Inspector",
version: "1",
}),
listTargets: async () => {
throw new Error("No target listing requested");
},
observe: async () => {
throw new Error("No runtime observation requested");
},
close: async () => {
closeAttempts += 1;
},
};
const server = createServer(
{ kind: "fixed", analysis: { execute: async () => ok(null) } },
{
javascriptRuntimeObservation: runtime,
optionalObservationProviderOwner: "caller",
},
);
resources.push(server);
await server.close();
expect(closeAttempts).toBe(0);
});
afterEach(async () => {
await Promise.all(
resources.splice(0).map(async (resource) => resource.close()),