From 3c6f4f492fa73a3f6d7561796bdd39e283bb80b6 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Sat, 21 Feb 2026 08:36:24 -0800 Subject: [PATCH 1/2] fix: prevent SSH timeout stacking in MirrorSQL Each sshInstance() call added a new 5-minute timeout without clearing the previous one, stacking dangling timeouts on rapid repeated calls. Co-authored-by: Cursor --- apps/backend/middleware/legacy/sql.mirror.ts | 9 ++- .../middleware/legacy/ssh-timeout.test.ts | 64 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 tests/unit/middleware/legacy/ssh-timeout.test.ts diff --git a/apps/backend/middleware/legacy/sql.mirror.ts b/apps/backend/middleware/legacy/sql.mirror.ts index 95c91c9..fec82c7 100644 --- a/apps/backend/middleware/legacy/sql.mirror.ts +++ b/apps/backend/middleware/legacy/sql.mirror.ts @@ -3,6 +3,7 @@ import { Config, NodeSSH } from 'node-ssh'; export class MirrorSQL { private static _instance: MirrorSQL | null = null; private static _ssh: NodeSSH | null = null; + private static _timeoutHandle: ReturnType | null = null; static instance() { if (!this._instance) this._instance = new MirrorSQL(); @@ -24,15 +25,19 @@ export class MirrorSQL { await this._ssh.connect(sshConfig); } - setTimeout( + if (this._timeoutHandle) { + clearTimeout(this._timeoutHandle); + } + this._timeoutHandle = setTimeout( () => { if (this._ssh && this._ssh.isConnected()) { this._ssh.dispose(); this._ssh = null; } + this._timeoutHandle = null; }, 5 * 60 * 1000 - ); // auto-dispose after 5 minutes of inactivity + ); return this._ssh; } diff --git a/tests/unit/middleware/legacy/ssh-timeout.test.ts b/tests/unit/middleware/legacy/ssh-timeout.test.ts new file mode 100644 index 0000000..146377a --- /dev/null +++ b/tests/unit/middleware/legacy/ssh-timeout.test.ts @@ -0,0 +1,64 @@ +jest.mock('node-ssh', () => { + const mockSSH = { + isConnected: jest.fn().mockReturnValue(false), + connect: jest.fn().mockResolvedValue(undefined), + dispose: jest.fn(), + }; + return { NodeSSH: jest.fn(() => mockSSH) }; +}); + +import { MirrorSQL } from '../../../../apps/backend/middleware/legacy/sql.mirror'; + +describe('MirrorSQL SSH timeout stacking', () => { + beforeEach(() => { + jest.useFakeTimers(); + // Reset the singleton between tests + (MirrorSQL as any)._instance = null; + (MirrorSQL as any)._ssh = null; + (MirrorSQL as any)._timeoutHandle = null; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should only have one active timeout after multiple sshInstance() calls', async () => { + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + + await MirrorSQL.sshInstance(); + await MirrorSQL.sshInstance(); + await MirrorSQL.sshInstance(); + + const timeoutCalls = setTimeoutSpy.mock.calls.filter( + ([, ms]) => ms === 5 * 60 * 1000 + ); + + expect(timeoutCalls).toHaveLength(3); + // After 3 calls, the first 2 timeouts should have been cleared + expect(clearTimeoutSpy).toHaveBeenCalledTimes(2); + + setTimeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + }); + + it('should not dispose SSH if timeout was superseded by a newer call', async () => { + const { NodeSSH } = jest.requireMock('node-ssh'); + const mockSSH = new NodeSSH(); + mockSSH.isConnected.mockReturnValue(true); + + await MirrorSQL.sshInstance(); + // Advance partway — not enough to trigger + jest.advanceTimersByTime(4 * 60 * 1000); + + await MirrorSQL.sshInstance(); + // Advance past original 5 min mark — old timeout should have been cleared + jest.advanceTimersByTime(2 * 60 * 1000); + + expect(mockSSH.dispose).not.toHaveBeenCalled(); + + // Advance to trigger the second (active) timeout + jest.advanceTimersByTime(3 * 60 * 1000); + expect(mockSSH.dispose).toHaveBeenCalledTimes(1); + }); +}); From f2de0f49b972920aa35e6d1a9fdcb993a33b6f58 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Fri, 27 Feb 2026 09:47:00 -0800 Subject: [PATCH 2/2] style: format files with Prettier --- tests/unit/middleware/legacy/ssh-timeout.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit/middleware/legacy/ssh-timeout.test.ts b/tests/unit/middleware/legacy/ssh-timeout.test.ts index 146377a..2e5fa1f 100644 --- a/tests/unit/middleware/legacy/ssh-timeout.test.ts +++ b/tests/unit/middleware/legacy/ssh-timeout.test.ts @@ -30,9 +30,7 @@ describe('MirrorSQL SSH timeout stacking', () => { await MirrorSQL.sshInstance(); await MirrorSQL.sshInstance(); - const timeoutCalls = setTimeoutSpy.mock.calls.filter( - ([, ms]) => ms === 5 * 60 * 1000 - ); + const timeoutCalls = setTimeoutSpy.mock.calls.filter(([, ms]) => ms === 5 * 60 * 1000); expect(timeoutCalls).toHaveLength(3); // After 3 calls, the first 2 timeouts should have been cleared