diff --git a/.gitignore b/.gitignore index 631fc0e7..dc2d13e7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ Fixtures/ # Internal design specs + implementation plans (superpowers flow). # Local-only by design; never published to the public repo. docs/superpowers/ + +# Session scratch artifacts (debug fixtures, serve logs, reply drafts). +# Local-only by design; never published. +scratchpad/ diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 6d7b5ca4..56d842cd 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -1151,12 +1151,25 @@ public final class AetherEngine: ObservableObject { let outcome = await host.awaitStartupReadiness(timeoutSeconds: timeout) try checkLoadCurrent(gen) + // AE#169 round 3: the data-wait exists for a first segment still being produced. A pump + // that already exited with nothing served (tail resume onto an unproducible final + // segment) can never satisfy it; consult liveness so the gate fails over immediately + // instead of riding 24 s of false hope. A restart in flight reads as still-producing. + let productionFinished = session.currentProducerFinished && !session.restartInFlight + if outcome == .awaitingData, productionFinished { + EngineLog.emit( + "[AetherEngine] #169 readiness gate: production already finished with no data " + + "served; skipping the data-wait (not a slow link)", + category: .session) + } + switch StartupReadinessGate.nextAction( outcome: outcome, attempt: attempt, masterAlreadyFellBack: masterFallbackUsed, hasMediaFallbackURL: session.mediaPlaylistURL != nil, - dataWaitRounds: dataWaitRounds + dataWaitRounds: dataWaitRounds, + productionFinished: productionFinished ) { case .proceed: return diff --git a/Sources/AetherEngine/Native/StartupReadinessGate.swift b/Sources/AetherEngine/Native/StartupReadinessGate.swift index b0be0fbc..0b4f7fbd 100644 --- a/Sources/AetherEngine/Native/StartupReadinessGate.swift +++ b/Sources/AetherEngine/Native/StartupReadinessGate.swift @@ -75,6 +75,9 @@ enum StartupReadinessGate { /// Decide the next action after `attempt` master attempts (1-based) produced `outcome`. /// `dataWaitRounds` counts the consecutive `awaitingData` windows already ridden this gate run. + /// `productionFinished` reports pump liveness (AE#169 round 3): the data-wait exists for a + /// first segment still being produced; production that already exited with nothing served can + /// never satisfy it, so riding the rounds is pure false hope. static func nextAction( outcome: StartupReadiness, attempt: Int, @@ -82,13 +85,17 @@ enum StartupReadinessGate { masterAlreadyFellBack: Bool, hasMediaFallbackURL: Bool, dataWaitRounds: Int = 0, - maxDataWaitRounds: Int = maxDataWaitRounds + maxDataWaitRounds: Int = maxDataWaitRounds, + productionFinished: Bool = false ) -> StartupGateAction { if outcome == .ready { return .proceed } // First segment not served yet: keep the current master (DV preserved) and keep waiting, bounded - // by the data-wait budget. Only once the budget is spent does an unstarted first segment fall - // through to the cold-failure reload/fallback below (a genuinely wedged producer still terminates). - if outcome == .awaitingData && dataWaitRounds < maxDataWaitRounds { return .keepAwaitingData } + // by the data-wait budget. Only once the budget is spent, or production has provably exited + // without serving anything (AE#169 round 3), does an unstarted first segment fall through to + // the cold-failure reload/fallback below (a genuinely wedged producer still terminates). + if outcome == .awaitingData && dataWaitRounds < maxDataWaitRounds && !productionFinished { + return .keepAwaitingData + } if attempt < masterAttempts { return .reloadMaster } if !masterAlreadyFellBack && hasMediaFallbackURL { return .fallBackToMedia } return .giveUp diff --git a/Sources/AetherEngine/Video/HLSSegmentProducer.swift b/Sources/AetherEngine/Video/HLSSegmentProducer.swift index b5c76556..d07ac863 100644 --- a/Sources/AetherEngine/Video/HLSSegmentProducer.swift +++ b/Sources/AetherEngine/Video/HLSSegmentProducer.swift @@ -285,7 +285,7 @@ final class HLSSegmentProducer: @unchecked Sendable { /// Gate uses AV_PKT_FLAG_KEY (not libavformat's keyframe index) because MKV SimpleBlock keyframe bit can be off. /// Audio gate waits for video: without this, a non-IDR-keyframe miss puts video 10+ s past audio ("asynchron"). - private let restartTargetVideoDts: Int64 + private let restartTargetVideoPts: Int64 private var restartTargetAudioDts: Int64 private var audioWaitForVideo: Bool private var firstActualVideoDts: Int64 = Int64.min @@ -450,6 +450,45 @@ final class HLSSegmentProducer: @unchecked Sendable { packetCounterLock.unlock() } + /// AE#169 round 3: pregate observability for the engine's starved-EOF re-anchor arm. A pump + /// whose scan-forward gate never opened wrote nothing; the last keyframe it dropped BELOW the + /// target is the true final random-access point the engine can re-anchor production on. + private var _videoGateOpened = false + private var _lastPregateDroppedKeyframePts: Int64 = Int64.min + var videoGateOpened: Bool { + packetCounterLock.lock() + defer { packetCounterLock.unlock() } + return _videoGateOpened + } + var lastPregateDroppedKeyframePts: Int64 { + packetCounterLock.lock() + defer { packetCounterLock.unlock() } + return _lastPregateDroppedKeyframePts + } + var hasRestartTarget: Bool { restartTargetVideoPts != Int64.min } + private func markVideoGateOpened() { + packetCounterLock.lock() + _videoGateOpened = true + packetCounterLock.unlock() + } + private func notePregateDroppedKeyframe(pts: Int64) { + packetCounterLock.lock() + if pts > _lastPregateDroppedKeyframePts { _lastPregateDroppedKeyframePts = pts } + packetCounterLock.unlock() + } + + /// AE#169 round 3 pure decision: whether a video packet opens the restart scan-forward gate. + /// The gate target is a plan-boundary PTS (`segmentPlan[baseIndex].startPts`), so the packet + /// is judged by presentation time. Comparing DTS dropped the exact IRAP the restart seeked + /// for (a keyframe's DTS sits a reorder delay below its own PTS; same defect class as the #92 + /// cutter fix): mid-file the next IRAP rescued the miss one GOP late, but at the file tail no + /// later IRAP exists, so the unbounded VOD gate starved to EOF with zero packets written. + static func videoGateTargetSatisfied(pts: Int64, dts: Int64, targetPts: Int64) -> Bool { + if targetPts == Int64.min { return true } + let ts = pts != Int64.min ? pts : dts + return ts != Int64.min && ts >= targetPts + } + var muxerLifetimeFragmentBytes: Int { stateLock.lock() defer { stateLock.unlock() } @@ -621,7 +660,7 @@ final class HLSSegmentProducer: @unchecked Sendable { targetSegmentDurationSeconds: Double = 6.0, videoFallbackDurationPts: Int64, audioFallbackDurationPts: Int64 = 0, - restartTargetVideoDts: Int64 = Int64.min, + restartTargetVideoPts: Int64 = Int64.min, closedCaptionStreamIndex: Int32 = -1, subtitleTapStreamIndices: Set = [], subtitlePacketStreamIndices: Set = [], @@ -673,7 +712,7 @@ final class HLSSegmentProducer: @unchecked Sendable { self.liveCurrentSegmentIndex = baseIndex self.videoFallbackDurationPts = videoFallbackDurationPts self.audioFallbackDurationPts = audioFallbackDurationPts - self.restartTargetVideoDts = restartTargetVideoDts + self.restartTargetVideoPts = restartTargetVideoPts // Audio target set dynamically once video gate opens (rescaled to audio TB). self.restartTargetAudioDts = Int64.min // Audio always waits for video: some MKV remuxes (Bluey BD) have a non-IDR first packet; @@ -1376,7 +1415,7 @@ final class HLSSegmentProducer: @unchecked Sendable { // MARK: - Pump private func runPumpLoop() { - if restartTargetVideoDts > Int64.min { + if restartTargetVideoPts > Int64.min { bumpRestartCount() } let pumpStart = DispatchTime.now() @@ -1633,7 +1672,7 @@ final class HLSSegmentProducer: @unchecked Sendable { if Self.shouldBufferPregateAudio( isAudioPkt: isAudioPkt, audioWaitForVideo: audioWaitForVideo, - isHeadOfStream: restartTargetVideoDts == Int64.min, + isHeadOfStream: restartTargetVideoPts == Int64.min, isLive: isLive, bufferedBytes: pregateAudioBufferBytes, packetSize: Int(packet.pointee.size), @@ -1644,7 +1683,7 @@ final class HLSSegmentProducer: @unchecked Sendable { pktPtr = nil // ownership moves to the buffer; freed on replay or teardown continue } else if isAudioPkt, audioWaitForVideo, - restartTargetVideoDts == Int64.min || !isLive, + restartTargetVideoPts == Int64.min || !isLive, !pregateAudioOverflowLogged { pregateAudioOverflowLogged = true EngineLog.emit( @@ -1958,12 +1997,16 @@ final class HLSSegmentProducer: @unchecked Sendable { // Scan-forward gate: wait for AV_PKT_FLAG_KEY (matroska seek can land 100+ ms early and // SimpleBlock keyframe bit can be off for an IDR in the Cues index). Initial-start also // waits: first packet is not always a sync sample (Bluey MKV: dts=0 pts=33, no key flag, - // seg-0 rejected by AVPlayer with -12860 indefinite stall). + // seg-0 rejected by AVPlayer with -12860 indefinite stall). The target is a plan-boundary + // PTS, so the packet is judged by presentation time (AE#169 round 3): comparing DTS + // dropped the anchor IRAP itself under B-frame reorder, and at the file tail no later + // IRAP exists to rescue the miss, starving the unbounded VOD gate to EOF. if isVideoPkt { if firstActualVideoDts == Int64.min { let isKey = (packet.pointee.flags & AV_PKT_FLAG_KEY) != 0 - let targetSatisfied = restartTargetVideoDts == Int64.min - || (packet.pointee.dts != Int64.min && packet.pointee.dts >= restartTargetVideoDts) + let targetSatisfied = Self.videoGateTargetSatisfied( + pts: packet.pointee.pts, dts: packet.pointee.dts, + targetPts: restartTargetVideoPts) // #133: on a live H.264 Annex-B mid-stream join, opening on a bare keyframe flag is not // enough. A join packet must carry a decodable IDR access unit (in-band SPS+PPS+IDR); // otherwise the decoder renders references it never received (green frames) or, when the @@ -1974,6 +2017,11 @@ final class HLSSegmentProducer: @unchecked Sendable { ? extractJoinVideoConfig(packet) : nil let joinGateSatisfied = !liveH264AnnexBJoin || joinConfig != nil guard isKey, targetSatisfied, joinGateSatisfied else { + if isKey { + let ts = packet.pointee.pts != Int64.min + ? packet.pointee.pts : packet.pointee.dts + if ts != Int64.min { notePregateDroppedKeyframe(pts: ts) } + } pregateVideoDropCount += 1 if pregateVideoDropCount == 1 { pregateWaitStart = Date() @@ -1985,8 +2033,9 @@ final class HLSSegmentProducer: @unchecked Sendable { EngineLog.emit( "[HLSSegmentProducer] still waiting for \(awaiting): " + "dropped=\(pregateVideoDropCount) " - + "lastDts=\(packet.pointee.dts) isKey=\(isKey) " - + "target=\(restartTargetVideoDts) " + + "lastDts=\(packet.pointee.dts) lastPts=\(packet.pointee.pts) " + + "isKey=\(isKey) " + + "target=\(restartTargetVideoPts) " + "baseIndex=\(baseIndex)", category: .session ) @@ -2019,6 +2068,7 @@ final class HLSSegmentProducer: @unchecked Sendable { ) } firstActualVideoDts = packet.pointee.dts + markVideoGateOpened() firstActualVideoPts = packet.pointee.pts != Int64.min ? packet.pointee.pts : packet.pointee.dts @@ -2040,7 +2090,7 @@ final class HLSSegmentProducer: @unchecked Sendable { "[HLSSegmentProducer] video gate open: " + "actual=\(firstActualVideoDts) " + "anchorPts=\(firstActualVideoPts) " - + "target=\(restartTargetVideoDts) " + + "target=\(restartTargetVideoPts) " + "desired=\(desiredFirstVideoTfdtPts) " + "shift=\(videoShiftPts) " // #133 follow-up diag: PID + reconstruct state per epoch, so retest logs separate a @@ -2155,7 +2205,7 @@ final class HLSSegmentProducer: @unchecked Sendable { if firstActualAudioDts == Int64.min { firstActualAudioDts = packet.pointee.dts let audioTb = audioConfig?.sourceTimeBase ?? AVRational(num: 1, den: 1000) - if restartTargetVideoDts == Int64.min { + if restartTargetVideoPts == Int64.min { // Head-of-stream: inherit video's shift so the audio-minus-video offset survives (Cars: EAC3 +256 ms). // Snapping to desired=0 would pull the entire audio track ahead of picture. audioShiftPts = av_rescale_q( @@ -2179,7 +2229,7 @@ final class HLSSegmentProducer: @unchecked Sendable { ) } let gapInAudioTb: Int64 - if restartTargetVideoDts == Int64.min { + if restartTargetVideoPts == Int64.min { gapInAudioTb = 0 } else { gapInAudioTb = restartTargetAudioDts == Int64.min diff --git a/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift b/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift index acf9e566..a2662572 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift @@ -15,6 +15,35 @@ extension HLSVideoEngine { return packetsWritten == 0 && cachedSegments == 0 } + /// AE#169 round 3 pure decision: a VOD pump that reached EOF with its scan-forward gate never + /// opened produced nothing; the plan boundary it targeted has no runtime keyframe at or after + /// it (tail Cues drift or a mis-flagged tail IRAP; the gate itself is pts-based since round 3). + /// If the gate saw keyframes below the target while dropping, the last of them is the true + /// final random-access point and production re-anchors there (bounded), so the tail content + /// gets produced and end-of-media completes through the tail-park instead of the forward-wait + /// escalation restarting into the same starve until -12889. A head-of-stream pump (no restart + /// target) starving means a keyframe-less source, which stays the #126 fatal surface. + static func shouldReanchorVODAfterGateStarvation( + isLive: Bool, + videoGateOpened: Bool, + hadRestartTarget: Bool, + lastDroppedKeyframePts: Int64 + ) -> Bool { + guard !isLive, !videoGateOpened, hadRestartTarget else { return false } + return lastDroppedKeyframePts != Int64.min + } + + /// AE#169 round 3 pure decision: the plan segment whose span contains a source pts (the last + /// index whose startPts is at or below it). nil when the plan is empty or the pts precedes + /// the first boundary (nowhere sane to re-anchor). + static func planSegmentIndex(forSourcePts pts: Int64, plan: [Segment]) -> Int? { + var result: Int? = nil + for (i, seg) in plan.enumerated() { + if seg.startPts <= pts { result = i } else { break } + } + return result + } + /// AE#169 round 2 pure decision: a VOD pump read-error exit that produced media before dying /// is revivable (the source worked; a reconnect-churned read failed). The complement is the /// #126 dead-source fatal surface; live keeps its reopen machinery. @@ -107,6 +136,20 @@ extension HLSVideoEngine { } return } + // AE#169 round 3: a VOD pump that reached EOF with its scan-forward gate never opened + // wrote nothing because the targeted plan boundary has no runtime keyframe at or after it + // (the unproducible tail segment of rrgomes' DV MKV). Re-anchor on the last keyframe the + // gate dropped instead of returning, which would leave the forward-wait escalation + // restarting into the same starve. + if case .eof = reason, Self.shouldReanchorVODAfterGateStarvation( + isLive: isLiveSession, + videoGateOpened: prod.videoGateOpened, + hadRestartTarget: prod.hasRestartTarget, + lastDroppedKeyframePts: prod.lastPregateDroppedKeyframePts + ) { + handleVODGateStarvationExit(prod) + return + } guard isLiveSession else { return } let reopenTransport = Self.liveReopenTransport( sourceReopenableByURL: sourceReopenableByURL, @@ -219,6 +262,45 @@ extension HLSVideoEngine { requestRestart(at: idx, authoritative: true) } + /// AE#169 round 3: re-anchor a VOD session whose pump starved its scan-forward gate to EOF. + /// The last keyframe the gate dropped below the target is the final real random-access point + /// of the file; producing from its segment folds the tail content into the cache so playback + /// reaches end-of-media (via the tail-park completion) instead of dying at -12889 on a + /// segment no anchoring can produce. Bounded by its own #99-shaped gate. + func handleVODGateStarvationExit(_ prod: HLSSegmentProducer) { + let lastKeyPts = prod.lastPregateDroppedKeyframePts + restartLock.lock() + let plan = segmentPlan + let admitted = gateStarvationReviveGate.admit() + let attempts = gateStarvationReviveGate.attempts + let cap = gateStarvationReviveGate.maxAttempts + restartLock.unlock() + guard admitted else { + EngineLog.emit( + "[HLSVideoEngine] #169 VOD gate-starvation re-anchor cap reached " + + "(\(attempts) starved pumps, cap \(cap)); giving up " + + "(no keyframe at/after the plan boundary in this session)", + category: .session + ) + return + } + guard let idx = Self.planSegmentIndex(forSourcePts: lastKeyPts, plan: plan) else { + EngineLog.emit( + "[HLSVideoEngine] #169 VOD gate starved to EOF but the dropped keyframe " + + "(pts=\(lastKeyPts)) maps to no plan segment; not re-anchoring", + category: .session + ) + return + } + EngineLog.emit( + "[HLSVideoEngine] #169 VOD gate starved to EOF at seg\(prod.anchoredBaseIndex): " + + "no keyframe at/after the plan boundary; re-anchoring on the last real keyframe " + + "(pts=\(lastKeyPts)) -> seg\(idx) (attempt \(attempts)/\(cap))", + category: .session + ) + requestRestart(at: idx, authoritative: true) + } + /// #99: revive a VOD session whose pump died with muxerFailed. The restart path rebuilds the /// producer with a fresh muxer and calls audioBridge.startSegment() (which also rebuilds a /// post-EOF-drained encoder), so the known transient causes heal. Aimed like the wedge re-anchor: diff --git a/Sources/AetherEngine/Video/HLSVideoEngine.swift b/Sources/AetherEngine/Video/HLSVideoEngine.swift index 0c2fe505..d81ec3fb 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine.swift @@ -416,7 +416,7 @@ public final class HLSVideoEngine: @unchecked Sendable { /// Session-long FLAC bridge for codecs illegal in fMP4. Engine-owned (not producer-owned) so /// encoder state survives producer restarts; `startSegment()` rebases PTS on each restart. var audioBridge: AudioBridge? - private var segmentPlan: [Segment] = [] + var segmentPlan: [Segment] = [] /// Guards subsystem refs + `sessionEpoch`. Never held across waits or network I/O so /// `stop()` on the main thread is never blocked behind a restart's 5 s waitForFinish. @@ -454,6 +454,11 @@ public final class HLSVideoEngine: @unchecked Sendable { /// that just failed (a sticky pb error would burn the revive gate without one fresh attempt). var mainDemuxerSuspectDead = false + /// AE#169 round 3 (under `restartLock`): bounded re-anchor for a VOD pump whose restart + /// scan-forward gate starved to EOF (no runtime keyframe at/after the targeted plan boundary, + /// the unproducible tail segment). Same gate shape as #99. + var gateStarvationReviveGate = MuxerFailureReviveGate(maxAttempts: 2) + /// #93 residual: a stalled AVPlayer sometimes never resumes REQUESTING after a wedge re-anchor /// (device: plain playback, one -15628 errorLog, then zero segment GETs while parked in /// waitingToMinimizeStalls forever, item never fails). The served playlist alone cannot reach @@ -1644,9 +1649,11 @@ public final class HLSVideoEngine: @unchecked Sendable { throw HLSVideoEngineError.notStarted } - // Scan-forward + dynamic-shift: producer scans for the first AV_PKT_FLAG_KEY packet with - // dts >= videoTarget, then computes shift = actualFirstDts - desiredFirstTfdt and applies - // it to all subsequent packets. Audio target set dynamically after video lands. + // Scan-forward + dynamic-shift: producer scans for the first AV_PKT_FLAG_KEY packet whose + // presentation time reaches videoTarget (a plan-boundary PTS; judging by DTS dropped the + // anchor IRAP under B-frame reorder, AE#169 round 3), then computes + // shift = actualFirstDts - desiredFirstTfdt and applies it to all subsequent packets. + // Audio target set dynamically after video lands. let videoTarget: Int64 let desiredVideoTfdt: Int64 let desiredAudioTfdt: Int64 @@ -1693,7 +1700,7 @@ public final class HLSVideoEngine: @unchecked Sendable { targetSegmentDurationSeconds: liveCutTargetSeconds, videoFallbackDurationPts: videoFallbackDurationPts, audioFallbackDurationPts: audioFallbackDurationPts, - restartTargetVideoDts: videoTarget, + restartTargetVideoPts: videoTarget, closedCaptionStreamIndex: closedCaptionStreamIndexForSession, subtitleTapStreamIndices: Set(nativeSubtitleSourceStreamIndicesForSession.compactMap { $0 }), subtitlePacketStreamIndices: allEmbeddedSubtitleStreamIndices, // #112 rework diff --git a/Tests/AetherEngineTests/Issue169GateStarvationTests.swift b/Tests/AetherEngineTests/Issue169GateStarvationTests.swift new file mode 100644 index 00000000..4136de8b --- /dev/null +++ b/Tests/AetherEngineTests/Issue169GateStarvationTests.swift @@ -0,0 +1,109 @@ +import Testing +import Foundation +@testable import AetherEngine + +/// AE#169 round 3 (rrgomes): the restart scan-forward gate compared packet DTS against a +/// plan-boundary PTS (`segmentPlan[baseIndex].startPts`). Under B-frame reorder a keyframe's DTS +/// sits a reorder delay below its own PTS, so the gate dropped the exact IRAP the restart seeked +/// for (the same defect class the #92 cutter fix removed from segment cutting). Mid-file the next +/// IRAP rescued the miss one GOP late; at the file tail there is no next IRAP, so the VOD gate +/// (unbounded by design) dropped every remaining packet to EOF and the pump exited with +/// `packetsWritten=0` ("still waiting for video keyframe: dropped=200 lastDts=2880419 +/// isKey=false target=2878501"). The final segment was then unproducible under any anchoring and +/// the forward-wait escalation restarted into the same starve. +struct Issue169GateStarvationTests { + + // MARK: - videoGateTargetSatisfied (pregate opens on presentation time) + + @Test("the anchor IRAP whose dts sits a reorder delay below the target pts opens the gate") + func anchorIRAPWithReorderedDtsOpens() { + // rrgomes' seg718 geometry: plan boundary 2878501, IRAP pts just above it, dts ~125ms below. + #expect(HLSSegmentProducer.videoGateTargetSatisfied( + pts: 2_878_620, dts: 2_878_495, targetPts: 2_878_501)) + // Exact landing (dts == pts sources) is unchanged. + #expect(HLSSegmentProducer.videoGateTargetSatisfied( + pts: 2_878_501, dts: 2_878_501, targetPts: 2_878_501)) + } + + @Test("a keyframe before the target boundary stays dropped") + func belowTargetKeyframeStaysDropped() { + // The seg716 IRAP during a seg718-anchored scan: presentation time below the boundary. + #expect(!HLSSegmentProducer.videoGateTargetSatisfied( + pts: 2_872_411, dts: 2_872_290, targetPts: 2_878_501)) + } + + @Test("no restart target admits the first keyframe (head of stream)") + func headOfStreamAdmitsFirstKeyframe() { + #expect(HLSSegmentProducer.videoGateTargetSatisfied( + pts: 33, dts: 0, targetPts: Int64.min)) + #expect(HLSSegmentProducer.videoGateTargetSatisfied( + pts: Int64.min, dts: Int64.min, targetPts: Int64.min)) + } + + @Test("NOPTS pts falls back to dts; both missing cannot satisfy a real target") + func noptsFallsBackToDts() { + #expect(HLSSegmentProducer.videoGateTargetSatisfied( + pts: Int64.min, dts: 2_878_600, targetPts: 2_878_501)) + #expect(!HLSSegmentProducer.videoGateTargetSatisfied( + pts: Int64.min, dts: Int64.min, targetPts: 2_878_501)) + } + + // MARK: - shouldReanchorVODAfterGateStarvation (engine arm for the starved-EOF exit) + + @Test("a VOD pump that starved its gate to EOF re-anchors on the last dropped keyframe") + func starvedGateReanchors() { + #expect(HLSVideoEngine.shouldReanchorVODAfterGateStarvation( + isLive: false, videoGateOpened: false, hadRestartTarget: true, + lastDroppedKeyframePts: 2_878_620)) + } + + @Test("an opened gate, a head-of-stream pump, a live pump, or no seen keyframe never re-anchor") + func reanchorGuards() { + // Gate opened: normal production ran; a plain EOF is a normal end. + #expect(!HLSVideoEngine.shouldReanchorVODAfterGateStarvation( + isLive: false, videoGateOpened: true, hadRestartTarget: true, + lastDroppedKeyframePts: 2_878_620)) + // Head-of-stream pump (no restart target) starving means a keyframe-less source, not a + // tail-boundary mismatch; the #126 fatal surface owns that. + #expect(!HLSVideoEngine.shouldReanchorVODAfterGateStarvation( + isLive: false, videoGateOpened: false, hadRestartTarget: false, + lastDroppedKeyframePts: 2_878_620)) + // Live has its own reopen machinery. + #expect(!HLSVideoEngine.shouldReanchorVODAfterGateStarvation( + isLive: true, videoGateOpened: false, hadRestartTarget: true, + lastDroppedKeyframePts: 2_878_620)) + // No keyframe seen below the target: nowhere sane to re-anchor. + #expect(!HLSVideoEngine.shouldReanchorVODAfterGateStarvation( + isLive: false, videoGateOpened: false, hadRestartTarget: true, + lastDroppedKeyframePts: Int64.min)) + } + + // MARK: - planSegmentIndex(forSourcePts:) (re-anchor target mapping) + + private func plan() -> [HLSVideoEngine.Segment] { + (0..<720).map { i in + HLSVideoEngine.Segment(startPts: Int64(i) * 4000, endPts: Int64(i + 1) * 4000, + startSeconds: Double(i) * 4.0, durationSeconds: 4.0) + } + } + + @Test("a source pts maps to the segment whose span contains it") + func planIndexMapsIntoSpan() { + let plan = plan() + #expect(HLSVideoEngine.planSegmentIndex(forSourcePts: 2_872_411, plan: plan) == 718) + #expect(HLSVideoEngine.planSegmentIndex(forSourcePts: 2_872_000, plan: plan) == 718) + #expect(HLSVideoEngine.planSegmentIndex(forSourcePts: 0, plan: plan) == 0) + // Past the final boundary clamps into the last segment. + #expect(HLSVideoEngine.planSegmentIndex(forSourcePts: 10_000_000, plan: plan) == 719) + } + + @Test("an empty plan or a pts before the first boundary has no mapping") + func planIndexRejectsUnmappable() { + #expect(HLSVideoEngine.planSegmentIndex(forSourcePts: 100, plan: []) == nil) + let offset = plan().map { seg in + HLSVideoEngine.Segment(startPts: seg.startPts + 5000, endPts: seg.endPts + 5000, + startSeconds: seg.startSeconds, durationSeconds: seg.durationSeconds) + } + #expect(HLSVideoEngine.planSegmentIndex(forSourcePts: 100, plan: offset) == nil) + } +} diff --git a/Tests/AetherEngineTests/StartupReadinessGateTests.swift b/Tests/AetherEngineTests/StartupReadinessGateTests.swift index 23cfc032..7a91e903 100644 --- a/Tests/AetherEngineTests/StartupReadinessGateTests.swift +++ b/Tests/AetherEngineTests/StartupReadinessGateTests.swift @@ -89,6 +89,31 @@ struct StartupReadinessGateTests { dataWaitRounds: StartupReadinessGate.maxDataWaitRounds) == .fallBackToMedia) } + // AE#169 round 3: the data-wait assumed "still producing over a slow link", but in rrgomes' + // tail resume the pump had already exited with zero packets written three seconds before the + // first data-wait round. Riding all 8 rounds is 24 s of false hope with a message describing + // the opposite of reality; finished production with no data served must fail over immediately. + + @Test("Finished production with no data served fails over immediately instead of riding the data-wait") + func finishedProductionSkipsDataWait() { + #expect(StartupReadinessGate.nextAction( + outcome: .awaitingData, attempt: 1, masterAttempts: 2, + masterAlreadyFellBack: false, hasMediaFallbackURL: true, + dataWaitRounds: 0, productionFinished: true) == .reloadMaster) + #expect(StartupReadinessGate.nextAction( + outcome: .awaitingData, attempt: 2, masterAttempts: 2, + masterAlreadyFellBack: false, hasMediaFallbackURL: true, + dataWaitRounds: 0, productionFinished: true) == .fallBackToMedia) + } + + @Test("Production still running keeps the data-wait patience") + func runningProductionKeepsDataWait() { + #expect(StartupReadinessGate.nextAction( + outcome: .awaitingData, attempt: 1, masterAttempts: 2, + masterAlreadyFellBack: false, hasMediaFallbackURL: true, + dataWaitRounds: 3, productionFinished: false) == .keepAwaitingData) + } + @Test("The timeout outcome distinguishes an unserved first segment from a served-but-dead master") func timeoutOutcomeSplitsOnLoadedMedia() { // No media loaded when the window elapses: the first segment has not been served (slow production).