@@ -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 ) ? {
0 commit comments