Skip to content

Commit f42f76d

Browse files
committed
Skip latency samples when the playhead matches no tracked segment
The oldest-segment fallback inflated the latency estimate by up to a whole playlist window whenever segment bookkeeping was stale (mid-seek, right after a jump or live-edge resync). A run of those phantom samples flips the median and drives phantom catch-up decisions.
1 parent 4fdb277 commit f42f76d

2 files changed

Lines changed: 74 additions & 1 deletion

File tree

latencyCompensator.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,12 @@ function getCurrentlyPlayingSegment(tech) {
400400
}
401401
}
402402

403-
return playlist.segments[0];
403+
// No match: the playhead is outside the tracked window (mid-seek, or the
404+
// bookkeeping is stale right after a jump/live-edge resync). Report no
405+
// segment so this check is skipped — falling back to the oldest segment
406+
// would inflate the latency sample by up to the whole playlist window,
407+
// and a run of those flips the median into phantom catch-ups.
408+
return null;
404409
}
405410

406411
// ---- Rebuffer memory persistence ----

test/segment-lookup.test.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
Regression: getCurrentlyPlayingSegment used to fall back to
3+
playlist.segments[0] (the OLDEST segment) when no segment matched the
4+
playhead -- segment.end is undefined for un-buffered segments, or the
5+
playhead sits past every tracked end right after a seek/jump. That phantom
6+
sample inflated the latency estimate by up to a whole playlist window, and
7+
a run of them flips the 5-sample median into phantom catch-up decisions.
8+
It must instead report no segment, so check() skips the sample entirely.
9+
*/
10+
11+
import { test } from "node:test";
12+
import assert from "node:assert";
13+
import LatencyCompensator from "../latencyCompensator.js";
14+
import { FakeLivePlayer } from "./fake-player.js";
15+
16+
test("regression: playhead matching no segment reports idle and produces no latency sample", (t) => {
17+
t.mock.timers.enable({
18+
apis: ["setInterval", "setTimeout", "Date"],
19+
now: 1_000_000_000_000,
20+
});
21+
t.mock.method(console, "info", () => {});
22+
t.mock.method(console, "log", () => {});
23+
24+
const model = new FakeLivePlayer({
25+
segDur: 2,
26+
initialLatencySec: 8,
27+
initialBufferSec: 6,
28+
});
29+
const comp = new LatencyCompensator(model);
30+
comp.setClockSkew(0);
31+
const stats = [];
32+
comp.onStats = (s) => stats.push(s);
33+
34+
// Force the no-match condition the bug keyed on: every segment's `end`
35+
// is undefined (VHS bookkeeping for segments it hasn't buffered), while
36+
// valid program-date-time stamps stay present.
37+
const tech = model.tech({ IWillNotUseThisInPlugins: true });
38+
const realMedia = tech.vhs.playlists.media;
39+
tech.vhs.playlists.media = () => {
40+
const playlist = realMedia();
41+
playlist.segments = playlist.segments.map((s) => ({
42+
...s,
43+
end: undefined,
44+
}));
45+
return playlist;
46+
};
47+
48+
// Get past the startup grace period, then drive exactly one check tick.
49+
t.mock.timers.tick(21_000);
50+
model.advanceTo(Date.now());
51+
comp.enable();
52+
comp.check();
53+
54+
assert.strictEqual(stats.length, 1, "one check must emit one stats sample");
55+
assert.strictEqual(stats[0].action.type, "idle");
56+
assert.strictEqual(
57+
stats[0].action.reason,
58+
"no segment timing (stream lacks program-date-time?)",
59+
"the check must be skipped for lack of segment timing, not decided on a phantom segments[0] sample"
60+
);
61+
assert.strictEqual(
62+
stats[0].latency,
63+
null,
64+
"no latency sample may be produced when no segment matches the playhead"
65+
);
66+
67+
comp.disable();
68+
});

0 commit comments

Comments
 (0)