Skip to content

Commit 7ea32cf

Browse files
fix(live): measure a window slide against the consumer's next fetch
The AE#441 retest read `live window slid past the consumer` four times in the 48 s after a deep rewound landing, every one of them firstVisible == consumerTarget + 1, with no stall and no cache miss behind any of them. The line was reading the LAST fetch when the cost is decided by the NEXT one. `declareTarget` fires as a segment is served and the consumer walks indices forward, so everything below consumerTarget is already in AVPlayer's buffer and the index it asks for next is consumerTarget + 1. A viewer parked at the floor therefore sits one segment below firstVisible for part of every slide, the same one-segment sawtooth residentFloorOutputSeconds() has against the rendered playhead. Its next fetch is firstVisible itself, which is resident. Chasing that turned up the reason the tolerance is not merely cosmetic: at exactly that off-by-one, eviction unlinks the segment currently being served. A serve holds a URL rather than a file handle (mediaSegmentURL hands peekURL's result to a response that stats and opens it afterwards), so the window is a 404 for an index the playlist offered when it was asked for. Eviction now stops at the fetch point, which is the bound evictBelow already documented for itself, and never trails firstVisible by more than that one segment so a consumer that stopped fetching cannot pin retention behind it. Not observed to fire on loopback, where a serve completes in well under a segment; closed by construction. The harness could not reach this regime at all, which is why the local counter-run read 0 lines against the retest's 4. `live --rewind-hold` filled `window * 0.6` before rewinding, so the floor was always the session's own start rather than an eviction frontier. It now fills past full, warns when --seconds leaves too little hold behind that, and reports the shape of each latched line instead of only counting them. Measured, 220 s parked at the floor, window 60 s, paced 1x: before 4 latched lines, all gap 1, 0 stalls, max inversion 3.80 s (TD 4) after 0 latched lines, 0 stalls, max inversion 3.60 s Unpaced backlog burst, window 30 s, after the change: 1326 lines, gap up to 12, 1326 of them above one segment, 8 stalled ticks. The two classes stay separated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WbciAqCSxpaiUpTumrCuA9
1 parent e19dffb commit 7ea32cf

4 files changed

Lines changed: 134 additions & 10 deletions

File tree

Sources/AetherEngine/Video/SegmentCache.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,8 +346,9 @@ final class SegmentCache: @unchecked Sendable {
346346
return currentTargetIndex >= target
347347
}
348348

349-
/// Evict segments strictly below cutoff (= live firstVisible). Bounded by firstVisible <= currentTargetIndex
350-
/// so it only removes segments the playlist already dropped; pruneOutsideWindow handles the forward bound.
349+
/// Evict segments strictly below cutoff, which the live caller bounds at the consumer's own fetch
350+
/// point (`VideoSegmentProvider.liveEvictionFloor`) so this never unlinks the segment a response is
351+
/// about to stat; pruneOutsideWindow handles the forward bound.
351352
func evictBelow(_ cutoff: Int) {
352353
condition.lock()
353354
var doomed: [URL] = []

Sources/AetherEngine/Video/VideoSegmentProvider.swift

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,8 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable {
590590
// its own lock; nesting the two here would invert the ordering evictBelow's async
591591
// hop exists to avoid).
592592
let consumerTarget = cacheRef.targetIndex
593-
cacheRef.evictBelow(cutoff)
593+
cacheRef.evictBelow(Self.liveEvictionFloor(firstVisible: cutoff,
594+
consumerTarget: consumerTarget))
594595
self?.noteWindowSlideRelativeToConsumer(cutoff: cutoff, consumerTarget: consumerTarget)
595596
}
596597
}
@@ -606,6 +607,36 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable {
606607
return (segments.count, 0, refreshCounter, false, 0)
607608
}
608609

610+
/// AE#441 round 3: what a window slide costs is decided by the consumer's NEXT fetch, not its last.
611+
///
612+
/// `declareTarget` is called as a segment is served and the consumer walks indices forward one at a
613+
/// time, so everything strictly below `consumerTarget` is already in AVPlayer's own buffer and the
614+
/// index it will ask for next is `consumerTarget + 1`. A viewer parked at the floor therefore sits
615+
/// at `firstVisible - 1` for part of every segment, by the same one-segment sawtooth
616+
/// `residentFloorOutputSeconds()` has against the rendered playhead: the slide moves in whole
617+
/// segments while the consumer moves continuously. Its next fetch is `firstVisible` itself, which is
618+
/// resident, so nothing is missed. From `firstVisible - 2` down the index it is about to ask for has
619+
/// been deleted, and that is the cache miss this line exists to name.
620+
static func windowSlidPastConsumer(firstVisible: Int, consumerTarget: Int) -> Bool {
621+
return consumerTarget + 1 < firstVisible
622+
}
623+
624+
/// The lowest index a window slide may unlink, which is not always the playlist's new first visible.
625+
///
626+
/// A serve holds a URL, not a file handle: `mediaSegmentURL` hands `peekURL`'s result to a response
627+
/// that stats and opens the file afterwards, so unlinking the segment currently being served turns
628+
/// into a 404 for an index the playlist offered when it was asked for. And a viewer riding the floor
629+
/// puts the slide exactly one segment above the fetch point routinely, not rarely (measured: every
630+
/// latched line of a 220 s parked run had `firstVisible == consumerTarget + 1`). So eviction stops at
631+
/// the fetch point, which is the bound `evictBelow` already documents for itself.
632+
///
633+
/// Bounded on the other side too: the floor never trails `firstVisible` by more than one segment, so
634+
/// a consumer that has stopped fetching entirely cannot pin retention behind it.
635+
static func liveEvictionFloor(firstVisible: Int, consumerTarget: Int) -> Int {
636+
guard consumerTarget >= 0 else { return firstVisible }
637+
return max(firstVisible - 1, min(firstVisible, consumerTarget))
638+
}
639+
609640
/// The sliding window overtaking the consumer's fetch point is the failure mode the removed advance
610641
/// park used to make impossible (it capped the producer 10 segments ahead of that point). Live is
611642
/// source-paced, so a real-time origin cannot get there; an origin that hands over more than one
@@ -616,7 +647,7 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable {
616647
private func noteWindowSlideRelativeToConsumer(cutoff: Int, consumerTarget: Int) {
617648
guard consumerTarget >= 0 else { return }
618649
stateLock.lock()
619-
let outside = consumerTarget < cutoff
650+
let outside = Self.windowSlidPastConsumer(firstVisible: cutoff, consumerTarget: consumerTarget)
620651
let shouldLog = outside && !_liveConsumerOutsideWindowLatched
621652
_liveConsumerOutsideWindowLatched = outside
622653
stateLock.unlock()

Sources/aetherctl/LiveCmd.swift

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -785,10 +785,18 @@ private func liveRewindHoldTest(url: URL, seconds playSeconds: Double, dvrWindow
785785
return 1
786786
}
787787

788-
// Fill the window before rewinding: parking just above the floor only means anything once the
789-
// floor is retention rather than the session's own start.
790-
let fillFor = min(max(10.0, dvrWindow * 0.6), playSeconds * 0.4)
791-
print(String(format: " FILL: %.0fs before the rewind (window=%.0fs)", fillFor, dvrWindow))
788+
// Fill the window before rewinding, and fill it PAST full. A floor below a window that has not
789+
// filled yet is the session's own start, which never moves, so a park against it measures a
790+
// regime that has no eviction in it at all. The first version of this leg filled `window * 0.6`
791+
// and therefore reported a clean bill of health for a regime it could not reach.
792+
let fillFor = dvrWindow + 12.0
793+
print(String(format: " FILL: %.0fs before the rewind (window=%.0fs, filled past full)",
794+
fillFor, dvrWindow))
795+
if playSeconds < fillFor + 30.0 {
796+
print(String(format: " WARNING: --seconds %.0f leaves %.0fs of hold after a %.0fs fill; "
797+
+ "the window will not be sliding for long enough to read anything",
798+
playSeconds, playSeconds - fillFor, fillFor))
799+
}
792800
let startTime = Date()
793801
while Date().timeIntervalSince(startTime) < fillFor {
794802
try? await Task.sleep(nanoseconds: 500_000_000)
@@ -830,7 +838,10 @@ private func liveRewindHoldTest(url: URL, seconds playSeconds: Double, dvrWindow
830838
print(String(format: " ticks with floor above the playhead: %d/%d (max %.2fs)",
831839
invertedTicks, holdTicks, maxInversion))
832840
print(String(format: " ticks where the clock did not advance: %d/%d", stalledTicks, holdTicks))
833-
print(" 'live window slid past the consumer': \(slides.count)")
841+
print(" 'live window slid past the consumer': \(slides.count)"
842+
+ (slides.count > 0
843+
? " (max firstVisible-consumerTarget gap \(slides.maxGap), \(slides.gapsAboveOne) above one segment)"
844+
: ""))
834845
print(" final state: \(finalState)")
835846

836847
// The inversion alone is not the defect; the window passing the consumer's FETCH point is.
@@ -843,11 +854,27 @@ private func liveRewindHoldTest(url: URL, seconds playSeconds: Double, dvrWindow
843854
return 0
844855
}
845856

857+
/// Counts the latched slide line AND reads its shape. The gap `firstVisible - consumerTarget` is the
858+
/// whole reading: a gap of 1 means the consumer's next fetch is the new first visible segment, which
859+
/// is still resident, while a gap above 1 means the segment it will ask for next is already deleted.
846860
private final class SlideCounter: @unchecked Sendable {
847861
private let lock = NSLock()
848862
private(set) var count = 0
863+
private(set) var maxGap = 0
864+
private(set) var gapsAboveOne = 0
849865
func note(_ line: String) {
850866
lock.lock(); defer { lock.unlock() }
851-
if line.contains("live window slid past the consumer") { count += 1 }
867+
guard line.contains("live window slid past the consumer") else { return }
868+
count += 1
869+
guard let first = Self.intField("firstVisible=", in: line),
870+
let target = Self.intField("consumerTarget=", in: line) else { return }
871+
let gap = first - target
872+
maxGap = max(maxGap, gap)
873+
if gap > 1 { gapsAboveOne += 1 }
874+
}
875+
private static func intField(_ key: String, in line: String) -> Int? {
876+
guard let r = line.range(of: key) else { return nil }
877+
let rest = line[r.upperBound...].prefix { $0 == "-" || $0.isNumber }
878+
return Int(rest)
852879
}
853880
}

Tests/AetherEngineTests/Issue441ResidentLiveFloorTests.swift

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,68 @@ struct Issue441ResidentLiveFloorTests {
112112
#expect(cache.highestResidentIndex == nil)
113113
}
114114
}
115+
116+
/// AE#441 round 3: the retest read `live window slid past the consumer` four times in the 48 s after a
117+
/// deep rewound landing, every one of them `firstVisible == consumerTarget + 1`, with no stall and no
118+
/// cache miss behind any of them. Reproduced on the harness (`live --rewind-hold`, 220 s, window 60 s):
119+
/// four latched lines, gap 1 on all four. So the line was reading the LAST fetch when the cost is
120+
/// decided by the NEXT one, and the slide that reaches the fetch point was also unlinking the segment
121+
/// under the serve.
122+
@Suite("AE#441 round 3 a window slide is measured against the consumer's next fetch")
123+
struct Issue441ConsumerFetchPointTests {
124+
125+
// MARK: - The discriminator
126+
127+
/// The sawtooth on the fetch axis. A viewer parked at the floor sits one segment below the new
128+
/// first-visible index for part of every slide; the index it asks for next is that very segment.
129+
@Test("one segment behind the window is the parked viewer's ordinary position, not a defect")
130+
func oneSegmentBehindIsNotADefect() {
131+
#expect(VideoSegmentProvider.windowSlidPastConsumer(firstVisible: 5, consumerTarget: 4) == false)
132+
#expect(VideoSegmentProvider.windowSlidPastConsumer(firstVisible: 33, consumerTarget: 32) == false)
133+
}
134+
135+
/// Two segments behind is the real thing: `consumerTarget + 1` has already been deleted, so the
136+
/// consumer's next request is a miss whatever the playlist says.
137+
@Test("two segments behind means the next fetch is already deleted")
138+
func twoSegmentsBehindIsTheDefect() {
139+
#expect(VideoSegmentProvider.windowSlidPastConsumer(firstVisible: 6, consumerTarget: 4))
140+
#expect(VideoSegmentProvider.windowSlidPastConsumer(firstVisible: 40, consumerTarget: 11))
141+
}
142+
143+
/// A consumer at or ahead of the window is the healthy steady state and was never a defect.
144+
@Test("a consumer inside the window stays silent")
145+
func consumerInsideTheWindow() {
146+
#expect(VideoSegmentProvider.windowSlidPastConsumer(firstVisible: 5, consumerTarget: 5) == false)
147+
#expect(VideoSegmentProvider.windowSlidPastConsumer(firstVisible: 5, consumerTarget: 12) == false)
148+
}
149+
150+
// MARK: - The eviction floor
151+
152+
/// The case the retest exposed: the slide reaching `consumerTarget + 1` used to unlink the segment
153+
/// whose URL a response was about to stat, which is a 404 for an index the playlist offered.
154+
@Test("eviction stops at the segment being served")
155+
func evictionSpareTheServedSegment() {
156+
#expect(VideoSegmentProvider.liveEvictionFloor(firstVisible: 5, consumerTarget: 4) == 4)
157+
}
158+
159+
/// A consumer already inside the window costs nothing: the floor is the playlist's own.
160+
@Test("a consumer inside the window does not hold eviction back")
161+
func consumerInsideDoesNotHoldBack() {
162+
#expect(VideoSegmentProvider.liveEvictionFloor(firstVisible: 5, consumerTarget: 5) == 5)
163+
#expect(VideoSegmentProvider.liveEvictionFloor(firstVisible: 5, consumerTarget: 40) == 5)
164+
}
165+
166+
/// The other side of the bound. A consumer that stopped fetching entirely must not pin retention
167+
/// behind it, so the floor never trails the window by more than the one served segment.
168+
@Test("a stalled consumer cannot pin retention behind the window")
169+
func stalledConsumerCannotPinRetention() {
170+
#expect(VideoSegmentProvider.liveEvictionFloor(firstVisible: 40, consumerTarget: 4) == 39)
171+
#expect(VideoSegmentProvider.liveEvictionFloor(firstVisible: 900, consumerTarget: 0) == 899)
172+
}
173+
174+
/// Before the first fetch there is no point to protect.
175+
@Test("no declared fetch point evicts to the window")
176+
func noConsumerYet() {
177+
#expect(VideoSegmentProvider.liveEvictionFloor(firstVisible: 12, consumerTarget: -1) == 12)
178+
}
179+
}

0 commit comments

Comments
 (0)