From c1535811ca5bc0579ee89178294ad0acbafd5f28 Mon Sep 17 00:00:00 2001 From: James Hobin Date: Fri, 28 Aug 2026 13:42:11 -0400 Subject: [PATCH] Fix leak of closed parallel sockets --- src/ToolSocket.js | 16 ++++++++++++++++ src/integration.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/ToolSocket.js b/src/ToolSocket.js index 3cd64d5e..c68bee1a 100644 --- a/src/ToolSocket.js +++ b/src/ToolSocket.js @@ -823,6 +823,22 @@ class ToolSocket { // infoName() keeps the children in sync if the parent is named later if (!toolsocket.parallelSockets) toolsocket.parallelSockets = []; toolsocket.parallelSockets.push(parallel); + // Stop tracking a parallel once it closes. Most parallels are one-shot (the + // proxy closes them after a single request), so a list that only ever grew + // pinned a ToolSocket, its WebSocket, Sender, Receiver and ping Timeout per + // request for the life of the process. A parallel that reconnects re-registers + // itself on 'open', mirroring how the NB layer gates its own teardown. + parallel.addEventListener('close', () => { + const index = toolsocket.parallelSockets.indexOf(parallel); + if (index > -1) { + toolsocket.parallelSockets.splice(index, 1); + } + }); + parallel.addEventListener('open', () => { + if (!toolsocket.parallelSockets.includes(parallel)) { + toolsocket.parallelSockets.push(parallel); + } + }); if (toolsocket.remoteInfoName) { parallel.infoName(toolsocket.remoteInfoName + ' ยท data'); } diff --git a/src/integration.test.js b/src/integration.test.js index 842404d1..962c3ba5 100644 --- a/src/integration.test.js +++ b/src/integration.test.js @@ -160,4 +160,29 @@ describe('ToolSocket', () => { toolSocket.message('test/binary', { message: 'Binary data' }, null, [binaryData1, binaryData2]); }); + + test('a closed parallel socket stops being tracked by its parent', (done) => { + const parallel = ToolSocket.makeParallelSocket(toolSocket); + + parallel.addEventListener('open', () => { + try { + expect(toolSocket.parallelSockets).toContain(parallel); + } catch (error) { + done(error); + return; + } + parallel.close(); + }); + + parallel.addEventListener('close', () => { + try { + // Parallels are one-shot, so a list that only ever grew pinned the + // whole socket (and its ping timer) once per proxied request + expect(toolSocket.parallelSockets).not.toContain(parallel); + done(); + } catch (error) { + done(error); + } + }); + }); });