From 5c23bc96e8ccbda2413f267407312adc15b8a26d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:49:04 -0700 Subject: [PATCH] fix(ssh): fence reset transport opens during shutdown --- src/main/ipc/ssh.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++ src/main/ipc/ssh.ts | 8 ++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 39843b601c3..e76aa5302b5 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -2520,6 +2520,51 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + it('ssh:resetRelay does not open a transport when shutdown starts while it waits for a connect', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + let failConnect!: (error: unknown) => void + const connectResult = new Promise((_resolve, reject) => { + failConnect = reject + }) + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockReturnValue(connectResult) + // Why undefined: reset must fall through to opening its own transport, which is the call under test. + mockConnectionManager.getConnection.mockReturnValue(undefined) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + const connectPromise = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) + + // Why admitted first: the gate latches only after reset is already parked behind the connect, so + // the entry fence cannot be what stops it. + const resetPromise = handlers.get('ssh:resetRelay')!(null, { + targetId: 'ssh-1' + }) as Promise + await Promise.resolve() + + quitTeardownStartGate.tryStart({ preventDefault() {} }) + failConnect(new Error('transport lost')) + await expect(connectPromise).rejects.toThrow('transport lost') + + await expect(resetPromise).rejects.toThrow('closed for app shutdown') + // Why once: the resumed reset must not open a second transport that would outlive the drain. + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + expect(mockForceStopRelayForTarget).not.toHaveBeenCalled() + }) + it('ssh:connect waits for an in-flight reset before starting a new connection', async () => { const target: SshTarget = { id: 'ssh-1', diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 174edf024b5..db72b6f8928 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -1319,7 +1319,13 @@ export function registerSshHandlers( } const existingConn = connectionManager!.getConnection(targetId) - const conn = existingConn ?? (await connectionManager!.connect(target)) + let conn = existingConn + if (!conn) { + // Why re-check: admission fenced this reset before it parked on the in-flight connect, so shutdown + // may have started (and drained) while we waited — opening a transport now would outlive the drain. + assertSshConnectsNotFenced() + conn = await connectionManager!.connect(target) + } try { await forceStopRelayForTarget(conn, targetId) } finally {