From dffd1ac180dd7c48396680e1fa874ebf575bc84c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 12:51:11 -0700 Subject: [PATCH] Clean up CDP proxy client listeners --- src/main/browser/cdp-ws-proxy.test.ts | 22 ++++++++++++++++ src/main/browser/cdp-ws-proxy.ts | 38 ++++++++++++++++++++------- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index ea3730130..9f345a2e6 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -330,6 +330,28 @@ describe('CdpWsProxy', () => { }) }) + it('detaches client websocket listeners after client close', async () => { + const client = await connect() + const serverClient = (proxy as unknown as { client: WebSocket | null }).client + expect(serverClient).toBeTruthy() + const offSpy = vi.spyOn(serverClient!, 'off') + + client.close() + + const start = Date.now() + while ( + (proxy as unknown as { client: WebSocket | null }).client && + Date.now() - start < 2_000 + ) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + + expect((proxy as unknown as { client: WebSocket | null }).client).toBeNull() + const removedEvents = offSpy.mock.calls.map(([event]) => event) + expect(removedEvents).toEqual(expect.arrayContaining(['message', 'close'])) + offSpy.mockRestore() + }) + it('rejects inflight requests on stop', async () => { let resolveCommand: (v: unknown) => void mock.webContents.debugger.sendCommand.mockImplementation( diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 6ec1d2bb5..f65e5b5a3 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: this proxy owns HTTP discovery, websocket client lifecycle, and CDP debugger forwarding together. */ import { WebSocketServer, WebSocket } from 'ws' import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http' import type { WebContents } from 'electron' @@ -9,6 +10,7 @@ export class CdpWsProxy { private httpServer: Server | null = null private wss: WebSocketServer | null = null private client: WebSocket | null = null + private detachClientListeners: (() => void) | null = null private port = 0 private debuggerMessageHandler: ((...args: unknown[]) => void) | null = null private debuggerDetachHandler: ((...args: unknown[]) => void) | null = null @@ -39,16 +41,27 @@ export class CdpWsProxy { failStart(error) } this.wss.on('connection', (ws) => { - if (this.client) { - this.client.close() - } + this.closeClient() this.client = ws - ws.on('message', (data) => this.handleClientMessage(ws, data.toString())) - ws.on('close', () => { + const onMessage = (data: WebSocket.RawData): void => { + this.handleClientMessage(ws, data.toString()) + } + const onClose = (): void => { + detach() if (this.client === ws) { this.client = null } - }) + } + const detach = (): void => { + ws.off('message', onMessage) + ws.off('close', onClose) + if (this.detachClientListeners === detach) { + this.detachClientListeners = null + } + } + this.detachClientListeners = detach + ws.on('message', onMessage) + ws.on('close', onClose) }) this.httpServer.listen(0, '127.0.0.1', () => { this.httpServer?.removeListener('error', onListenError) @@ -66,10 +79,7 @@ export class CdpWsProxy { async stop(): Promise { this.detachDebugger() - if (this.client) { - this.client.close() - this.client = null - } + this.closeClient() if (this.wss) { this.wss.close() this.wss = null @@ -84,6 +94,14 @@ export class CdpWsProxy { return this.port } + private closeClient(): void { + const client = this.client + this.detachClientListeners?.() + this.detachClientListeners = null + this.client = null + client?.close() + } + private send(payload: unknown, client = this.client): void { if (client?.readyState === WebSocket.OPEN) { client.send(JSON.stringify(payload))