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:
2026-11-07 15:09:22 +01:00
parent 25aed6635f
commit c0682aa572
13 changed files with 495 additions and 30 deletions
+110
View File
@@ -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", () => {