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.
This commit is contained in:
Bingxin Xu
2026-10-22 12:36:13 -04:00
parent 14a852455f
commit 6268f2fea2
4 changed files with 42 additions and 0 deletions
@@ -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()
})
})
@@ -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)
@@ -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)
})
})
@@ -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