From 6114ce0f356c20219726f2cbfc1c1d8dddb714bb Mon Sep 17 00:00:00 2001 From: Andreas Busslinger Date: Sat, 8 Aug 2026 23:33:35 +0200 Subject: [PATCH] fix(video): escalate live reopen exhaustion for URL sources too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reopen-exhaustion sites (the barren-cycle cap in handlePumpFinished and the attempt cap at the end of performLiveReopen) halted production and published onLiveSourceReset only for the .customFactory transport #199 introduced. A URL-source live session that exhausted its reopen budget hit the same terminal state with neither: the provider kept advertising blocking reloads it could never satisfy, the playlist stayed frozen, and the host was never asked to retune — the exact zombie window #199 closed for engine-created ingest readers, still open on the most common transport. Extract the escalation decision as a pure function covering every reopenable transport (.none stays out: it delegates to host retune before any reopen begins, and escalating it again would double-signal liveSourceReset) and use it at both sites. Co-Authored-By: Claude Fable 5 --- .../Video/HLSVideoEngine+LiveReopen.swift | 27 +++++++++++++------ .../Issue199RerouteRecoveryTests.swift | 14 ++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift b/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift index ffe8898d..9d9da871 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift @@ -95,6 +95,17 @@ extension HLSVideoEngine { return .none } + /// #199 follow-up pure decision: EVERY in-engine reopen transport that exhausts its budget must + /// halt production (dropping the blocking-reload advert, releasing held ?_HLS_msn= waiters) and + /// surface the loss to the host via onLiveSourceReset. #199 scoped that escalation to the factory + /// transport it introduced, which left a URL source's exhaustion as a zombie session: provider + /// un-halted, playlist frozen, host never told to retune. `.none` never reaches an exhaustion + /// site — it delegates to host retune before any reopen begins — and must stay out so a future + /// call-site reshuffle cannot double-signal that path. + static func liveReopenExhaustionEscalatesToHost(transport: LiveReopenTransport) -> Bool { + transport != .none + } + func handlePumpFinished(_ prod: HLSSegmentProducer, reason: HLSSegmentProducer.PumpExitReason) { // #65 (VOD only): a broken backpressure wedge means AVPlayer is stuck behind a parked producer. @@ -219,10 +230,10 @@ extension HLSVideoEngine { + "\(barrenNow) reopen cycles; giving up (source considered dead)", category: .session ) - if reopenTransport == .customFactory { - // #199: same last-resort surface as reopen exhaustion; without it the recoverable - // exit reason skipped the halt above and the zombie session would hold blocking - // reloads it can never satisfy. + if Self.liveReopenExhaustionEscalatesToHost(transport: reopenTransport) { + // #199 follow-up: same last-resort surface for EVERY reopenable transport; the + // recoverable exit reason skipped the halt above, so without this the zombie + // session holds blocking reloads it can never satisfy and the host is never told. provider?.markLiveProductionHalted() onLiveSourceReset?() } @@ -549,10 +560,10 @@ extension HLSVideoEngine { + "source considered permanently lost", category: .session ) - if transport == .customFactory { - // #199: the in-engine transport is exhausted; surface the loss the way a factory-less - // custom source would have immediately, so the host can retune instead of holding a - // zombie session whose blocking-reload advert can never be satisfied. + if Self.liveReopenExhaustionEscalatesToHost(transport: transport) { + // #199 follow-up: the in-engine transport is exhausted; surface the loss the way a + // factory-less custom source would have immediately, so the host can retune instead of + // holding a zombie session whose blocking-reload advert can never be satisfied. provider?.markLiveProductionHalted() onLiveSourceReset?() } diff --git a/Tests/AetherEngineTests/Issue199RerouteRecoveryTests.swift b/Tests/AetherEngineTests/Issue199RerouteRecoveryTests.swift index 98134df4..1d8a69d2 100644 --- a/Tests/AetherEngineTests/Issue199RerouteRecoveryTests.swift +++ b/Tests/AetherEngineTests/Issue199RerouteRecoveryTests.swift @@ -88,6 +88,20 @@ final class Issue199RerouteRecoveryTests: XCTestCase { sourceReopenableByURL: false, hasCustomSourceReopenFactory: false), .none) } + // MARK: - Reopen exhaustion escalation + + func testEveryReopenableTransportEscalatesOnExhaustion() { + XCTAssertTrue(HLSVideoEngine.liveReopenExhaustionEscalatesToHost(transport: .url), + "a URL source that exhausts its reopen budget must halt production and tell the host; leaving it un-halted is a zombie session with a frozen playlist") + XCTAssertTrue(HLSVideoEngine.liveReopenExhaustionEscalatesToHost(transport: .customFactory), + "the original #199 escalation") + } + + func testTransportlessSourcesNeverReachExhaustionEscalation() { + XCTAssertFalse(HLSVideoEngine.liveReopenExhaustionEscalatesToHost(transport: .none), + ".none delegates to host retune before any reopen begins; escalating it again would double-signal liveSourceReset") + } + // MARK: - Fresh-reader factory func testMainVideoReaderVendsFreshIndependentReader() {