fix(browser): observe direct target disconnects
This commit is contained in:
@@ -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 =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+12
-1
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user