fix(inspector): retain usable targets and cancel owned capture waits
This commit is contained in:
parent
29a2d13994
commit
55778f20ba
|
|
@ -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()));
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue