fix(spf): fix async teardown leaks and recreate engine on src change (#841)

This commit is contained in:
Christian Pillsbury
2026-03-10 07:43:20 -07:00
committed by GitHub
parent 54f71d62aa
commit f50d509749
6 changed files with 104 additions and 28 deletions
@@ -254,9 +254,11 @@ export function endOfStream({
owners: WritableState<EndOfStreamOwners>;
}): () => void {
let hasEnded = false;
let destroyed = false;
const activeActorUnsubs: Array<() => void> = [];
const runEvaluate = async () => {
if (destroyed) return;
const currentState = state.current;
const currentOwners = owners.current;
if (hasEnded) {
@@ -306,6 +308,7 @@ export function endOfStream({
const cleanupCombineLatest = combineLatest([state, owners]).subscribe(async () => runEvaluate());
return () => {
destroyed = true;
activeActorUnsubs.forEach((u) => u());
cleanupOwners();
cleanupCombineLatest();
@@ -275,5 +275,8 @@ export function loadTextTrackCues({
}
);
return cleanup;
return () => {
abortController?.abort();
cleanup();
};
}
@@ -52,13 +52,15 @@ export function setupMediaSource({
owners: WritableState<MediaSourceOwners>;
}): () => void {
let settingUp = false;
let abortController: AbortController | null = null;
return combineLatest([state, owners]).subscribe(
const unsubscribe = combineLatest([state, owners]).subscribe(
async ([currentState, currentOwners]: [MediaSourceState, MediaSourceOwners]) => {
if (!canSetup(currentState, currentOwners) || !shouldSetup(currentState, currentOwners) || settingUp) return;
try {
settingUp = true;
abortController = new AbortController();
// Create MediaSource
// Note: ManagedMediaSource (Safari) requires disableRemotePlayback and
@@ -69,14 +71,21 @@ export function setupMediaSource({
// Attach to element
attachMediaSource(mediaSource, currentOwners.mediaElement!);
// Wait for sourceopen
await waitForSourceOpen(mediaSource);
// Wait for sourceopen — aborted if destroy() is called before it fires
await waitForSourceOpen(mediaSource, abortController.signal);
// Update owners with created MediaSource
owners.patch({ mediaSource });
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
throw error;
} finally {
settingUp = false;
}
}
);
return () => {
abortController?.abort();
unsubscribe();
};
}
@@ -98,7 +98,9 @@ export function updateDuration({
state: WritableState<DurationUpdateState>;
owners: WritableState<DurationUpdateOwners>;
}): () => void {
return combineLatest([state, owners]).subscribe(
let destroyed = false;
const unsubscribe = combineLatest([state, owners]).subscribe(
async ([currentState, currentOwners]: [DurationUpdateState, DurationUpdateOwners]) => {
if (!shouldUpdateDuration(currentState, currentOwners)) return;
@@ -107,10 +109,9 @@ export function updateDuration({
// MSE spec: duration cannot be set while any SourceBuffer is updating
await waitForSourceBuffersReady(currentOwners);
// Re-check readyState after the async wait — the endOfStream orchestrator may
// have called mediaSource.endOfStream() concurrently, transitioning readyState
// to 'ended'. Setting duration in that state throws InvalidStateError.
if (mediaSource!.readyState !== 'open') return;
// Re-check after async wait: destroyed, or readyState changed (e.g. endOfStream
// already called endOfStream(), transitioning 'open' → 'ended').
if (destroyed || mediaSource!.readyState !== 'open') return;
let duration = currentState.presentation!.duration!;
@@ -127,4 +128,9 @@ export function updateDuration({
mediaSource!.duration = duration;
}
);
return () => {
destroyed = true;
unsubscribe();
};
}
+36 -18
View File
@@ -7,28 +7,36 @@ import { createPlaybackEngine, type PlaybackEngine } from './engine';
* Implements the src/play() contract per the WHATWG HTML spec so that SPF can
* be used anywhere a media element API is expected.
*
* The engine is created once at construction and reused across src changes and
* mediaElement swaps — attach/detach only update the owner reference.
* A new engine is created on every src assignment — this fully tears down all
* state, SourceBuffers, and in-flight requests from the previous source before
* the next one begins. The media element reference is preserved across src
* changes and re-applied to the new engine automatically.
*
* @example
* const media = new SpfMedia({ preferredAudioLanguage: 'en' });
* media.attach(document.querySelector('video'));
* media.src = 'https://stream.mux.com/abc123.m3u8';
*
* // Later, swap element without recreating the engine:
* media.attach(otherVideoElement);
* // Change source — old engine is destroyed, new one starts clean:
* media.src = 'https://stream.mux.com/xyz456.m3u8';
*
* // Explicit teardown:
* media.destroy();
*/
export class SpfMedia {
readonly engine: PlaybackEngine;
#engine: PlaybackEngine;
#config: PlaybackEngineConfig;
/** Pending loadstart listener from a deferred play() retry, if any. */
#loadstartListener: (() => void) | null = null;
constructor(config: PlaybackEngineConfig = {}) {
this.engine = createPlaybackEngine(config);
this.#config = config;
this.#engine = createPlaybackEngine(config);
}
get engine(): PlaybackEngine {
return this.#engine;
}
// ---------------------------------------------------------------------------
@@ -36,33 +44,43 @@ export class SpfMedia {
// ---------------------------------------------------------------------------
attach(mediaElement: HTMLMediaElement): void {
this.engine.owners.patch({ mediaElement });
this.#engine.owners.patch({ mediaElement });
}
detach(): void {
this.#cancelPendingPlay();
this.engine.owners.patch({ mediaElement: undefined });
this.#engine.owners.patch({ mediaElement: undefined });
}
destroy(): void {
this.#cancelPendingPlay();
this.engine.destroy();
this.#engine.destroy();
}
// ---------------------------------------------------------------------------
// src — synchronous IDL attribute (WHATWG §4.8.11.2)
// Setting src synchronously triggers the load algorithm via state patch.
// The URL is derived from state — no separate field needed.
// Each assignment destroys the current engine and starts a fresh one, exactly
// as the browser's load algorithm resets all media element state on src change.
// ---------------------------------------------------------------------------
get src(): string {
return this.engine.state.current.presentation?.url ?? '';
return this.#engine.state.current.presentation?.url ?? '';
}
set src(value: string) {
this.engine.state.patch({
presentation: value ? { url: value } : undefined,
});
const prevMediaElement = this.#engine.owners.current.mediaElement;
this.#cancelPendingPlay();
this.#engine.destroy();
this.#engine = createPlaybackEngine(this.#config);
if (prevMediaElement) {
this.#engine.owners.patch({ mediaElement: prevMediaElement });
}
if (value) {
this.#engine.state.patch({ presentation: { url: value } });
}
}
// ---------------------------------------------------------------------------
@@ -71,13 +89,13 @@ export class SpfMedia {
// ---------------------------------------------------------------------------
play(): Promise<void> {
const { mediaElement } = this.engine.owners.current;
const { mediaElement } = this.#engine.owners.current;
if (!mediaElement) {
return Promise.reject(new Error('SpfMedia: no media element attached'));
}
// Signal play intent — enables loading even with preload="none"
this.engine.state.patch({ playbackInitiated: true });
this.#engine.state.patch({ playbackInitiated: true });
return mediaElement.play().catch((err: unknown) => {
// If we have a pending HLS source, the rejection may be because MSE
@@ -103,7 +121,7 @@ export class SpfMedia {
#cancelPendingPlay(): void {
if (!this.#loadstartListener) return;
const { mediaElement } = this.engine.owners.current;
const { mediaElement } = this.#engine.owners.current;
mediaElement?.removeEventListener('loadstart', this.#loadstartListener);
this.#loadstartListener = null;
}
@@ -98,6 +98,43 @@ describe('SpfMedia', () => {
expect(media.engine).toBe(engine);
});
it('creates a new engine when src is set', () => {
const media = new SpfMedia();
const initial = media.engine;
media.src = 'https://example.com/v1.m3u8';
expect(media.engine).not.toBe(initial);
});
it('destroys the old engine when src changes', () => {
const media = new SpfMedia();
media.src = 'https://example.com/v1.m3u8';
const spy = vi.spyOn(media.engine, 'destroy');
media.src = 'https://example.com/v2.m3u8';
expect(spy).toHaveBeenCalledOnce();
});
it('re-attaches the media element to the new engine when src changes', () => {
const media = new SpfMedia();
const el = document.createElement('video');
media.attach(el);
media.src = 'https://example.com/v1.m3u8';
expect(media.engine.owners.current.mediaElement).toBe(el);
});
it('cancels pending play listener when src changes', async () => {
const media = new SpfMedia();
const el = document.createElement('video');
media.attach(el);
media.src = 'https://example.com/v1.m3u8';
el.play = () => Promise.reject(new Error('no supported sources'));
media.play().catch(() => {});
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const spy = vi.spyOn(el, 'removeEventListener');
media.src = 'https://example.com/v2.m3u8';
expect(spy).toHaveBeenCalledWith('loadstart', expect.any(Function));
});
it('sets mediaElement in owners when attached', () => {
const media = new SpfMedia();
const el = document.createElement('video');