fix(browser): observe direct target disconnects

This commit is contained in:
N0zoM1z0
2026-07-14 20:48:01 +00:00
parent 0aac8aaba5
commit 9be24f8de1
4 changed files with 110 additions and 6 deletions
+21 -2
View File
@@ -30,6 +30,7 @@ const MAX_PENDING_COMMANDS = 128;
export class CdpConnection {
readonly #pending = new Map<number, PendingCommand>();
readonly #listeners = new Set<(event: CdpEvent) => void>();
readonly #disconnectListeners = new Set<() => void>();
#nextId = 1;
#closed = false;
#protocolFailed = false;
@@ -39,8 +40,8 @@ export class CdpConnection {
private readonly operation: BrowserObservationOperation,
) {
socket.on("message", (data) => this.#receive(data));
socket.on("close", () => this.#failPending("disconnected"));
socket.on("error", () => this.#failPending("disconnected"));
socket.on("close", () => this.#disconnect());
socket.on("error", () => this.#disconnect());
}
/** Connect to one already-validated loopback CDP WebSocket. */
@@ -65,6 +66,16 @@ export class CdpConnection {
return () => this.#listeners.delete(listener);
}
/** Subscribe to an unexpected transport loss while an operation is active. */
onDisconnect(listener: () => void): () => void {
if (this.#closed) {
listener();
return () => undefined;
}
this.#disconnectListeners.add(listener);
return () => this.#disconnectListeners.delete(listener);
}
/** Execute one bounded command, optionally within a flat target session. */
async send(
method: string,
@@ -214,6 +225,14 @@ export class CdpConnection {
pending.reject(new BrowserObservationError(this.operation, reason));
}
}
#disconnect(): void {
const wasClosed = this.#closed;
this.#failPending("disconnected");
if (wasClosed) return;
for (const listener of this.#disconnectListeners) listener();
this.#disconnectListeners.clear();
}
}
const rawText = (data: RawData): string =>
+25 -3
View File
@@ -51,8 +51,12 @@ export const observeCdpSession = async (
initialUrl,
);
const removeListener = context.connection.onEvent((event) => {
if (event.sessionId === context.sessionId) capture.ingest(event);
if (observationEventMatches(event, context.sessionId))
capture.ingest(event);
});
const removeDisconnectListener = context.connection.onDisconnect(() =>
capture.targetTerminated(),
);
try {
await context.connection.send(
"Page.enable",
@@ -118,6 +122,7 @@ export const observeCdpSession = async (
};
} finally {
removeListener();
removeDisconnectListener();
}
};
@@ -165,8 +170,7 @@ class TimelineCapture {
break;
case "Inspector.targetCrashed":
case "Target.detachedFromTarget":
this.#add("target_terminated", params, null, null, "target_terminated");
this.end("target_terminated");
this.targetTerminated();
break;
}
}
@@ -184,6 +188,12 @@ class TimelineCapture {
this.#listeners.clear();
}
targetTerminated(): void {
if (this.#endReason !== undefined) return;
this.#add("target_terminated", {}, null, null, "target_terminated");
this.end("target_terminated");
}
setFinalUrl(rawUrl: string): void {
const destination = scopedUrl(rawUrl, this.allowedOrigins);
this.finalUrl = destination.scope === "approved" ? destination.url : null;
@@ -303,6 +313,18 @@ class TimelineCapture {
}
}
const observationEventMatches = (
event: CdpEvent,
sessionId: string | undefined,
): boolean => {
if (event.method !== "Target.detachedFromTarget")
return event.sessionId === sessionId;
return (
sessionId !== undefined &&
stringValue(recordValue(event.params)?.sessionId) === sessionId
);
};
const waitForWindow = async (
durationMs: number,
capture: TimelineCapture,
+52
View File
@@ -720,6 +720,58 @@ describe("CdpBrowserProvider", () => {
);
});
it("ends a browser session when its flat target session detaches", async () => {
const browser = await startFakeCdpBrowser({
sessionTimeline: "target_detached",
});
browsers.push(browser);
const result = await new CdpBrowserProvider().observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
approved: true,
observation_ms: 1_000,
}),
);
if (!result.ok) throw result.error;
expect(result.value.window.end_reason).toBe("target_terminated");
expect(result.value.timeline.at(-1)).toMatchObject({
type: "target_terminated",
detail: "target_terminated",
});
});
it("ends a direct page session when its transport disconnects", async () => {
const browser = await startFakeCdpBrowser({
pageScopedVersionWebSocket: true,
omitTargetWebSocket: true,
closeAfterMethod: "Page.setLifecycleEventsEnabled",
});
browsers.push(browser);
const result = await new CdpBrowserProvider().observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
approved: true,
observation_ms: 1_000,
}),
);
if (!result.ok) throw result.error;
expect(result.value.window.end_reason).toBe("target_terminated");
expect(result.value.timeline.at(-1)).toMatchObject({
type: "target_terminated",
detail: "target_terminated",
});
const methods = browser.commands.map(({ method }) => method);
expect(methods).not.toContain("Target.attachToTarget");
expect(methods).not.toContain("Target.detachFromTarget");
expect(methods).not.toContain("Target.closeTarget");
});
it("discovers untrusted WebMCP declarations without registering or invoking them", async () => {
const browser = await startFakeCdpBrowser({ webMcpTools: true });
browsers.push(browser);
+12 -1
View File
@@ -51,7 +51,11 @@ interface FakeOptions {
readonly binaryWebSocketEvent?: boolean;
readonly invalidBinaryWebSocketEvent?: boolean;
readonly sourceMapBody?: string;
readonly sessionTimeline?: "same_origin" | "outside_policy";
readonly sessionTimeline?:
| "same_origin"
| "outside_policy"
| "target_detached";
readonly closeAfterMethod?: string;
readonly sensitiveShapes?: boolean;
readonly invalidResponseBodyBase64?: boolean;
readonly webMcpTools?: boolean;
@@ -183,6 +187,8 @@ export const startFakeCdpBrowser = async (
if (options.malformedEventShapeOnMethod === command.method)
socket.send(JSON.stringify({ method: 42 }));
emitEvents(socket, command, port, options);
if (options.closeAfterMethod === command.method)
setImmediate(() => socket.close());
});
});
await new Promise<void>((resolve, reject) => {
@@ -963,6 +969,11 @@ const emitEvents = (
name: "networkIdle",
timestamp: 15,
});
if (options.sessionTimeline === "target_detached")
event(socket, "Target.detachedFromTarget", undefined, {
sessionId: command.sessionId,
targetId: "allowed-page",
});
}
};