fix(plex): a link too slow for the stream resumes in place, and says so
A request hls.js got no answer to in time (no status) was taken for a server away: a new session, the same first segment lost again, a restart every half minute that never ended, since every session answer reset the away window. Measured on prod: a work network at ~4 Mbit/s against 11-16 MB remux segments. A timeout now resumes loading in the same session; the away window ends only when media arrives (or a direct stream answers), and runs out in a 'too slow' error. A segment gets 30 s to its first byte. After 5 s of stalling on a link measured slower than the stream, the player says so with both rates.
This commit is contained in:
@@ -46,6 +46,7 @@ class FakeHls implements HlsPort {
|
||||
loaded: string | null = null;
|
||||
destroyed = false;
|
||||
audioTrack = 0;
|
||||
resumes = 0;
|
||||
constructor(readonly handlers: HlsHandlers) {}
|
||||
load(url: string) {
|
||||
this.loaded = url;
|
||||
@@ -53,6 +54,24 @@ class FakeHls implements HlsPort {
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
resume() {
|
||||
this.resumes++;
|
||||
}
|
||||
/** hls.js gave up on a request that never answered in time: no response, so no status. */
|
||||
timedOut(details = "fragLoadTimeOut") {
|
||||
this.handlers.onError({
|
||||
fatal: true,
|
||||
network: true,
|
||||
status: null,
|
||||
timeout: true,
|
||||
type: "networkError",
|
||||
details,
|
||||
});
|
||||
}
|
||||
/** A fragment arrived: media is getting through. */
|
||||
arrived() {
|
||||
this.handlers.onFragLoaded({ type: "main", bytes: 1_000_000, durationS: 6 });
|
||||
}
|
||||
getAudioTrack() {
|
||||
return this.audioTrack;
|
||||
}
|
||||
@@ -72,6 +91,7 @@ class FakeHls implements HlsPort {
|
||||
fatal: false,
|
||||
network: true,
|
||||
status: 410,
|
||||
timeout: false,
|
||||
type: "networkError",
|
||||
details: "fragLoadError",
|
||||
});
|
||||
@@ -82,6 +102,7 @@ class FakeHls implements HlsPort {
|
||||
fatal: false,
|
||||
network: true,
|
||||
status,
|
||||
timeout: false,
|
||||
type: "networkError",
|
||||
details: "fragLoadError",
|
||||
});
|
||||
@@ -92,6 +113,7 @@ class FakeHls implements HlsPort {
|
||||
fatal: true,
|
||||
network: true,
|
||||
status,
|
||||
timeout: false,
|
||||
type: "networkError",
|
||||
details: "levelLoadError",
|
||||
});
|
||||
@@ -579,6 +601,7 @@ describe("a server that is restarting (a deploy)", () => {
|
||||
fire();
|
||||
await asks[1]!.resolve(hls("A"));
|
||||
hlses[1]!.handlers.onManifestParsed();
|
||||
hlses[1]!.arrived(); // back: media gets through again
|
||||
advance(AWAY_WINDOW_MS + 1);
|
||||
hlses[1]!.lost(502); // a second deploy, long after the first
|
||||
expect(engine.getSnapshot().error).toBeNull();
|
||||
@@ -608,6 +631,93 @@ describe("a server that is restarting (a deploy)", () => {
|
||||
hlses[0]!.lost(500);
|
||||
expect(engine.getSnapshot().error).toBe("generic");
|
||||
});
|
||||
|
||||
it("takes a direct stream's answer for the server being back: it has no fragments to show it", async () => {
|
||||
const { engine, asks, fire, advance } = setup();
|
||||
engine.start("A", 0);
|
||||
await asks[0]!.reject({ status: 502 });
|
||||
fire();
|
||||
await asks[1]!.resolve(direct("A"));
|
||||
advance(AWAY_WINDOW_MS + 1);
|
||||
engine.start("A", 100); // a second deploy, long after the first
|
||||
await asks[2]!.reject({ status: 502 });
|
||||
expect(engine.getSnapshot().error).toBeNull();
|
||||
});
|
||||
|
||||
it("ends in an error when the server answers every session but none of them delivers", async () => {
|
||||
// Every answer used to start the absence over, so this asked for a new session forever.
|
||||
const { engine, asks, hlses, fire } = setup();
|
||||
engine.start("A", 0);
|
||||
await asks[0]!.resolve(hls("A"));
|
||||
hlses[0]!.handlers.onManifestParsed();
|
||||
hlses[0]!.lost(0);
|
||||
for (let i = 1; i < 40 && fire(); i++) {
|
||||
await asks[i]!.resolve(hls("A", 0, `s-${i}`));
|
||||
hlses[i]!.handlers.onManifestParsed();
|
||||
hlses[i]!.lost(0);
|
||||
}
|
||||
expect(engine.getSnapshot().error).toBe("generic");
|
||||
expect(asks.length).toBeLessThan(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a link too slow for the stream", () => {
|
||||
// Measured on prod (2026-11-04, a work network at ~4 Mbit/s against 11–16 MB remux segments): an
|
||||
// audio segment waited behind a video one past hls.js's time to first byte, five times, and gave up
|
||||
// with no answer. That was taken for a server away: a new session, the same segment lost again, a
|
||||
// restart every half minute until the viewer closed the player.
|
||||
it("resumes loading in the same session, and asks for no new one", async () => {
|
||||
const { engine, asks, hlses, timers } = setup();
|
||||
engine.start("A", 453);
|
||||
await asks[0]!.resolve(hls("A", 453));
|
||||
hlses[0]!.handlers.onManifestParsed();
|
||||
hlses[0]!.timedOut();
|
||||
expect(asks).toHaveLength(1);
|
||||
expect(timers.filter((t) => t.armed)).toHaveLength(0);
|
||||
expect(hlses[0]!.resumes).toBe(1);
|
||||
expect(hlses[0]!.destroyed).toBe(false);
|
||||
expect(engine.getSnapshot()).toMatchObject({ ready: true, error: null });
|
||||
});
|
||||
|
||||
it.each(["fragLoadTimeOut", "levelLoadTimeOut", "audioTrackLoadTimeOut"])(
|
||||
"treats a %s the same way",
|
||||
async (details) => {
|
||||
const { engine, asks, hlses } = setup();
|
||||
engine.start("A", 0);
|
||||
await asks[0]!.resolve(hls("A"));
|
||||
hlses[0]!.timedOut(details);
|
||||
expect(asks).toHaveLength(1);
|
||||
expect(hlses[0]!.resumes).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps going for as long as media still arrives, however slowly", async () => {
|
||||
const { engine, asks, hlses, advance } = setup();
|
||||
engine.start("A", 0);
|
||||
await asks[0]!.resolve(hls("A"));
|
||||
hlses[0]!.handlers.onManifestParsed();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
hlses[0]!.timedOut();
|
||||
advance(60_000);
|
||||
hlses[0]!.arrived();
|
||||
}
|
||||
hlses[0]!.timedOut();
|
||||
expect(engine.getSnapshot().error).toBeNull();
|
||||
expect(asks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says the connection is too slow once nothing has arrived for the whole window", async () => {
|
||||
const { engine, asks, hlses, advance } = setup();
|
||||
engine.start("A", 0);
|
||||
await asks[0]!.resolve(hls("A"));
|
||||
hlses[0]!.handlers.onManifestParsed();
|
||||
hlses[0]!.timedOut();
|
||||
advance(AWAY_WINDOW_MS + 1);
|
||||
hlses[0]!.timedOut();
|
||||
expect(engine.getSnapshot().error).toBe("slow");
|
||||
expect(hlses[0]!.resumes).toBe(1);
|
||||
expect(asks).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("play intent while a session is asked for", () => {
|
||||
|
||||
Reference in New Issue
Block a user