diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts index 9743005a466..8c5b8632fa7 100644 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -317,6 +317,13 @@ export async function readFileViaStream( } } ) + // Why: beforeResolve is an optional hook; if a mux ever resolves without running + // it, metadata never installs and no deadline is armed. Fail instead of hanging. + .then(() => { + if (!settled && !metadataReady) { + fail(new StreamProtocolError('Metadata response resolved without stream identity')) + } + }) .catch((err) => { fail(err as Error) }) diff --git a/src/main/ssh/ssh-filesystem-stream-retention.test.ts b/src/main/ssh/ssh-filesystem-stream-retention.test.ts index 1614afa3910..15400212f78 100644 --- a/src/main/ssh/ssh-filesystem-stream-retention.test.ts +++ b/src/main/ssh/ssh-filesystem-stream-retention.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, it } from 'vitest' +import { afterEach, expect, it, vi } from 'vitest' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { FileReadCapExceededError, @@ -203,3 +203,19 @@ it('preserves the provider fallback when an older relay has no streaming method' provider.dispose() } }) + +// Why: the metadata install moved from the mandatory resolve path to the optional +// beforeResolve hook, and the request timer is cleared before that hook runs. A mux +// that ignores the hook must fail the read, not leave it pending with no deadline. +it('fails the read when a multiplexer resolves without running beforeResolve', async () => { + const connection = createConnection() + vi.spyOn(connection.mux, 'request').mockResolvedValue({ + totalSize: 10, + isBinary: false, + streamId: 7 + }) + + await expect(readFileViaStream(connection.mux, '/no-hook.txt')).rejects.toBeInstanceOf( + StreamProtocolError + ) +})