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..2e5fa1f --- /dev/null +++ b/tests/unit/middleware/legacy/ssh-timeout.test.ts @@ -0,0 +1,62 @@ +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); + }); +});