From 6268f2fea2303a48a69614c8ea1de4550f8ebebf Mon Sep 17 00:00:00 2001 From: Bingxin Xu Date: Thu, 22 Oct 2026 12:36:13 -0400 Subject: [PATCH] fix(ssh): skip a loopback route for a torn-down connection and report an already-exited forward Check connection ownership before starting the forward, and make onExit fire for a forward that exited before the listener was registered, so the connection never keeps a dead route. --- .../ssh-connection-loopback-route.test.ts | 18 ++++++++++++++++++ src/main/ssh/connection/ssh-connection.ts | 2 ++ .../system-ssh-loopback-route.test.ts | 17 +++++++++++++++++ .../system-ssh-loopback-route.ts | 5 +++++ 4 files changed, 42 insertions(+) diff --git a/src/main/ssh/connection/ssh-connection-loopback-route.test.ts b/src/main/ssh/connection/ssh-connection-loopback-route.test.ts index ab20392d642..e331967d436 100644 --- a/src/main/ssh/connection/ssh-connection-loopback-route.test.ts +++ b/src/main/ssh/connection/ssh-connection-loopback-route.test.ts @@ -135,4 +135,22 @@ describe('SshConnection loopback route', () => { expect(route.dispose).toHaveBeenCalled() }) + + it('does not start a route for a connection torn down as it reported connected', async () => { + let conn: SshConnection | null = null + conn = new SshConnection( + createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }), + createCallbacks({ + onStateChange: (_id, state) => { + if (state.status === 'connected') { + void conn?.disconnect({ quiet: true }) + } + } + }) + ) + + await conn.connect().catch(() => undefined) + + expect(startRouteMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ssh/connection/ssh-connection.ts b/src/main/ssh/connection/ssh-connection.ts index 644888db60f..f73c949a52c 100644 --- a/src/main/ssh/connection/ssh-connection.ts +++ b/src/main/ssh/connection/ssh-connection.ts @@ -667,6 +667,8 @@ export class SshConnection { private startSystemSshLoopbackRoute(connectGeneration: number): void { const options = this.getSystemSshBuildArgsOptions() if ( + !this.isCurrentConnectAttempt(connectGeneration) || + !this.useSystemSshTransport || this.systemSshLoopbackRoute || this.systemSshLoopbackRouteStartingGeneration === connectGeneration || !shouldUseSystemSshLoopbackRoute(this.target, this.systemSshResolvedConfig, options) diff --git a/src/main/ssh/system-transport/system-ssh-loopback-route.test.ts b/src/main/ssh/system-transport/system-ssh-loopback-route.test.ts index 86e42c87747..817d0e69312 100644 --- a/src/main/ssh/system-transport/system-ssh-loopback-route.test.ts +++ b/src/main/ssh/system-transport/system-ssh-loopback-route.test.ts @@ -337,4 +337,21 @@ describe('startSystemSshLoopbackRoute', () => { expect(treeKillMock).toHaveBeenCalledWith(4242, { site: 'system-ssh-loopback-route' }) await vi.waitFor(() => expect(forward.kill).toHaveBeenCalledWith('SIGTERM')) }) + + it('reports a forward that already exited before anyone listened for it', async () => { + const forward = createFakeProcess() + const probe = createFakeProcess() + spawnMock.mockReturnValueOnce(forward).mockImplementationOnce(() => { + answerProbe(probe, 0) + return probe + }) + const route = await startSystemSshLoopbackRoute(createTarget(), createResolved(), {}, 'linux') + forward.exitCode = 255 + forward.emit('exit', 255) + + const onExit = vi.fn() + route.onExit(onExit) + + expect(onExit).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/ssh/system-transport/system-ssh-loopback-route.ts b/src/main/ssh/system-transport/system-ssh-loopback-route.ts index 013f35690bd..36db9c4f0c6 100644 --- a/src/main/ssh/system-transport/system-ssh-loopback-route.ts +++ b/src/main/ssh/system-transport/system-ssh-loopback-route.ts @@ -88,6 +88,11 @@ export async function startSystemSshLoopbackRoute( return { args, onExit: (callback) => { + // Why: the forward can exit between the probe and this registration, after 'exit' already fired. + if (forward.process.exitCode !== null || forward.process.signalCode !== null) { + callback() + return + } forward.process.once('exit', callback) }, dispose