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
35 changes: 34 additions & 1 deletion Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1551,8 +1551,19 @@ extension AetherEngine {
guard self.itemDeathReviveGate.admit(position: position) else {
EngineLog.emit(
"[AetherEngine] #93 item death (failedToPlayToEndTime) at "
+ "\(String(format: "%.2f", position))s; revive budget exhausted, giving up",
+ "\(String(format: "%.2f", position))s; revive budget exhausted",
category: .engine)
// AE#561: a frozen position across three reloads is the reload answering the
// same bytes three times. Offer the source to the engine's own decoder before
// the session is left dead.
await self.escalateToSoftwarePath(
SoftwarePathEscalation.Request(
domain: SoftwarePathEscalation.mediaErrorDomain,
code: 0,
message: "item death at a frozen position, revive budget exhausted",
positionSeconds: position.isFinite ? max(0, position) : 0
)
)
return
}
EngineLog.emit(
Expand All @@ -1574,6 +1585,28 @@ extension AetherEngine {
}
.store(in: &nativeCancellables)

// AE#561: the last rung. Every recovery above reloads the same item against the same bytes,
// which is no answer to a segment AVPlayer refuses on its merits. The engine's own decoder
// reads the demuxer directly and answers a sample Apple's parser rejects by skipping one
// frame, so it is offered the session before the failure is made terminal. Once per session,
// and only for a verdict on the MEDIA (see SoftwarePathEscalation).
let escalationBudget = softwarePathEscalationBudget
let escalationPreferred = loadedOptions.preferredDecodePath
let escalationRemoteHLS = loadedOptions.nativeRemoteHLS
host.softwarePathAvailability = {
SoftwarePathEscalation.Availability(
alreadyEscalated: escalationBudget.isSpent,
preferredDecodePath: escalationPreferred,
nativeRemoteHLS: escalationRemoteHLS
)
}
host.$pendingSoftwarePathEscalation
.compactMap { $0 }
.sink { [weak self] request in
Task { @MainActor [weak self] in await self?.escalateToSoftwarePath(request) }
}
.store(in: &nativeCancellables)

// appliesPerFrameHDRDisplayMetadata unconditionally true: DV P5 has no HDR10 base layer, so the per-frame RPU is what AVPlayer's tone-mapper needs on a non-DV panel (DrHurt #4 2026-05-26). Prior servingMasterPlaylist gate broke P5. Apple's default is also true; explicit write surfaces the live value in diagnostics.
// forwardBufferDuration default (4 s): deep buffer lets AVPlayer race to the live edge and hit the transcode warm-up gap head-on (-12888); 4 s PACES consumption. Verified: 8 s worsened startup pause (8-10 s vs ~1 s).
// Live REJOIN: skip initial seek so AVPlayer picks edge-minus-holdback instead; seek-to-0 against the re-served backlog wedged the reloaded item in waitingToPlay (device repro: tvOS 26, Jellyfin stream.ts). See LiveReloadPolicy.
Expand Down
63 changes: 63 additions & 0 deletions Sources/AetherEngine/AetherEngine+SoftwarePathEscalation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

/// AE#561: the rung under every native recovery, taken when AVPlayer refuses the media itself.
///
/// The recoveries above this one all answer the same bytes again: the #93 revive reloads the item at
/// the position that died, and the stage-2 chain refills the same segment. Against a transient that
/// is exactly right. Against a segment Apple's parser refuses on its merits it is a loop, and the
/// reporter's capture shows it ending the session with the replacement item dying 62 ms after the
/// first. `SoftwarePlaybackHost` decodes with libavcodec, which skips such a frame and plays on, and
/// it reads the demuxer directly rather than the loopback HLS, so it steps around a local-server
/// wedge too.
///
/// The rebuild is `reloadAtCurrentPosition(applying:)`, which keeps the session: same playhead, same
/// subtitle carryover, same external-track registry. Its own `decodePathRefusal` is what decides
/// whether the software path can serve this source at all, so a source it cannot serve costs a
/// refusal here rather than a second dead session.
extension AetherEngine {

/// Rebuild this session on the software path, once, because the native one refused the media.
@MainActor
func escalateToSoftwarePath(_ request: SoftwarePathEscalation.Request) async {
guard SoftwarePathEscalation.shouldEscalate(
errorDomain: request.domain,
availability: SoftwarePathEscalation.Availability(
alreadyEscalated: softwarePathEscalationBudget.isSpent,
preferredDecodePath: loadedOptions.preferredDecodePath,
nativeRemoteHLS: loadedOptions.nativeRemoteHLS
)
) else { return }
// The host's probe read mount-time options and the #93 rung does not consult it at all, so
// the decision is made again here, against what the session is actually running on.
guard softwarePathEscalationBudget.take() else { return }

EngineLog.emit(
"[AetherEngine] #561 AVPlayer refused the media (\(request.domain)/\(request.code)) at "
+ "\(String(format: "%.2f", request.positionSeconds))s; rebuilding this session on the "
+ "software path, which decodes it with libavcodec instead: \(request.message)",
category: .engine
)

do {
try await reloadAtCurrentPosition { $0.preferredDecodePath = .software }
EngineLog.emit(
"[AetherEngine] #561 rebuilt on the software path", category: .engine)
} catch {
// The rung is gone and the failure was never surfaced, so it has to be surfaced here or
// the session would sit on a picture that stopped with nothing said.
EngineLog.emit(
"[AetherEngine] #561 the software path cannot serve this session (\(error)); "
+ "surfacing the original failure",
category: .engine
)
publishError(
PlaybackErrorInfo(
kind: .nativeItemFailed,
message: request.message,
underlyingDomain: request.domain.isEmpty ? nil : request.domain,
underlyingCode: request.code == 0 ? nil : request.code
)
)
}
}
}
5 changes: 5 additions & 0 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2192,6 +2192,10 @@ public final class AetherEngine: ObservableObject {
/// bounded reload budget. Cancelled on load reset; superseded by newer deaths.
var itemDeathConfirmTask: Task<Void, Never>? = nil
var itemDeathReviveGate = ItemDeathReviveGate(maxAttempts: 3)
/// AE#561: the one rebuild onto the software path this session may spend when AVPlayer refuses
/// the media. Replaced (not reset) on teardown, so a closure held by a dead host cannot spend
/// the next session's.
var softwarePathEscalationBudget = SoftwarePathEscalation.Budget()
/// #65 final rung, storm shape: on a frozen live playlist each stage-2 reload replays the tail,
/// re-stalls within seconds, and the fresh stall SUPERSEDES the ladder task before its
/// post-reload rung can run, so the reload cycle alone would loop forever. This gate persists
Expand Down Expand Up @@ -3643,6 +3647,7 @@ public final class AetherEngine: ObservableObject {
itemDeathConfirmTask = nil
itemDeathReviveGate = ItemDeathReviveGate(maxAttempts: 3)
stallReloadReviveGate = ItemDeathReviveGate(maxAttempts: 2)
softwarePathEscalationBudget = SoftwarePathEscalation.Budget()
masterFallbackUsed = false
nativeSubtitleReanchorTask?.cancel()
nativeSubtitleReanchorTask = nil
Expand Down
37 changes: 37 additions & 0 deletions Sources/AetherEngine/Native/NativeAVPlayerHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ final class NativeAVPlayerHost {
/// AE#495: what the AE#495 relay knows about the origin's certificate, when the item this host
/// plays is served by one. Set by the engine at mount, read only while classifying a failure.
var upstreamTrustRefusal: (@Sendable () -> Int?)?
/// AE#561: what the session can still offer when AVPlayer refuses the media, answered by the
/// engine because the host owns none of it. Set at mount, read only while classifying a failure.
var softwarePathAvailability: (@Sendable () -> SoftwarePathEscalation.Availability?)?
/// #50: latched on first .playing; discriminates startup failures (never played) from mid-playback transients. .failed and timeControlStatus KVOs are unsynchronized, so instantaneous status is unreliable. Reset with the item on a reused host.
private var hasEverPlayed = false
@Published private(set) var didReachEnd: Bool = false
Expand Down Expand Up @@ -134,6 +137,11 @@ final class NativeAVPlayerHost {
/// surfaces the failure. Reset on each load.
@Published private(set) var pendingDisplayRejection: DisplayRejection?

/// AE#561: a failure this host would otherwise have made terminal, offered to the engine's own
/// decoder instead. The engine's subscriber rebuilds the session on the software path at the
/// carried position. Reset on each load.
@Published private(set) var pendingSoftwarePathEscalation: SoftwarePathEscalation.Request?

/// AetherEngine#168: dynamic range read back from the item's parsed video-track CMFormatDescription,
/// so the probe-free `nativeRemoteHLS` bypass can report the real format instead of the `.sdr` default.
/// nil until a video track resolves (or when none does: the audio-only black-screen symptom). The
Expand Down Expand Up @@ -524,6 +532,7 @@ final class NativeAVPlayerHost {
}
failure = nil
pendingDisplayRejection = nil
pendingSoftwarePathEscalation = nil
lastSuppressedStartupFailure = nil
isReady = false
seekableEnd = 0
Expand Down Expand Up @@ -891,6 +900,30 @@ final class NativeAVPlayerHost {
/// Discriminates on hasEverPlayed, not instantaneous timeControlStatus: .failed and timeControlStatus KVOs are unsynchronized (426b45c: still published terminal failure at 27.3s while AVPlayer played smoothly).
/// Before first .playing: surface promptly (genuine startup failure). After: defer 5s and confirm -- clear if .playing or clock advanced, surface if both stopped.
@MainActor
/// AE#561: offer a failure to the engine's own decoder before making it terminal. True when the
/// offer was made, in which case nothing is surfaced here and the engine rebuilds the session.
private func offerToSoftwarePath(_ desc: String, item: AVPlayerItem, position: Double) -> Bool {
let nsError = item.error as NSError?
guard SoftwarePathEscalation.shouldEscalate(
errorDomain: nsError?.domain,
availability: softwarePathAvailability?()
) else { return false }
let at = position.isFinite ? String(format: "%.2f", position) + "s" : "an unreadable position"
EngineLog.emit(
"[NativeAVPlayerHost] #\(sessionID) #561 AVPlayer refused the media "
+ "(\(nsError?.domain ?? "?")/\(nsError?.code ?? 0)) at \(at); handing the session to the "
+ "engine's own decoder instead of surfacing: \(desc)",
category: .engine
)
pendingSoftwarePathEscalation = SoftwarePathEscalation.Request(
domain: nsError?.domain ?? "",
code: nsError?.code ?? 0,
message: desc,
positionSeconds: position.isFinite ? max(0, position) : 0
)
return true
}

private func handleItemFailed(_ desc: String, item: AVPlayerItem) {
// Ignore a late `.failed` KVO from an item we have already replaced.
guard playerItem === item else { return }
Expand Down Expand Up @@ -939,6 +972,9 @@ final class NativeAVPlayerHost {
domain: (item.error as NSError?)?.domain)
return
}
// AE#561: a startup failure on the media itself (a segment Apple's parser refuses) is
// not the end of the source, only of this consumer's opinion of it.
if offerToSoftwarePath(desc, item: item, position: renderedTime) { return }
failure = Self.itemFailureInfo(desc: desc, itemError: item.error,
relayRefusalCode: upstreamTrustRefusal?())
return
Expand Down Expand Up @@ -968,6 +1004,7 @@ final class NativeAVPlayerHost {
+ "clock=\(String(format: "%.2f", self.renderedTime)))",
category: .engine
)
if self.offerToSoftwarePath(desc, item: item, position: self.renderedTime) { return }
self.failure = Self.itemFailureInfo(desc: desc, itemError: item.error,
relayRefusalCode: self.upstreamTrustRefusal?())
} else {
Expand Down
79 changes: 79 additions & 0 deletions Sources/AetherEngine/Native/SoftwarePathEscalation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import Foundation

/// The last rung under a native session AVPlayer will not play: hand the source to the engine's own
/// decoder instead of ending the session (AE#561).
///
/// Every recovery above this one reloads the SAME item against the SAME bytes: the #93 revive
/// reloads at the position that died, and the stage-2 chain refills the same segment. That is the
/// right answer to a transient, and no answer at all to a segment AVPlayer refuses on its merits,
/// which is what a damaged source produces. The reporter's capture shows the shape exactly: the
/// item dies with `-19602`, the reload lands on the same segment, and the replacement item dies on
/// it 62 ms later.
///
/// `SoftwarePlaybackHost` decodes with libavcodec, which answers a sample Apple's parser rejects by
/// skipping one frame, and it reads the demuxer directly rather than the loopback HLS the native
/// path is served over, so it also steps around a local-server wedge. One escalation per session,
/// because a second one could only repeat the first.
enum SoftwarePathEscalation {

/// What the host hands the engine when it would otherwise surface a terminal failure.
struct Request: Equatable, Sendable {
/// The failure that prompted it, carried so the engine's log names the real cause.
let domain: String
let code: Int
let message: String
/// Where the session was, so the rebuild lands where the viewer was watching.
let positionSeconds: Double
}

/// One session's single escalation, shared with the mount-time closure the host reads.
///
/// A reference type because the host's probe is `@Sendable` and the latch it has to see lives on
/// the engine. `take()` is what spends it, so two failures arriving together cannot both rebuild.
final class Budget: @unchecked Sendable {
private let lock = NSLock()
private var spent = false

var isSpent: Bool {
lock.lock(); defer { lock.unlock() }
return spent
}

/// True when this call took the escalation, false when it was already gone.
func take() -> Bool {
lock.lock(); defer { lock.unlock() }
if spent { return false }
spent = true
return true
}
}

/// What the session can still offer, answered by the engine because the host owns none of it.
struct Availability: Equatable, Sendable {
/// This session has spent its one escalation already.
let alreadyEscalated: Bool
/// The path the session is running on.
let preferredDecodePath: DecodePath
/// The remote-HLS bypass, where the engine decodes nothing at all.
let nativeRemoteHLS: Bool
}

/// The domain of a media failure, i.e. AVFoundation could not make sense of what it was served.
static let mediaErrorDomain = "CoreMediaErrorDomain"

/// Whether a failed native item is worth handing to the engine's own decoder.
///
/// The domain is the discriminator. A CoreMedia failure is a verdict on the MEDIA, which is the
/// one thing a second decoder can disagree with. A URL-loading failure is a verdict on the
/// SOURCE, which both paths read through the same reader, so escalating one would only spend a
/// rebuild to fail the same way a few seconds later. Nil availability means no engine answered,
/// which is never a reason to swallow a failure.
static func shouldEscalate(errorDomain: String?, availability: Availability?) -> Bool {
guard let availability, !availability.alreadyEscalated else { return false }
// Already there, or the host asked for a path this cannot improve on.
guard availability.preferredDecodePath == .automatic else { return false }
// The bypass has no local muxer and decodes nothing here, so #461 ignores the option anyway.
guard !availability.nativeRemoteHLS else { return false }
return errorDomain == mediaErrorDomain
}
}
6 changes: 6 additions & 0 deletions Sources/AetherEngine/Video/MP4SegmentMuxer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ final class MP4SegmentMuxer {
/// length-prefixed at all. Latched at init: it is a property of the configuration record that
/// lands in the sample entry, and the AE#561 sanitizer walks every video sample with it.
private let videoNALLengthPrefixSize: Int?
/// AE#561 harness switch: the sanitizer removes the only shape that reproduces a segment Apple's
/// parser refuses, so the rung underneath it (the software-path escalation) would have nothing to
/// be measured against. Read once from the environment, never set in a shipped configuration.
static let nalChainSanitizerDisabled =
ProcessInfo.processInfo.environment["AETHER_DISABLE_NAL_SANITIZER"] != nil
/// How many video samples the AE#561 sanitizer has had to cut, over this muxer's life.
private var truncatedVideoSamples: Int = 0

Expand Down Expand Up @@ -616,6 +621,7 @@ final class MP4SegmentMuxer {
// is why the file plays elsewhere. Cut the sample at its last complete NAL, which is what
// MKVToolNix writes when it remuxes one of these files.
if streamIndex == videoOutputStreamIndex,
!Self.nalChainSanitizerDisabled,
let lengthPrefixSize = videoNALLengthPrefixSize,
let data = packet.pointee.data,
packet.pointee.size > 0,
Expand Down
Loading
Loading