From 998d9d4f6596758b2fd8c89e7f247892a331cb97 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Sat, 21 Feb 2026 08:36:24 -0800 Subject: [PATCH 1/3] 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 --- .../middleware/legacy/ssh-timeout.test.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/unit/middleware/legacy/ssh-timeout.test.ts 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 affd679d54bd50878bf93bf4ea9618ace6399a4d Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Fri, 27 Feb 2026 09:47:00 -0800 Subject: [PATCH 2/3] 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 From b4ed7b23c33b26dd5711ea780eda01bdce87fcea Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Mon, 9 Mar 2026 23:27:38 -0700 Subject: [PATCH 3/3] chore: update SSH timeout test to use new sql.mirror.ts location The MirrorSQL class moved from apps/backend/middleware/legacy/ to shared/database/src/legacy/. Updates the test import path and property names to match the current implementation. --- tests/unit/middleware/legacy/ssh-timeout.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/middleware/legacy/ssh-timeout.test.ts b/tests/unit/middleware/legacy/ssh-timeout.test.ts index 2e5fa1f..26ee406 100644 --- a/tests/unit/middleware/legacy/ssh-timeout.test.ts +++ b/tests/unit/middleware/legacy/ssh-timeout.test.ts @@ -7,7 +7,7 @@ jest.mock('node-ssh', () => { return { NodeSSH: jest.fn(() => mockSSH) }; }); -import { MirrorSQL } from '../../../../apps/backend/middleware/legacy/sql.mirror'; +import { MirrorSQL } from '../../../../shared/database/src/legacy/sql.mirror'; describe('MirrorSQL SSH timeout stacking', () => { beforeEach(() => { @@ -15,7 +15,7 @@ describe('MirrorSQL SSH timeout stacking', () => { // Reset the singleton between tests (MirrorSQL as any)._instance = null; (MirrorSQL as any)._ssh = null; - (MirrorSQL as any)._timeoutHandle = null; + (MirrorSQL as any)._disposeTimer = null; }); afterEach(() => { @@ -41,9 +41,7 @@ describe('MirrorSQL SSH timeout stacking', () => { }); 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); + const closeSpy = jest.spyOn(MirrorSQL.instance(), 'close'); await MirrorSQL.sshInstance(); // Advance partway — not enough to trigger @@ -53,10 +51,12 @@ describe('MirrorSQL SSH timeout stacking', () => { // Advance past original 5 min mark — old timeout should have been cleared jest.advanceTimersByTime(2 * 60 * 1000); - expect(mockSSH.dispose).not.toHaveBeenCalled(); + expect(closeSpy).not.toHaveBeenCalled(); // Advance to trigger the second (active) timeout jest.advanceTimersByTime(3 * 60 * 1000); - expect(mockSSH.dispose).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + + closeSpy.mockRestore(); }); });