From 8829617b793eb4bb8fb449b5d9982458eb28f05e Mon Sep 17 00:00:00 2001 From: Wolfgang Beyer Date: Mon, 11 May 2026 15:20:45 +0000 Subject: [PATCH] patch puppeteer for missing session IDs --- src/bin/chrome-devtools-mcp-main.ts | 1 + src/puppeteer-patches.ts | 97 ++++++++++++++++++++++++ src/third_party/index.ts | 2 + tests/puppeteer-patches.test.ts | 113 ++++++++++++++++++++++++++++ tests/setup.ts | 1 + 5 files changed, 214 insertions(+) create mode 100644 src/puppeteer-patches.ts create mode 100644 tests/puppeteer-patches.test.ts diff --git a/src/bin/chrome-devtools-mcp-main.ts b/src/bin/chrome-devtools-mcp-main.ts index 1d03c089b..742e7861e 100644 --- a/src/bin/chrome-devtools-mcp-main.ts +++ b/src/bin/chrome-devtools-mcp-main.ts @@ -5,6 +5,7 @@ */ import '../polyfill.js'; +import '../puppeteer-patches.js'; import process from 'node:process'; diff --git a/src/puppeteer-patches.ts b/src/puppeteer-patches.ts new file mode 100644 index 000000000..8043619d7 --- /dev/null +++ b/src/puppeteer-patches.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './third_party/index.js'; + +import { + InternalConnection as Connection, + type InternalCallbackRegistry as CallbackRegistry, +} from './third_party/index.js'; + +// 1. Map active command IDs directly to their sessionId +const idToSessionIdMap = new Map(); + +// 2. Intercept _rawSend to map request IDs to their sessionIds +const originalRawSend = Connection.prototype._rawSend; +Connection.prototype._rawSend = function ( + this: Connection, + ...args: Parameters +) { + const [callbacks, method, params, sessionId, options] = args; + + const wrappedCallbacks = Object.create(callbacks) as CallbackRegistry; + wrappedCallbacks.create = function ( + label: string, + timeout: number | undefined, + request: (id: number) => void, + ) { + const wrappedRequest = (id: number) => { + if (sessionId) { + idToSessionIdMap.set(id, sessionId); + } + return request(id); + }; + return callbacks.create(label, timeout, wrappedRequest); + }; + + return originalRawSend.call( + this, + wrappedCallbacks, + method, + params, + sessionId, + options, + ); +}; + +// 3. Repair missing sessionId in Chrome error responses +const onMessageDescriptor = Object.getOwnPropertyDescriptor( + Connection.prototype, + 'onMessage', +); + +if (onMessageDescriptor && typeof onMessageDescriptor.value === 'function') { + const originalOnMessage = onMessageDescriptor.value; + const patchedOnMessage = async function (this: Connection, message: string) { + try { + const object = JSON.parse(message); + if (object.id) { + let modified = false; + const sessionId = idToSessionIdMap.get(object.id); + if (sessionId) { + if (!object.sessionId) { + object.sessionId = sessionId; + modified = true; + } + idToSessionIdMap.delete(object.id); + } + // Clear "session not found" errors coming from dead sessions to prevent uncaught exceptions + if ( + object.error && + (object.error.code === -32001 || + object.error.message?.includes('Session with given id not found.')) + ) { + delete object.error; + object.result = {}; + modified = true; + } + if (modified) { + message = JSON.stringify(object); + } + } + } catch { + // Suppress JSON parsing errors to let the original handler deal with them + } + if (typeof originalOnMessage === 'function') { + return originalOnMessage.call(this, message); + } + }; + + Object.defineProperty(Connection.prototype, 'onMessage', { + ...onMessageDescriptor, + value: patchedOnMessage, + }); +} diff --git a/src/third_party/index.ts b/src/third_party/index.ts index 7cf50217c..f4b21a09c 100644 --- a/src/third_party/index.ts +++ b/src/third_party/index.ts @@ -46,6 +46,8 @@ export { export {default as puppeteer} from 'puppeteer-core'; export type * from 'puppeteer-core'; export {PipeTransport} from 'puppeteer-core/internal/node/PipeTransport.js'; +export {Connection as InternalConnection} from 'puppeteer-core/internal/cdp/Connection.js'; +export {CallbackRegistry as InternalCallbackRegistry} from 'puppeteer-core/internal/common/CallbackRegistry.js'; export type {CdpPage} from 'puppeteer-core/internal/cdp/Page.js'; export type {JSONSchema7, JSONSchema7Definition} from 'json-schema'; export { diff --git a/tests/puppeteer-patches.test.ts b/tests/puppeteer-patches.test.ts new file mode 100644 index 000000000..6ec41598b --- /dev/null +++ b/tests/puppeteer-patches.test.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it, afterEach} from 'node:test'; + +import {CdpCDPSession} from 'puppeteer-core/internal/cdp/CdpSession.js'; +import sinon from 'sinon'; + +import {InternalConnection as Connection} from '../src/third_party/index.js'; + +describe('puppeteer-patches', () => { + afterEach(() => { + sinon.restore(); + }); + + it('restores missing sessionId in responses', async () => { + const mockTransport: { + send(msg: string): void; + onmessage?: (msg: string) => void; + onclose?: () => void; + close(): void; + } = { + send() { + /* No-op */ + }, + close() { + /* No-op */ + }, + }; + const transportSendSpy = sinon.spy(mockTransport, 'send'); + const connection = new Connection('http://localhost', mockTransport); + const sessionId = 'test-session-id'; + const session = new CdpCDPSession( + connection, + 'page', + sessionId, + undefined, + false, + ); + const onMessageSpy = sinon.spy(session, 'onMessage'); + connection._sessions.set(sessionId, session); + + // Trigger send through session to establish mapping + const sendPromise = session.send('Browser.getVersion'); + + // Get the ID from the sent message + sinon.assert.calledOnce(transportSendSpy); + const sentMessage = transportSendSpy.getCall(0).args[0]; + const messageId = JSON.parse(sentMessage).id; + + // Simulate message without sessionId + await mockTransport.onmessage?.( + JSON.stringify({id: messageId, result: {}}), + ); + + // Wait for the send to resolve to clean up + await sendPromise; + + // Verify that session.onMessage was called because the patch added sessionId + sinon.assert.calledOnce(onMessageSpy); + + const receivedMessage = onMessageSpy.getCall(0).args[0]; + + function hasSessionId(obj: unknown): obj is {sessionId: string} { + return typeof obj === 'object' && obj !== null && 'sessionId' in obj; + } + + if (!hasSessionId(receivedMessage)) { + throw new Error('Message missing sessionId'); + } + assert.strictEqual(receivedMessage.sessionId, sessionId); + }); + + it('suppresses session not found errors', async () => { + const mockTransport: { + send(msg: string): void; + onmessage?: (msg: string) => void; + onclose?: () => void; + close(): void; + } = { + send() { + /* No-op */ + }, + close() { + /* No-op */ + }, + }; + const transportSendSpy = sinon.spy(mockTransport, 'send'); + const connection = new Connection('http://localhost', mockTransport); + const promise = connection.send('Browser.getVersion', undefined); + + // Get the ID from the sent message + sinon.assert.calledOnce(transportSendSpy); + const sentMessage = transportSendSpy.getCall(0).args[0]; + const messageId = JSON.parse(sentMessage).id; + + // Simulate receiving the specific error + await mockTransport.onmessage?.( + JSON.stringify({ + id: messageId, + error: {code: -32001, message: 'Session with given id not found.'}, + }), + ); + + // Verify that the promise resolved (error was suppressed) + const result = await promise; + assert.deepStrictEqual(result, {}); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index d48cc49dc..95c56772f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -5,6 +5,7 @@ */ import '../src/polyfill.js'; +import '../src/puppeteer-patches.js'; import path from 'node:path'; import {it} from 'node:test';