fix(player): stop AbortError storm from HLS stall recovery (DR-096)
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m14s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 14s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m14s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 14s
Html5PlayerAdapter.play() reported every interrupted play attempt as a
player error. While an HLS stream stalls, hls.js' gap-controller nudges
the element to recover, which cancels the pending play() promise and
raises AbortError ("play() request was interrupted by a call to
pause()"). That is transient — the element is still trying to play — but
it hit host.onError roughly once a second for the whole stall, leaving
the UI stuck reporting paused.
Treat an interrupted play as a debug-level non-event, and memoise the
in-flight attempt so the UI and recovery paths share one element.play()
rather than stacking calls that abort each other.
This is the loop amplifier, complementing DR-095 which removed the
dead-segment stall that triggered it.
Note: webviewAudioAdapter.play() has the same raw shape but is not
implicated — audio playback does not go through hls.js — so it is left
unchanged rather than widening this fix.
This commit is contained in:
@@ -95,6 +95,63 @@ describe("Html5PlayerAdapter", () => {
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
|
||||
// aborts an in-flight play(). That AbortError is transient — the element is
|
||||
// still trying to play — so it must not be surfaced as a player error, or the
|
||||
// UI reports failure ~once a second for the whole stall.
|
||||
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
|
||||
const abort = new DOMException(
|
||||
"The play() request was interrupted by a call to pause().",
|
||||
"AbortError"
|
||||
);
|
||||
video.play = vi.fn(async () => {
|
||||
throw abort;
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("play() still reports a genuine failure", async () => {
|
||||
video.play = vi.fn(async () => {
|
||||
throw new DOMException("no supported source", "NotSupportedError");
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).toHaveBeenCalledTimes(1);
|
||||
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
|
||||
});
|
||||
|
||||
it("play() coalesces concurrent attempts into one element.play() call", async () => {
|
||||
// During a stall the UI and recovery paths can both ask to play. Stacking
|
||||
// element.play() calls is what generates the AbortError storm.
|
||||
let resolvePlay: () => void = () => {};
|
||||
video.play = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((r) => {
|
||||
resolvePlay = () => {
|
||||
video.paused = false;
|
||||
r();
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const first = adapter.play();
|
||||
const second = adapter.play();
|
||||
resolvePlay();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("play() works again after a previous attempt settled", async () => {
|
||||
await adapter.play();
|
||||
await adapter.play();
|
||||
expect(video.play).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("pause() calls element.pause()", async () => {
|
||||
video.paused = false;
|
||||
await adapter.pause();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* intents flowing through the PlayerAdapter interface while preserving the
|
||||
* hard-won element behavior verbatim.
|
||||
*
|
||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
|
||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
|
||||
*/
|
||||
|
||||
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||
@@ -41,10 +41,24 @@ export interface Html5ElementBridge {
|
||||
getMediaSourceId(): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the `AbortError` the browser raises when a pending `play()` promise is
|
||||
* cancelled by a `pause()` (or a source/seek change). It signals "that specific
|
||||
* play attempt was superseded", not "playback failed" — hls.js' stall recovery
|
||||
* produces it routinely, so it must not reach the player's error channel.
|
||||
*/
|
||||
function isPlayInterruptedError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const { name, message } = err as { name?: string; message?: string };
|
||||
return name === "AbortError" || (message ?? "").includes("interrupted");
|
||||
}
|
||||
|
||||
export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
readonly kind = "html5" as const;
|
||||
|
||||
private attachedElement: HTMLVideoElement | null = null;
|
||||
/** In-flight play() attempt, so concurrent callers share one element.play(). */
|
||||
private pendingPlay: Promise<void> | null = null;
|
||||
private host: AdapterHost;
|
||||
private bridge: Html5ElementBridge;
|
||||
|
||||
@@ -81,12 +95,31 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
async play(): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
try {
|
||||
await el.play();
|
||||
// handlePlay on the element reports "playing"; no double-report here.
|
||||
} catch (err) {
|
||||
this.host.onError(`play() failed: ${err}`);
|
||||
}
|
||||
// Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
|
||||
// gap-controller recovery path can both ask to play; stacking element.play()
|
||||
// calls is what turns one stall into an AbortError storm.
|
||||
if (this.pendingPlay) return this.pendingPlay;
|
||||
|
||||
this.pendingPlay = (async () => {
|
||||
try {
|
||||
await el.play();
|
||||
// handlePlay on the element reports "playing"; no double-report here.
|
||||
} catch (err) {
|
||||
// A play() aborted by a pause() is transient, not a failure: hls.js
|
||||
// nudges the element to recover from a stall, which cancels the pending
|
||||
// play promise while the element keeps trying. Surfacing it would report
|
||||
// an error roughly once a second for the duration of the stall.
|
||||
if (isPlayInterruptedError(err)) {
|
||||
console.debug("[Html5PlayerAdapter] play() interrupted by pause (stall recovery)");
|
||||
} else {
|
||||
this.host.onError(`play() failed: ${err}`);
|
||||
}
|
||||
} finally {
|
||||
this.pendingPlay = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.pendingPlay;
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user