Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,32 @@ the public-API contract.

## [Unreleased]

_Nothing yet._
### Fixed

- **A live HLS join now takes the backlog the origin is already holding, so the
startup cushion is filled at I/O speed instead of in wall clock (#521).** The
ingest entered a live playlist three segments behind the edge, and three
joined segments finalize only two downstream, because the last one stays open
until the next arrives. The loopback startup cushion wants three, so the first
`/media.m3u8` was withheld until the origin produced its next segment, at
wall-clock speed, with the content for it already sitting in the window. The
bound that caused it was in the wrong unit: `joinStart` targets a coverage in
SECONDS and `edgeOffset` capped that at three SEGMENTS, while the coverage
term already bounds long-segment providers on its own (6 s segments break at
12 s), so the count only ever bound the short-segment sources the 8 s
coverage floor was written for. Measured on `hlsfixture --window 8` with
`play --live --fast-zap`, three runs per row: first picture on a 2 s-segment
channel **2.22 s before, 0.20 s after**, on 1 s segments **0.41 to 1.22 s
before, 0.18 to 0.20 s after**, and that spread is half the finding, since
before the change the cost depended on where in the upstream segment cycle
the tune landed. The join is not paid back as lag: read off the origin's
request log, both arms reach the same upstream segment number at the same
wall clock, so the deeper entry is caught up at I/O speed rather than
standing as a lag behind the live edge. A window at the three-segment floor
is unchanged, a long-segment provider is unchanged, and the oldest listed
segment of a deeper window is now deliberately left alone so the burst does
not race the origin for a segment about to be dropped. Raw MPEG-TS with no
playlist is untouched: there is no window to enter further back into.

## [6.76.1] - 2026-09-09

Expand Down
22 changes: 19 additions & 3 deletions Sources/AetherEngine/IO/HLSIngest/HLSPlaylistTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import Foundation

/// Pure cursor over successive live playlist refreshes. Returns each segment exactly once. Handles join, forward growth, and window-slide (rejoin + discontinuity flag for downstream PTS rebase).
///
/// Join policy: target duration coverage `max(minJoinCoverageSeconds, 1.5 * targetDuration)`, capped at `edgeOffset` segments. Count-only join burst up to 36s of backlog on long-segment providers, which caused a one-time AVPlayer pacing stall a few seconds into every direct session (device repro 2026-06-11). The 1.5x term ensures at least one upstream cadence of buffer across the bursty inter-batch arrival gap (device repro 2026-06-11: ~5s stalls every ~20s with a single-segment join). A shrinking playlist (spec-violating server) is treated as a stall.
/// Join policy: target duration coverage `max(minJoinCoverageSeconds, 1.5 * targetDuration)`, bounded by `edgeOffset` segments and by the eviction margin. Count-only join burst up to 36s of backlog on long-segment providers, which caused a one-time AVPlayer pacing stall a few seconds into every direct session (device repro 2026-06-11). The 1.5x term ensures at least one upstream cadence of buffer across the bursty inter-batch arrival gap (device repro 2026-06-11: ~5s stalls every ~20s with a single-segment join). A shrinking playlist (spec-violating server) is treated as a stall.
///
/// `edgeOffset` is a sanity bound on the burst, not the policy. It used to be 3, which is a COUNT standing in for a burst measured in SECONDS, and the coverage term above already bounds long segments on its own (6s segments break at 12s, 12s segments at 24s). So the count only ever bound SHORT segments, which are exactly the sources the 8s coverage floor was written for, and it cut them to a third of it. The cost was not only buffer: three joined segments yield only TWO finalized ones downstream, because the last one is still open until the next arrives, and the loopback startup cushion wants three. The live join therefore waited one upstream segment duration in wall clock for content the origin was already holding. Measured on `hlsfixture --window 8` with `play --live --fast-zap` (three runs per row, engine 6.76.1 against this change): first picture on a 2s-segment channel 2.22s before, 0.20s after; on 1s segments 0.41 to 1.22s before (it depended on where in the upstream segment cycle the tune landed) against 0.18 to 0.20s after, the phase dependency gone with it. A window three segments deep has nothing deeper to join into and is unchanged. The origin request log is what says the depth is not paid back later: both arms fetch up to the same upstream segment number at the same wall clock, so the burst is caught up at I/O speed rather than becoming a standing lag behind the live edge.
struct HLSPlaylistTracker {
private let edgeOffset: Int // max segments behind the live edge on join
private let minJoinCoverageSeconds: Double // floor for the duration-coverage target
Expand All @@ -20,7 +22,19 @@ struct HLSPlaylistTracker {
/// refresh intervals, well inside the reader's stall budget.
static let sequenceResetRejoinThreshold = 3

init(edgeOffset: Int = 3, minJoinCoverageSeconds: Double = 8) {
/// A window this shallow is joined whole; deeper than this, the oldest listed segment is left
/// where it is. It is the one closest to being dropped, and a join burst that reaches for it
/// races the origin for a 404 on a server that removes rather than retires. The margin is free:
/// the coverage target is met from the rest of the window in every shape that meets it at all.
static let joinEvictionMarginWindowDepth = 3

/// Segments the join may take, which is the count cap narrowed by the eviction margin above.
static func joinSegmentLimit(edgeOffset: Int, windowSegmentCount: Int) -> Int {
guard windowSegmentCount > joinEvictionMarginWindowDepth else { return edgeOffset }
return min(edgeOffset, windowSegmentCount - 1)
}

init(edgeOffset: Int = 8, minJoinCoverageSeconds: Double = 8) {
self.edgeOffset = edgeOffset
self.minJoinCoverageSeconds = minJoinCoverageSeconds
}
Expand All @@ -46,10 +60,12 @@ struct HLSPlaylistTracker {

func joinStart() -> Int {
let coverage = max(minJoinCoverageSeconds, 1.5 * playlist.targetDuration)
let limit = Self.joinSegmentLimit(edgeOffset: edgeOffset,
windowSegmentCount: playlist.segments.count)
var taken = 0
var seconds = 0.0
for segment in playlist.segments.reversed() {
if taken >= edgeOffset { break }
if taken >= limit { break }
if taken > 0, seconds >= coverage { break }
taken += 1
seconds += segment.duration
Expand Down
49 changes: 49 additions & 0 deletions Tests/AetherEngineTests/HLSPlaylistTrackerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,53 @@ final class HLSPlaylistTrackerTests: XCTestCase {
// reader's stall counter toward its ingestStalled terminal trip.
XCTAssertEqual(tracker.stallCount, 0)
}

// MARK: - Default join depth

func testDefaultJoinReachesTheCoverageTargetOnShortSegments() {
// The case the old count cap of 3 defeated: 1s segments want 8s of coverage, and the cap
// handed over 3s. Three joined segments finalize only two downstream (the last is still
// open), one short of the loopback startup cushion, so the join then waited a segment
// duration in wall clock for content the origin already had.
var tracker = HLSPlaylistTracker()
let uris = (0..<12).map { "s\($0)" }
let new = tracker.newSegments(in: playlist(sequence: 40, uris: uris, duration: 1))
XCTAssertEqual(new.map(\.uri), ["s4", "s5", "s6", "s7", "s8", "s9", "s10", "s11"])
}

func testDefaultJoinLeavesTheOldestSegmentOfADeepWindow() {
// Eviction margin: the oldest listed segment is the one closest to being dropped, so the
// burst stops one short of it rather than racing the origin for a 404.
var tracker = HLSPlaylistTracker()
let new = tracker.newSegments(
in: playlist(sequence: 7, uris: ["a", "b", "c", "d", "e", "f", "g", "h"], duration: 1)
)
XCTAssertEqual(new.map(\.uri), ["b", "c", "d", "e", "f", "g", "h"])
}

func testDefaultJoinTakesAFloorDepthWindowWhole() {
// Three segments is as shallow as a live window is expected to get, so there is nothing to
// hold back and the margin does not apply. Byte-identical to the behaviour before the cap
// was raised, which is what keeps the change invisible to a minimal origin.
var tracker = HLSPlaylistTracker()
let new = tracker.newSegments(in: playlist(sequence: 12, uris: ["a", "b", "c"], duration: 1))
XCTAssertEqual(new.map(\.uri), ["a", "b", "c"])
}

func testDefaultJoinIsUnchangedForLongSegments() {
// The raised cap moves nothing here: the coverage term breaks a 6s-segment provider at 12s
// on its own, which is why the count was only ever binding on short segments.
var tracker = HLSPlaylistTracker()
let new = tracker.newSegments(
in: playlist(sequence: 3, uris: ["a", "b", "c", "d", "e", "f"], duration: 6)
)
XCTAssertEqual(new.map(\.uri), ["e", "f"])
}

func testJoinSegmentLimitAppliesTheMarginOnlyBelowTheCap() {
XCTAssertEqual(HLSPlaylistTracker.joinSegmentLimit(edgeOffset: 8, windowSegmentCount: 3), 8)
XCTAssertEqual(HLSPlaylistTracker.joinSegmentLimit(edgeOffset: 8, windowSegmentCount: 4), 3)
XCTAssertEqual(HLSPlaylistTracker.joinSegmentLimit(edgeOffset: 8, windowSegmentCount: 9), 8)
XCTAssertEqual(HLSPlaylistTracker.joinSegmentLimit(edgeOffset: 8, windowSegmentCount: 20), 8)
}
}
7 changes: 4 additions & 3 deletions Tests/AetherEngineTests/Issue177IngestPrefetchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ struct Issue177IngestPrefetchTests {

@Test("backlog fetches overlap within the window and commit in playlist order")
func prefetchOverlapsAndPreservesOrder() throws {
// First playlist advertises seg0..7 (the tracker joins on the last 3: seg5..7); the
// First playlist advertises seg0..7 (1 s segments, so the tracker's 8 s coverage target
// wants the whole window and the eviction margin leaves the oldest, joining seg1..7); the
// refresh advertises seg0..15 with ENDLIST, delivering seg8..15 as one 8-segment batch
// that exercises the full prefetch window.
let segmentCount = 16
Expand All @@ -271,8 +272,8 @@ struct Issue177IngestPrefetchTests {

#expect(reader.resolveSegmentFormatHint() == "mpegts")

// Join takes seg5..7 per the tracker's edge policy, the refresh appends seg8..15.
let expected = segments[5...].reduce(Data(), +)
// Join takes seg1..7 per the tracker's edge policy, the refresh appends seg8..15.
let expected = segments[1...].reduce(Data(), +)
let got = drain(reader, expectedBytes: expected.count, timeout: 90)

#expect(reader.terminalError == nil)
Expand Down
16 changes: 16 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,22 @@ cadence and the holdback follows it down, so the win belongs to the source GOP r
`TARGETDURATION` can never fall below `ceil(max EXTINF)`, and a long-GOP source therefore keeps most of
its runway under either profile.

**An HLS source with a window of its own now fills that cushion at the join rather than in wall clock**
(6.77.0). The ingest used to enter a live playlist three segments behind the edge, and three joined
segments finalize only two downstream, because the last one stays open until the next arrives. The
cushion wants three, so the gate then waited one upstream segment duration for content the origin was
already holding in its window. The join now takes the coverage its own policy always targeted, which on
short segments is several times three. Measured against `hlsfixture --window 8` with `play --live
--fast-zap`, three runs per row: first picture on a 2 s-segment channel **2.22 s before, 0.20 s after**;
on 1 s segments **0.41 to 1.22 s before, 0.18 to 0.20 s after**, and the spread is the second half of
the finding, since before the change the number depended on where in the upstream segment cycle the tune
landed. A window at the three-segment floor has nothing deeper to offer and is unchanged, and so is a
long-segment provider, whose coverage target was already met inside the old bound. The deeper entry is
not paid back later: both arms fetch up to the same upstream segment number at the same wall clock, so
it is caught up at I/O speed instead of becoming a standing lag behind the live edge. What it does not
touch is a source with no playlist at all (raw MPEG-TS over HTTP), where there is no window to enter
further back into and the content genuinely does not exist yet.

### The tail after the first serve, and which signal survives it

A serve is not motion. Past it AVPlayer can present the first frame, publish
Expand Down
Loading