diff --git a/ERRORS.md b/ERRORS.md index 88f8a4af..7e6d3b9e 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -118,6 +118,7 @@ This document lists all error scenarios and the messages users will see. | `--text-file` unreadable | `Cannot read file: ${e.message}` | | `--out` path no write permission | `Permission denied: cannot write to "${outPath}".` | | Disk full | `Disk full — cannot write audio file.` | +| `--stream` connection drops before the final audio chunk | `Stream disconnected before audio completed.` | ### `mmx speech transcribe` diff --git a/src/utils/audio-stream.ts b/src/utils/audio-stream.ts index a6bc2d7d..032ff270 100644 --- a/src/utils/audio-stream.ts +++ b/src/utils/audio-stream.ts @@ -37,6 +37,13 @@ function missingAudioError(): CLIError { ); } +function truncatedStreamError(): CLIError { + return new CLIError( + 'Stream disconnected before audio completed.', + ExitCode.NETWORK, + ); +} + export async function* decodeAudioStream( response: Response, ): AsyncGenerator> { @@ -61,8 +68,13 @@ export async function* decodeAudioStream( } let receivedAudio = false; + let streamCompleted = false; for await (const event of parseSSE(response)) { - if (!event.data || event.data === '[DONE]') break; + if (!event.data) break; + if (event.data === '[DONE]') { + streamCompleted = true; + break; + } let parsed: AudioPayload; try { @@ -82,10 +94,14 @@ export async function* decodeAudioStream( yield decodeHexAudio(hex); } - if (parsed.data?.status === 2) break; + if (parsed.data?.status === 2) { + streamCompleted = true; + break; + } } if (!receivedAudio) throw missingAudioError(); + if (!streamCompleted) throw truncatedStreamError(); } export async function pipeAudioStream(response: Response): Promise { diff --git a/test/utils/audio-stream.test.ts b/test/utils/audio-stream.test.ts index 23bdefd4..03731c2d 100644 --- a/test/utils/audio-stream.test.ts +++ b/test/utils/audio-stream.test.ts @@ -53,4 +53,18 @@ describe('decodeAudioStream', () => { 'API stream ended without audio data', ); }); + + it('rejects a stream that closes after audio chunks but without a terminator', async () => { + // A dropped connection: chunks arrive, then the body ends with no + // `status: 2` chunk and no `data: [DONE]` terminator. + const response = new Response( + 'data: {"data":{"audio":"414243","status":1}}\n\n' + + 'data: {"data":{"audio":"444546","status":1}}\n\n', + { headers: { 'Content-Type': 'text/event-stream' } }, + ); + + await expect(collectAudio(response)).rejects.toThrow( + 'Stream disconnected before audio completed.', + ); + }); });