diff --git a/src/inspector/V8InspectorEndpoint.test.ts b/src/inspector/V8InspectorEndpoint.test.ts new file mode 100644 index 000000000..83a0d20fc --- /dev/null +++ b/src/inspector/V8InspectorEndpoint.test.ts @@ -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((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((resolve) => server.close(() => resolve())); + } +}); diff --git a/src/inspector/V8InspectorEndpoint.ts b/src/inspector/V8InspectorEndpoint.ts index 53f15e073..a3dcd2357 100644 --- a/src/inspector/V8InspectorEndpoint.ts +++ b/src/inspector/V8InspectorEndpoint.ts @@ -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; } diff --git a/src/inspector/V8InspectorProvider.partial.test.ts b/src/inspector/V8InspectorProvider.partial.test.ts index 0ee635249..6b1ac23c8 100644 --- a/src/inspector/V8InspectorProvider.partial.test.ts +++ b/src/inspector/V8InspectorProvider.partial.test.ts @@ -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((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((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((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((resolve) => sockets.close(() => resolve())); await new Promise((resolve) => server.close(() => resolve())); await rm(root, { recursive: true, force: true }); diff --git a/src/inspector/V8InspectorProvider.ts b/src/inspector/V8InspectorProvider.ts index 06a413871..bf89e227e 100644 --- a/src/inspector/V8InspectorProvider.ts +++ b/src/inspector/V8InspectorProvider.ts @@ -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 => { let removeDisconnect = (): void => undefined; + let removeProtocolFailure = (): void => undefined; const disconnected = new Promise((_resolve, reject) => { removeDisconnect = connection.onDisconnect((error) => reject(error)); }); + const protocolFailure = new Promise((_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(); } };