Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
15 changes: 14 additions & 1 deletion Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions Sources/AetherEngine/Native/StartupReadinessGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,20 +75,27 @@ 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,
masterAttempts: Int = masterAttempts,
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
Expand Down
78 changes: 64 additions & 14 deletions Sources/AetherEngine/Video/HLSSegmentProducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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<Int32> = [],
subtitlePacketStreamIndices: Set<Int32> = [],
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
82 changes: 82 additions & 0 deletions Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading