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) {