fix: prevent playlist from referencing segments deleted by high-water mark

The segmentsToKeepBefore buffer could extend the playlist window below
   #highestDeletedBelow, causing the playlist to reference segment files
   that had already been deleted from disk. Clients requesting those
   segments would get 404s.

   Add a segmentFloor option to FilterBeforeSegmentNumber that acts as a
   hard lower bound for minSeg, and pass #highestDeletedBelow as the floor
   from HlsSession.trimPlaylist().
This commit is contained in:
Christian Benincasa
2026-04-13 16:16:36 -04:00
parent 19e8e60d9a
commit ce34bef8c5
3 changed files with 31 additions and 30 deletions

View File

@@ -921,18 +921,17 @@ describe('HlsPlaylistMutator', () => {
});
describe('high-water mark floor protection', () => {
// These tests verify the invariant that HlsSession's #highestDeletedBelow
// relies on: the before_segment_number filter acts as a hard floor, so the
// playlist never references segments below (segmentNumber - segmentsToKeepBefore).
// These tests verify that the segmentFloor option prevents the playlist
// from referencing segments that have been deleted from disk. Without it,
// segmentsToKeepBefore can extend the window below the deletion threshold.
const start = dayjs('2024-10-18T14:00:00.000-0400');
const largeOpts = { ...defaultOpts, maxSegmentsToKeep: 20 };
it('Scenario B: segmentNumber=0 (empty _minByIp) selects segments from the start', () => {
// Without the high-water mark fix, stale cleanup empties _minByIp and
// minSegmentRequested returns 0. This causes trimPlaylist to serve segments
// from the very beginning of the playlist — all of which have been deleted.
// This test documents the behavior that #highestDeletedBelow prevents.
it('without segmentFloor, segmentNumber=0 selects segments from the start', () => {
// Without segmentFloor, stale cleanup empties _minByIp and
// minSegmentRequested returns 0, causing the playlist to serve
// segments from the very beginning — all of which may be deleted.
const lines = createPlaylist(100);
const result = mutator.trimPlaylist(
@@ -946,17 +945,17 @@ describe('HlsPlaylistMutator', () => {
largeOpts,
);
// minSeg = max(0-10, 0) = 0 → all 100 segments pass the filter (≥20) → take first 20 = segs 0..19
// minSeg = max(0-10, 0) = 0 → all 100 pass filter → take first 20
expect(result.playlist).toContain('data000000.ts');
expect(result.playlist).toContain('data000019.ts');
expect(result.playlist).not.toContain('data000020.ts');
});
it('Scenario B fix: high-water mark as segmentNumber floors the playlist above deleted range', () => {
it('segmentFloor prevents playlist from referencing deleted segments', () => {
// After deleteOldSegments(190), #highestDeletedBelow = 190.
// Math.max(minSegmentRequested=0, highestDeletedBelow=190) = 190 is used
// as segmentNumber, so the playlist starts at 180 (190 - keepBefore:10)
// rather than 0, avoiding any reference to deleted segments.
// segmentNumber = max(minSegmentRequested=0, 190) = 190
// Without segmentFloor: minSeg = max(190-10, 0) = 180 → refs 180-189 which are deleted!
// With segmentFloor=190: minSeg = max(180, 190) = 190 → starts at 190, all exist.
const lines = createPlaylist(300);
const result = mutator.trimPlaylist(
@@ -965,23 +964,23 @@ describe('HlsPlaylistMutator', () => {
type: 'before_segment_number',
segmentNumber: 190,
segmentsToKeepBefore: 10,
segmentFloor: 190,
},
lines,
largeOpts,
);
// minSeg = max(190-10, 0) = 180; segs 180..299 (120 ≥ 20) → take first 20 = segs 180..199
expect(result.playlist).toContain('data000180.ts');
expect(result.playlist).toContain('data000199.ts');
expect(result.playlist).not.toContain('data000179.ts');
expect(result.playlist).not.toContain('data000000.ts');
// segs 190..299 (110 ≥ 20) → take first 20 = segs 190..209
expect(result.playlist).toContain('data000190.ts');
expect(result.playlist).toContain('data000209.ts');
expect(result.playlist).not.toContain('data000189.ts');
expect(result.playlist).not.toContain('data000180.ts');
});
it('Scenario A: stale client removal jump still floors above deleted range', () => {
// Client A was at seg 100, stale cleanup removes it, leaving Client B at seg 200.
// deleteOldSegments(190) ran, so #highestDeletedBelow = 190.
// Math.max(minSegmentRequested=200, highestDeletedBelow=190) = 200.
// Playlist must not include segments below 190 (200 - keepBefore:10).
it('segmentFloor does not affect segments above the floor', () => {
// Client B at seg 200, #highestDeletedBelow = 190.
// segmentNumber = max(200, 190) = 200, segmentFloor = 190
// minSeg = max(200-10, 190) = 190 → keeps 190..209
const lines = createPlaylist(300);
const result = mutator.trimPlaylist(
@@ -990,21 +989,18 @@ describe('HlsPlaylistMutator', () => {
type: 'before_segment_number',
segmentNumber: 200,
segmentsToKeepBefore: 10,
segmentFloor: 190,
},
lines,
largeOpts,
);
// minSeg = max(200-10, 0) = 190; segs 190..299 (110 ≥ 20) → take first 20 = segs 190..209
expect(result.playlist).toContain('data000190.ts');
expect(result.playlist).toContain('data000209.ts');
expect(result.playlist).not.toContain('data000189.ts');
expect(result.playlist).not.toContain('data000100.ts');
});
it('floor does not over-trim when segmentNumber equals the live edge', () => {
// When #highestDeletedBelow and minSegmentRequested agree (normal single-client case),
// the playlist should include the last keepBefore segments before the current position.
it('segmentFloor=0 behaves the same as no floor', () => {
const lines = createPlaylist(50);
const result = mutator.trimPlaylist(
@@ -1013,12 +1009,13 @@ describe('HlsPlaylistMutator', () => {
type: 'before_segment_number',
segmentNumber: 30,
segmentsToKeepBefore: 10,
segmentFloor: 0,
},
lines,
largeOpts,
);
// minSeg = max(30-10, 0) = 20; segs 20..49 (30 ≥ 20) → take first 20 = segs 20..39
// minSeg = max(30-10, 0) = 20; segs 20..49 (30 ≥ 20) → take first 20
expect(result.playlist).toContain('data000020.ts');
expect(result.playlist).toContain('data000039.ts');
expect(result.playlist).not.toContain('data000019.ts');

View File

@@ -33,6 +33,9 @@ type FilterBeforeSegmentNumber = {
type: 'before_segment_number';
segmentNumber: number;
segmentsToKeepBefore: number;
// Hard floor: never include segments below this number. Prevents the
// playlist from referencing segments that have been deleted from disk.
segmentFloor?: number;
};
export type HlsPlaylistFilterOptions =
@@ -155,7 +158,7 @@ export class HlsPlaylistMutator {
(beforeSeg) => {
const minSeg = Math.max(
beforeSeg.segmentNumber - beforeSeg.segmentsToKeepBefore,
0,
beforeSeg.segmentFloor ?? 0,
);
return seq.collect(allSegments, (segment) => {
const fileName = basename(segment.line);

View File

@@ -113,6 +113,7 @@ export class HlsSession extends BaseHlsSession<HlsSessionOptions> {
this.#highestDeletedBelow,
),
segmentsToKeepBefore: 10,
segmentFloor: this.#highestDeletedBelow,
};
return Result.attemptAsync(async () => {
return await this.lock.runExclusive(async () => {