From e3be586ef097c9bb878bac921bf4e2abef051484 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:30:22 -0700 Subject: [PATCH] fix(ssh): a fast remote directory listing no longer reports a false failure (#26940) ssh2 can deliver the exec ack and the exit status in one chunk, so 'exit' fires before the browse handler subscribes. Fall back to the status that 'close' repeats when no exit was observed. Co-authored-by: m4air Co-authored-by: suharvest --- src/main/ipc/ssh-browse.test.ts | 55 +++++++++++++++++++++ src/main/ssh/ssh-remote-directory-browse.ts | 8 ++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts index 2b6b1529ed7..5c3775bb168 100644 --- a/src/main/ipc/ssh-browse.test.ts +++ b/src/main/ipc/ssh-browse.test.ts @@ -80,6 +80,61 @@ describe('registerSshBrowseHandler', () => { expect(channel.stderr.listenerCount('error')).toBe(0) }) + it('reads the status from close when exit fired before the channel was returned', async () => { + const channel = createMockChannel() + // ssh2 can parse exit-status in the same chunk as the exec ack, before listeners attach. + const exec = vi + .fn() + .mockImplementationOnce(async () => { + channel.emit('exit', 0) + return channel + }) + .mockRejectedValue(new Error('unexpected PowerShell fallback')) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) + await Promise.resolve() + channel.emit('data', Buffer.from('/home/user\nsrc/\n')) + channel.emit('close', 0) + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: '/home/user', + pathFlavor: 'posix', + entries: [{ name: 'src', isDirectory: true }] + }) + expect(exec).toHaveBeenCalledTimes(1) + }) + + it('keeps an observed exit status over the close status', async () => { + const channel = createMockChannel() + const fallback = createMockChannel() + const exec = vi + .fn() + .mockResolvedValueOnce(channel) + .mockImplementationOnce(async () => { + fallback.emit('exit', 127) + return fallback + }) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) + const rejected = expect(resultPromise).rejects.toThrow('Remote listing failed (exit 7)') + await Promise.resolve() + channel.emit('data', Buffer.from('/home/user\n')) + channel.emit('exit', 7) + channel.emit('close', 0) + await vi.waitFor(() => expect(fallback.listenerCount('close')).toBe(1)) + fallback.emit('close', 127) + + await rejected + }) + it('escapes remote browse paths and reads the listing past rc-file stdout', async () => { const channel = createMockChannel() const exec = vi.fn().mockResolvedValue(channel) diff --git a/src/main/ssh/ssh-remote-directory-browse.ts b/src/main/ssh/ssh-remote-directory-browse.ts index b1fd912abbb..08e16ffe1a4 100644 --- a/src/main/ssh/ssh-remote-directory-browse.ts +++ b/src/main/ssh/ssh-remote-directory-browse.ts @@ -118,6 +118,7 @@ async function runBrowseCommand( let stdout = '' let stderr = '' let exitCode: number | null = null + let exitObserved = false let settled = false let timeout: ReturnType | null = null @@ -175,12 +176,17 @@ async function runBrowseCommand( } // `exit` fires before `close`; capture the code to tell a failed `ls` (that still printed `pwd`) from an empty listing. const onExit = (code: number | null): void => { + exitObserved = true exitCode = code } const onError = (error: Error): void => { rejectOnce(error) } - const onClose = (): void => { + const onClose = (code?: number | null): void => { + // Why: ssh2 can emit `exit` before `exec` resolves; `close` repeats the status. + if (!exitObserved && typeof code === 'number') { + exitCode = code + } stdout = fence?.readStdout(stdout) ?? stdout // Why: a null exitCode (channel closed without exit status) isn't success; don't treat empty stdout as an empty dir. if (exitCode !== 0) {