fix(spf): call sourceBuffer.abort() on AbortError to reset MSE parser state (#1081)

This commit is contained in:
Christian Pillsbury
2026-03-23 08:45:09 -07:00
committed by GitHub
parent 04f81a26e1
commit f5ecc93554
2 changed files with 75 additions and 6 deletions
+30 -6
View File
@@ -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;
}
}
}
@@ -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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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]);