diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts index 76de8d30f5a..f50775a492c 100644 --- a/src/main/ipc/ssh-browse.test.ts +++ b/src/main/ipc/ssh-browse.test.ts @@ -108,4 +108,41 @@ describe('registerSshBrowseHandler', () => { expect(channel.stderr.listenerCount('data')).toBe(0) expect(channel.stderr.listenerCount('error')).toBe(0) }) + + it('times out browse channels that never close', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const exec = vi.fn().mockResolvedValue(channel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/mnt/stalled' }) + let settled = false + void resultPromise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await Promise.resolve() + await vi.advanceTimersByTimeAsync(15_000) + + expect(settled).toBe(true) + await expect(resultPromise).rejects.toThrow('Remote directory listing timed out') + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('exit')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index d75732f76e0..83afbd9de01 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -6,6 +6,8 @@ export type RemoteDirEntry = { isDirectory: boolean } +const SSH_BROWSE_TIMEOUT_MS = 15_000 + // Why: the relay's fs.readDir enforces workspace root ACLs, which aren't // registered until a repo is added. This handler uses a raw SSH exec channel // to list directories, allowing the user to browse the remote filesystem @@ -45,8 +47,13 @@ export function registerSshBrowseHandler( let stderr = '' let exitCode: number | null = null let settled = false + let timeout: ReturnType | null = null const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } channel.off('data', onStdoutData) channel.stderr.off('data', onStderrData) channel.off('exit', onExit) @@ -62,6 +69,25 @@ export function registerSshBrowseHandler( cleanup() reject(error) } + const closeChannel = (): void => { + const closable = channel as { close?: () => void; destroy?: () => void } + try { + if (typeof closable.close === 'function') { + closable.close() + } else if (typeof closable.destroy === 'function') { + closable.destroy() + } + } catch { + /* best effort */ + } + } + const onTimeout = (): void => { + // Why: remote browsing runs before a relay workspace root exists, so + // it cannot rely on relay request deadlines. Bound this raw exec + // channel directly to keep Add Remote Project from hanging forever. + rejectOnce(new Error('Remote directory listing timed out')) + closeChannel() + } const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => { if (settled) { return @@ -147,6 +173,10 @@ export function registerSshBrowseHandler( // scoped listener, a disappearing remote can become process-fatal. channel.on('error', onError) channel.stderr.on('error', onError) + timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } }) } )