Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/backend/middleware/legacy/sql.mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | null = null;

static instance() {
if (!this._instance) this._instance = new MirrorSQL();
Expand All @@ -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;
}
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/middleware/legacy/ssh-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading