mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(runtime): retain Inspector and Electron cleanup ownership
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user