fix: time out ssh directory browsing (#3794)

This commit is contained in:
Neil
2026-05-30 10:32:00 -07:00
committed by GitHub
parent 722008d34f
commit b71a855c95
2 changed files with 67 additions and 0 deletions
+37
View File
@@ -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()
}
})
})
+30
View File
@@ -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<typeof setTimeout> | 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()
}
})
}
)