Skip to content

Commit 2500982

Browse files
author
Vincent Herbst
committed
Merge branch 'fix/ae446-withdraw-blocking-reload-when-source-stops'
2 parents a9852a4 + 77a5922 commit 2500982

3 files changed

Lines changed: 96 additions & 8 deletions

File tree

Sources/AetherEngine/Network/HLSLocalServer.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,10 @@ final class HLSLocalServer: @unchecked Sendable {
657657
let normalizedPath = (routePath == "/audio.m3u8") ? "/media.m3u8" : routePath
658658

659659
// #50 diag: promoted to .info so the host mirror names the failing path without a verbose build. Revert once #50 is root-caused.
660-
EngineLog.emit("[HLSLocalServer] \(firstLine)", category: .hlsServer)
660+
// AE#446: the fd is what says whether a blocking-reload hold is parking the connection the
661+
// next segment request needs. Same fd on both, and the segment could not be read until the
662+
// hold returned; different fds, and the client chose not to fetch.
663+
EngineLog.emit("[HLSLocalServer] \(firstLine) fd=\(fd)", category: .hlsServer)
661664
// #227 diag: name each distinct client once, so an AirPlay session shows whether the receiver fetches
662665
// for itself (its own LAN address appears) or the sender pulls everything (only 127.0.0.1 / own IP).
663666
if let peer = Self.peerAddress(of: fd) {

Sources/AetherEngine/Video/VideoSegmentProvider.swift

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,10 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable {
291291
/// RFC 8216 requires TARGETDURATION to stay constant for the lifetime of a media playlist.
292292
/// Guarded by stateLock and preserved across in-provider producer reopens.
293293
private var liveTargetDurationSeal = LiveTargetDurationSeal()
294+
/// AE#446: wall time of the newest finalized live segment, and the latch that says the source
295+
/// stopped delivering. See `liveDeliveryStalled`.
296+
private var _lastLiveSegmentFinalizedAt: Date?
297+
private var _liveDeliveryStalledLatched = false
294298
private var refreshCounter: Int = 0
295299
/// EXT-X-MEDIA-SEQUENCE first index; monotonically advancing, stays 0 for VOD.
296300
private var _liveFirstVisible: Int = 0
@@ -428,6 +432,7 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable {
428432
durationSeconds: durationSeconds,
429433
discontinuous: discontinuous
430434
))
435+
_lastLiveSegmentFinalizedAt = Date()
431436
_liveRecentDurations.append(durationSeconds)
432437
if _liveRecentDurations.count > Self.liveRecentDurationSampleCount {
433438
_liveRecentDurations.removeFirst(_liveRecentDurations.count - Self.liveRecentDurationSampleCount)
@@ -1190,10 +1195,51 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable {
11901195
}
11911196
var liveBlockingReloadEnabled: Bool {
11921197
Self.resolveLiveBlockingReload(halted: liveProductionHalted,
1198+
deliveryStalled: liveDeliveryStalled,
11931199
override: blockingReloadOverride,
11941200
policy: liveCadencePolicy)
11951201
}
11961202

1203+
/// AE#446: has the source stopped delivering on its own cadence?
1204+
///
1205+
/// A blocking reload that can never be satisfied is worse than no blocking reload at all. AVPlayer
1206+
/// issues no segment requests while one is outstanding (measured: a separate, idle connection sat
1207+
/// there the whole time), so the hold starves a client whose cache already holds every second in
1208+
/// front of it. `liveProductionHalted` catches this eventually, but only once the no-cut watchdog
1209+
/// has run, and the stall starts long before that.
1210+
///
1211+
/// The threshold is 1.5 x TARGETDURATION because that is AVPlayer's own patience for an unchanged
1212+
/// live playlist (-12888): past it the client already considers the source late, so holding its
1213+
/// next poll can only cost it something. Latched, because a source that has missed its cadence once
1214+
/// is exactly the "cannot honor the contract" category the static switch exists for (#167), and
1215+
/// letting the advertisement return would flap CAN-BLOCK-RELOAD across every recovery.
1216+
var liveDeliveryStalled: Bool {
1217+
stateLock.lock()
1218+
if _liveDeliveryStalledLatched {
1219+
stateLock.unlock()
1220+
return true
1221+
}
1222+
guard isLive, let last = _lastLiveSegmentFinalizedAt,
1223+
let targetDuration = liveTargetDurationSeal.value else {
1224+
stateLock.unlock()
1225+
return false
1226+
}
1227+
let since = Date().timeIntervalSince(last)
1228+
guard since > 1.5 * Double(targetDuration) else {
1229+
stateLock.unlock()
1230+
return false
1231+
}
1232+
_liveDeliveryStalledLatched = true
1233+
stateLock.unlock()
1234+
EngineLog.emit(
1235+
"[HLSVideoEngine] #446 source stopped delivering (no segment finalized for "
1236+
+ "\(String(format: "%.1f", since))s, TARGETDURATION \(targetDuration)s); withdrawing "
1237+
+ "CAN-BLOCK-RELOAD so a held poll cannot starve a client the cache could still feed",
1238+
category: .session
1239+
)
1240+
return true
1241+
}
1242+
11971243
/// Blocking-reload hold bound: 3 x sealed TARGETDURATION (= the advertised HOLD-BACK depth).
11981244
/// The old hardcoded 18 s was 9 x TD under fastZap. A hold that outlives AVPlayer's ~4 s
11991245
/// forward buffer guarantees the stall it exists to prevent. The seal is always resolved before
@@ -1282,8 +1328,11 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable {
12821328
/// the observed-cadence policy decides for ingest sources; signal-less live (plain-url Jellyfin
12831329
/// transcode) keeps the low-latency default. Pure so the precedence is unit-testable without a full
12841330
/// provider (#167).
1285-
static func resolveLiveBlockingReload(halted: Bool = false, override: Bool?, policy: LiveCadencePolicy?) -> Bool {
1286-
if halted { return false }
1331+
static func resolveLiveBlockingReload(halted: Bool = false, deliveryStalled: Bool = false,
1332+
override: Bool?, policy: LiveCadencePolicy?) -> Bool {
1333+
// Both outrank an explicit override: a source that is not delivering cannot honor the contract
1334+
// however loudly the host asks for it.
1335+
if halted || deliveryStalled { return false }
12871336
if let override { return override }
12881337
if let policy { return policy.blockingReloadEnabled }
12891338
return true
@@ -1461,14 +1510,24 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable {
14611510
let count = segments.count
14621511
stateLock.unlock()
14631512
if count > index { return true }
1464-
if !firstSegmentCondition.wait(until: deadline) {
1465-
stateLock.lock()
1466-
let final = segments.count
1467-
stateLock.unlock()
1468-
return final > index
1513+
// AE#446: wake in slices rather than parking for the whole bound. A source that stops
1514+
// delivering mid-hold has to be noticed here too, or the poll that was already in flight
1515+
// when it died still costs the client a full 3 x TARGETDURATION of not fetching anything.
1516+
let slice = min(deadline, Date().addingTimeInterval(Self.liveHoldRecheckSeconds))
1517+
if !firstSegmentCondition.wait(until: slice) {
1518+
if Date() >= deadline {
1519+
stateLock.lock()
1520+
let final = segments.count
1521+
stateLock.unlock()
1522+
return final > index
1523+
}
1524+
if liveDeliveryStalled { return false }
14691525
}
14701526
}
14711527
}
1528+
1529+
/// AE#446: how often a blocking-reload hold re-asks whether the source is still alive.
1530+
static let liveHoldRecheckSeconds: TimeInterval = 1.0
14721531
var masterCodecs: String? { codecsString }
14731532
var masterSupplementalCodecs: String? { supplementalCodecsString }
14741533
var masterResolution: (width: Int, height: Int)? {

Tests/AetherEngineTests/LiveProductionHaltTests.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,32 @@ final class LiveProductionHaltTests: XCTestCase {
2323
"non-halted signal-less live keeps the low-latency default")
2424
}
2525

26+
// MARK: - Delivery stall (AE#446)
27+
28+
/// Same policy as the halt above, applied one watchdog earlier. A source that has stopped
29+
/// delivering cannot satisfy a blocking reload either, and holding the client's poll for
30+
/// 3 x TARGETDURATION costs it every segment it would otherwise have fetched from the cache.
31+
func testDeliveryStallBeatsOverrideAndPolicy() {
32+
XCTAssertFalse(
33+
VideoSegmentProvider.resolveLiveBlockingReload(
34+
deliveryStalled: true, override: true, policy: nil),
35+
"a source that is not delivering cannot honor blocking-reload however loudly the host asks")
36+
XCTAssertFalse(
37+
VideoSegmentProvider.resolveLiveBlockingReload(
38+
deliveryStalled: true, override: nil, policy: nil))
39+
XCTAssertTrue(
40+
VideoSegmentProvider.resolveLiveBlockingReload(
41+
deliveryStalled: false, override: nil, policy: nil),
42+
"a delivering source keeps the low-latency default")
43+
}
44+
45+
func testHaltAndDeliveryStallAreIndependentRoutesToTheSameAnswer() {
46+
XCTAssertFalse(VideoSegmentProvider.resolveLiveBlockingReload(
47+
halted: true, deliveryStalled: false, override: true, policy: nil))
48+
XCTAssertFalse(VideoSegmentProvider.resolveLiveBlockingReload(
49+
halted: false, deliveryStalled: true, override: true, policy: nil))
50+
}
51+
2652
// MARK: - Pump-exit classification
2753

2854
func testHostRetuneExitsHaltLiveProduction() {

0 commit comments

Comments
 (0)