fix(inspector): retain usable targets and cancel owned capture waits

This commit is contained in:
morluto 2026-10-14 09:47:57 +08:00
parent 29a2d13994
commit 55778f20ba
4 changed files with 265 additions and 106 deletions

View File

@ -0,0 +1,69 @@
import { createServer } from "node:http";
import { expect, it } from "vitest";
import { V8InspectorProvider } from "./V8InspectorProvider.js";
it("keeps connectable siblings when a target omits its direct socket", async () => {
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",
}),
);
return;
}
if (request.url === "/json/list") {
const address = server.address();
if (address === null || typeof address === "string") {
response.writeHead(500).end();
return;
}
response.end(
JSON.stringify([
{
id: "connectable",
type: "node",
url: "https://example.test/entry.js",
webSocketDebuggerUrl: `ws://127.0.0.1:${String(address.port)}/connectable`,
},
{
id: "anonymous",
type: "node",
url: "https://example.test/eval.js",
},
]),
);
return;
}
response.writeHead(404).end();
});
try {
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");
const result = await new V8InspectorProvider().listTargets({
inspector_endpoint: `http://127.0.0.1:${String(address.port)}`,
});
if (!result.ok)
throw new Error(result.error.message, { cause: result.error });
expect(result).toMatchObject({
ok: true,
value: {
targets: [{ target_id: "connectable" }],
excluded: { unconnectable: 1 },
},
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

View File

@ -23,7 +23,10 @@ const targetSchema = z
type: z.string().min(1),
url: z.string(),
attached: z.boolean().nullable().optional(),
webSocketDebuggerUrl: z.string().min(1),
// Node may publish anonymous/evaluated targets without a direct socket.
// Keep malformed values local to their target so one row cannot discard
// otherwise usable siblings from the endpoint inventory.
webSocketDebuggerUrl: z.unknown().optional(),
})
.passthrough();
const targetsSchema = z.array(targetSchema);
@ -33,7 +36,7 @@ export interface V8InspectorTarget {
readonly type: string;
readonly url: string;
readonly attached: boolean | null;
readonly webSocketUrl: string;
readonly webSocketUrl: string | null;
}
export interface V8InspectorDiscovery {
@ -68,7 +71,6 @@ export const discoverV8Inspector = async (
endpoint,
target.id,
target.webSocketDebuggerUrl,
operation,
),
})),
};
@ -77,16 +79,14 @@ export const discoverV8Inspector = async (
const validatedInspectorWebSocket = (
endpoint: string,
targetId: string,
reported: string,
operation: BrowserObservationOperation,
): string => {
reported: unknown,
): string | null => {
if (typeof reported !== "string" || reported.length === 0) return null;
let socket: URL;
try {
socket = new URL(reported);
} catch (cause: unknown) {
throw new BrowserObservationError(operation, "invalid_endpoint_response", {
cause,
});
} catch {
return null;
}
const trusted = new URL(endpoint);
const allowedPaths = new Set([
@ -102,12 +102,16 @@ const validatedInspectorWebSocket = (
urlDeclaresQueryOrFragment(socket) ||
!allowedPaths.has(socket.pathname)
)
throw new BrowserObservationError(operation, "invalid_endpoint_response");
return null;
socket.hostname = trusted.hostname;
return socket.href;
};
/** Authorized target plus its durable location. */
export interface AuthorizedV8InspectorTarget extends V8InspectorTarget {
export interface AuthorizedV8InspectorTarget extends Omit<
V8InspectorTarget,
"webSocketUrl"
> {
readonly webSocketUrl: string;
readonly location: JavaScriptRuntimeTargetLocation;
}

View File

@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { expect, it } from "vitest";
import { expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import { z } from "zod";
@ -14,9 +14,116 @@ import { V8InspectorProvider } from "./V8InspectorProvider.js";
const failureCases = [
["disconnect", "disconnected"],
["protocol failure", "protocol_error"],
["idle protocol failure", "protocol_error"],
["cancellation", "cancelled"],
] as const;
const createPartialInspectorServer = (
failure: (typeof failureCases)[number][0],
scriptUrl: string,
reportedScriptUrl: string,
) => {
let signalScriptSent: (() => void) | undefined;
const scriptSent = new Promise<void>((resolve) => {
signalScriptSent = resolve;
});
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": "12.0",
}),
);
return;
}
if (request.url === "/json/list") {
const address = server.address();
if (address === null || typeof address === "string") {
response.writeHead(500).end();
return;
}
response.end(
JSON.stringify([
{
id: "target-1",
type: "node",
url: scriptUrl,
attached: false,
webSocketDebuggerUrl: `ws://127.0.0.1:${String(address.port)}/target-1`,
},
]),
);
return;
}
response.writeHead(404).end();
});
const sockets = new WebSocketServer({ server, path: "/target-1" });
sockets.on("connection", (socket) => {
socket.on("message", (raw) => {
const parsed = safeParseJson(raw.toString());
if (!parsed.ok) throw parsed.cause;
const command = z
.object({ id: z.number(), method: z.string() })
.parse(parsed.value);
if (command.method === "Runtime.enable") {
socket.send(
JSON.stringify({
method: "Runtime.executionContextCreated",
params: {
context: { id: 1, name: "main", origin: reportedScriptUrl },
},
}),
);
socket.send(
JSON.stringify({
method: "Debugger.scriptParsed",
params: {
scriptId: "1",
url: reportedScriptUrl,
executionContextId: 1,
hash: "captured-before-failure",
length: 24,
isModule: false,
},
}),
);
socket.send(JSON.stringify({ id: command.id, result: {} }));
return;
}
if (command.method === "Debugger.enable") {
// Receiving the next command proves the preceding events and reply
// were consumed; cancellation needs no timing assumption.
signalScriptSent?.();
if (failure === "protocol failure") {
socket.send(JSON.stringify({ id: command.id, result: "malformed" }));
} else {
socket.send(JSON.stringify({ id: command.id, result: {} }));
if (failure === "disconnect") socket.close();
if (failure === "idle protocol failure") {
setTimeout(() => socket.send("{malformed-json"), 20);
setTimeout(
() =>
socket.send(
JSON.stringify({
method: "Debugger.scriptParsed",
params: { scriptId: "late", url: scriptUrl },
}),
),
40,
);
}
}
return;
}
socket.send(JSON.stringify({ id: command.id, result: {} }));
});
});
return { server, sockets, scriptSent };
};
it.each(failureCases)(
"preserves captured scripts and contexts after %s",
async (failure, reason) => {
@ -26,93 +133,11 @@ it.each(failureCases)(
const reportedScriptUrl = failure === "cancellation" ? "" : scriptUrl;
await writeFile(scriptPath, "globalThis.ready = true;\n");
let signalScriptSent: (() => void) | undefined;
const scriptSent = new Promise<void>((resolve) => {
signalScriptSent = resolve;
});
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": "12.0",
}),
);
return;
}
if (request.url === "/json/list") {
const address = server.address();
if (address === null || typeof address === "string") {
response.writeHead(500).end();
return;
}
response.end(
JSON.stringify([
{
id: "target-1",
type: "node",
url: scriptUrl,
attached: false,
webSocketDebuggerUrl: `ws://127.0.0.1:${String(address.port)}/target-1`,
},
]),
);
return;
}
response.writeHead(404).end();
});
const sockets = new WebSocketServer({ server, path: "/target-1" });
sockets.on("connection", (socket) => {
socket.on("message", (raw) => {
const parsed = safeParseJson(raw.toString());
if (!parsed.ok) throw parsed.cause;
const command = z
.object({ id: z.number(), method: z.string() })
.parse(parsed.value);
if (command.method === "Runtime.enable") {
socket.send(
JSON.stringify({
method: "Runtime.executionContextCreated",
params: {
context: { id: 1, name: "main", origin: reportedScriptUrl },
},
}),
);
socket.send(
JSON.stringify({
method: "Debugger.scriptParsed",
params: {
scriptId: "1",
url: reportedScriptUrl,
executionContextId: 1,
hash: "captured-before-failure",
length: 24,
isModule: false,
},
}),
);
socket.send(JSON.stringify({ id: command.id, result: {} }));
return;
}
if (command.method === "Debugger.enable") {
// Receiving the next command proves the preceding events and reply
// were consumed; cancellation needs no timing assumption.
signalScriptSent?.();
if (failure === "protocol failure") {
socket.send(
JSON.stringify({ id: command.id, result: "malformed" }),
);
} else {
socket.send(JSON.stringify({ id: command.id, result: {} }));
if (failure === "disconnect") socket.close();
}
return;
}
socket.send(JSON.stringify({ id: command.id, result: {} }));
});
});
const { server, sockets, scriptSent } = createPartialInspectorServer(
failure,
scriptUrl,
reportedScriptUrl,
);
try {
await new Promise<void>((resolve) =>
@ -123,13 +148,19 @@ it.each(failureCases)(
throw new Error("Inspector fixture did not bind a TCP port");
const controller = new AbortController();
const timerSpy =
failure === "disconnect" ? vi.spyOn(globalThis, "setTimeout") : null;
const clearSpy =
failure === "disconnect" ? vi.spyOn(globalThis, "clearTimeout") : null;
const executionOptions =
failure === "cancellation" ? { signal: controller.signal } : {};
failure === "cancellation" || failure === "disconnect"
? { signal: controller.signal }
: {};
const observing = new V8InspectorProvider().observe(
{
inspector_endpoint: `http://127.0.0.1:${String(address.port)}`,
target_id: "target-1",
observation_ms: 10_000,
observation_ms: failure === "disconnect" ? 12_345 : 10_000,
},
executionOptions,
);
@ -139,6 +170,17 @@ it.each(failureCases)(
}
const result = await observing;
if (failure === "disconnect") {
const observationTimer = timerSpy?.mock.results.find(
(result, index) => timerSpy.mock.calls[index]?.[1] === 12_345,
)?.value;
expect(observationTimer).toBeDefined();
expect(
clearSpy?.mock.calls.some(([timer]) => timer === observationTimer),
).toBe(true);
expect(controller.signal.aborted).toBe(false);
}
expect(result.ok).toBe(false);
if (result.ok)
throw new Error("Expected the Inspector operation to fail");
@ -167,6 +209,7 @@ it.each(failureCases)(
},
});
} finally {
vi.restoreAllMocks();
await new Promise<void>((resolve) => sockets.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });

View File

@ -94,6 +94,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
);
const allowed: AuthorizedV8InspectorTarget[] = [];
const excluded = createInspectorExclusionCounts();
let unconnectable = 0;
for (const target of discovery.targets) {
const decision = await authorizeRuntimeTargetLocation(target.url, {
type: target.type,
@ -107,7 +108,18 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
excluded.unsupported_location += 1;
continue;
}
allowed.push({ ...target, location: decision.location });
if (target.webSocketUrl === null) {
unconnectable += 1;
continue;
}
allowed.push({
id: target.id,
type: target.type,
url: target.url,
attached: target.attached,
webSocketUrl: target.webSocketUrl,
location: decision.location,
});
}
allowed.sort((left, right) =>
left.id < right.id ? -1 : left.id > right.id ? 1 : 0,
@ -116,7 +128,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
javascriptRuntimeTargetListSchema.parse({
runtime: discovery.runtime,
targets: allowed.map(projectTarget),
excluded: { ...excluded, unconnectable: 0 },
excluded: { ...excluded, unconnectable },
limitations: describeInspectorTargetLimitations(
allowed.some((target) => target.attached === null),
),
@ -367,7 +379,23 @@ const authorizedTarget = async (
"observe_javascript_runtime",
"target_not_allowed",
);
return { ...target, location: decision.location };
if (target.webSocketUrl === null)
throw new BrowserObservationError(
"observe_javascript_runtime",
"invalid_endpoint_response",
{
detail:
"The selected Inspector target has no validated direct WebSocket endpoint.",
},
);
return {
id: target.id,
type: target.type,
url: target.url,
attached: target.attached,
webSocketUrl: target.webSocketUrl,
location: decision.location,
};
};
const assertRuntimeKind = (
@ -515,20 +543,35 @@ const waitForCapture = async (
signal?: AbortSignal,
): Promise<void> => {
let removeDisconnect = (): void => undefined;
let removeProtocolFailure = (): void => undefined;
const disconnected = new Promise<never>((_resolve, reject) => {
removeDisconnect = connection.onDisconnect((error) => reject(error));
});
const protocolFailure = new Promise<never>((_resolve, reject) => {
removeProtocolFailure = connection.onProtocolFailure((error) =>
reject(error),
);
});
const timerOwner = new AbortController();
const timerSignal =
signal === undefined
? timerOwner.signal
: AbortSignal.any([signal, timerOwner.signal]);
try {
await Promise.race([
delayWithCancellation(
observationMs,
"observe_javascript_runtime",
signal,
timerSignal,
),
disconnected,
protocolFailure,
]);
} finally {
// Cancel only this wait's timer. The caller owns and retains its signal.
timerOwner.abort();
removeDisconnect();
removeProtocolFailure();
}
};