diff --git a/packages/spf/src/dom/media/append-segment.ts b/packages/spf/src/dom/media/append-segment.ts index 0955b33d..6e774c2c 100644 --- a/packages/spf/src/dom/media/append-segment.ts +++ b/packages/spf/src/dom/media/append-segment.ts @@ -23,12 +23,36 @@ export async function appendSegment(sourceBuffer: SourceBuffer, data: AppendData if (data instanceof ArrayBuffer) { await appendChunk(sourceBuffer, data); } else { - for await (const chunk of data) { - // Check between chunks so an abort can stop streaming before the next - // appendBuffer call. The current chunk (if any) has already landed in the - // SourceBuffer; the partial: true flag in the actor model reflects this. - if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError'); - await appendChunk(sourceBuffer, chunk); + try { + for await (const chunk of data) { + // Check between chunks so an abort can stop streaming before the next + // appendBuffer call. The current chunk (if any) has already landed in the + // SourceBuffer; the partial: true flag in the actor model reflects this. + if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError'); + await appendChunk(sourceBuffer, chunk); + } + } catch (e) { + // Reset the MSE segment parser on any abort to discard partial fMP4 box + // data from the SourceBuffer's internal byte buffer. Two paths reach here: + // + // 1. Explicit abort check above (signal.aborted between chunks) — the + // last appended chunk may have left the parser mid-fragment. + // + // 2. The fetch was cancelled (signal fired), causing the underlying + // ReadableStream to error and the for-await loop to throw directly + // without ever passing through the signal check above. Same cleanup + // is needed: partial box data must be cleared before the next append. + // + // Without this, the next appendBuffer call sees stale partial data and + // Chrome throws CHUNK_DEMUXER_ERROR_APPEND_FAILED. + if (e instanceof DOMException && e.name === 'AbortError' && !sourceBuffer.updating) { + try { + sourceBuffer.abort(); + } catch { + // Thrown if the MediaSource is not "open" (e.g. during teardown). + } + } + throw e; } } } diff --git a/packages/spf/src/dom/media/tests/append-segment.test.ts b/packages/spf/src/dom/media/tests/append-segment.test.ts index 8dc5449a..6e929381 100644 --- a/packages/spf/src/dom/media/tests/append-segment.test.ts +++ b/packages/spf/src/dom/media/tests/append-segment.test.ts @@ -10,6 +10,7 @@ function makeSourceBuffer(): SourceBuffer { return { updating: false, + abort: vi.fn(), appendBuffer: vi.fn(() => { setTimeout(() => { for (const listener of listeners.updateend ?? []) listener(new Event('updateend')); @@ -104,6 +105,50 @@ describe('appendSegment', () => { await expect(appendSegment(sb, errorStream())).rejects.toThrow('stream failed'); }); + it('calls sourceBuffer.abort() and throws when signal is aborted between chunks', async () => { + const sb = makeSourceBuffer(); + const controller = new AbortController(); + + async function* twoChunks(): AsyncGenerator { + yield new Uint8Array(4); + controller.abort(); + yield new Uint8Array(4); + } + + await expect(appendSegment(sb, twoChunks(), controller.signal)).rejects.toMatchObject({ + name: 'AbortError', + }); + expect(sb.abort).toHaveBeenCalledOnce(); + // Only the first chunk should have been appended + expect(sb.appendBuffer).toHaveBeenCalledOnce(); + }); + + it('calls sourceBuffer.abort() when the stream itself throws an AbortError', async () => { + const sb = makeSourceBuffer(); + + async function* abortingStream(): AsyncGenerator { + yield new Uint8Array(4); + throw new DOMException('Aborted', 'AbortError'); + } + + await expect(appendSegment(sb, abortingStream())).rejects.toMatchObject({ + name: 'AbortError', + }); + expect(sb.abort).toHaveBeenCalledOnce(); + }); + + it('does not call sourceBuffer.abort() for non-abort stream errors', async () => { + const sb = makeSourceBuffer(); + + async function* errorStream(): AsyncGenerator { + yield new Uint8Array(4); + throw new Error('network error'); + } + + await expect(appendSegment(sb, errorStream())).rejects.toThrow('network error'); + expect(sb.abort).not.toHaveBeenCalled(); + }); + it('passes chunk bytes through to appendBuffer unchanged', async () => { const sb = makeSourceBuffer(); const data = new Uint8Array([1, 2, 3, 4]);