Skip to content
Open
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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@ the public-API contract.

## [Unreleased]

_Nothing yet._
### Fixed

- **Source failures keep their cause and request ownership.** Initial live probes preserve HTTP,
URL and TLS failures without changing the live-start `liveSourceUnavailable` kind (HTTP and
certificate classifications stay specific). HLS and demux errors retain their existing NSError domains/case codes while
exposing bounded diagnostic reasons and the original FFmpeg code separately. Optional
`SourceNetworkDiagnostics` events carry a captured playback/attempt context across redirects and
internal reloads and AVIO thread crossings, including streamed held HTTP/1 bodies. Immutable
events use extensible string-backed roles and phases. Refused AES key requests use the historical
`playlistUnreachable(status:)` case to preserve their HTTP status. Retiring a failed ranged connection clears
its transport error so a healthy forward-only replacement can open; replacement failures still
propagate. The observer does not alter request policy and requires hosts to redact source URLs.

## [7.45.0] - 2026-10-21

Expand Down
13 changes: 10 additions & 3 deletions Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ extension AetherEngine {
// AE#154: a VOD resume anchor seeks; nil keeps the live no-initial-seek contract.
skipInitialSeek: startPosition == nil,
inPlaceSwap: inPlaceHandover,
diagnosticContext: options.sourceDiagnosticsContext,
contract: .init(
isLive: options.isLive,
// AE#440: the same join tail exists where AVPlayer owns the buffer; the engine only
Expand Down Expand Up @@ -766,7 +767,8 @@ extension AetherEngine {
if rememberVerdict { rerouteVerdictMemory.record(url, now: Date()) }
var options = loadedOptions
options.nativeRemoteHLS = false
let reader = HLSLiveIngestReader(playlistURL: url, httpHeaders: options.httpHeaders)
let reader = HLSLiveIngestReader(playlistURL: url, httpHeaders: options.httpHeaders,
diagnosticContext: options.sourceDiagnosticsContext)
do {
_ = try await load(source: .custom(reader, formatHint: "mpegts"), options: options)
} catch is CancellationError {
Expand Down Expand Up @@ -872,6 +874,7 @@ extension AetherEngine {
// path only for the host's explicit fastZap profile.
liveJoinProfile: loadedOptions.liveJoinProfile,
sourceOpenPolicy: loadedOptions.sourceOpenPolicy,
diagnosticContext: loadedOptions.sourceDiagnosticsContext,
liveStartupGraceSeconds: loadedOptions.liveStartupGraceSeconds,
liveStartupSingleSegmentMinimumSeconds: loadedOptions.liveStartupSingleSegmentMinimumSeconds,
liveEdgeCushionSeconds: loadedOptions.liveEdgeCushionSeconds,
Expand Down Expand Up @@ -1912,6 +1915,7 @@ extension AetherEngine {
skipInitialSeek: LiveReloadPolicy.skipInitialSeek(
isLive: isLive, isRejoin: liveRejoin),
inPlaceSwap: inPlaceHandover,
diagnosticContext: loadedOptions.sourceDiagnosticsContext,
contract: .init(
isLive: isLive,
// AE#440: the join tail, opt-in. The host itself gates this on `isLive`.
Expand Down Expand Up @@ -2167,6 +2171,7 @@ extension AetherEngine {
let sequentialOrigin = loadedOptions.sequentialOrigin
let heldSourceConnection = loadedOptions.heldSourceConnection
let sourceOpenPolicy = loadedOptions.sourceOpenPolicy
let diagnosticContext = loadedOptions.sourceDiagnosticsContext
let declaredDuration = loadedOptions.declaredDurationSeconds
// Built on the main actor, captured into the detach: surfaces source stall/reconnect to playbackPhase (#85).
let networkPhaseSink: @Sendable (ReaderNetworkPhase) -> Void = { [weak self] phase in
Expand All @@ -2184,7 +2189,7 @@ extension AetherEngine {
dem = pre
} else {
dem = Demuxer()
try dem.open(url: url, extraHeaders: sourceHTTPHeaders, profile: .playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: maxAnalyzeDuration).withSequentialOrigin(sequentialOrigin, declaredDuration: declaredDuration).withHeldSourceConnection(heldSourceConnection).withSourceOpenPolicy(sourceOpenPolicy).withRequestHeaders(requestHeaders), isLive: isLive)
try dem.open(url: url, extraHeaders: sourceHTTPHeaders, profile: .playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: maxAnalyzeDuration).withSequentialOrigin(sequentialOrigin, declaredDuration: declaredDuration).withHeldSourceConnection(heldSourceConnection).withSourceOpenPolicy(sourceOpenPolicy).withDiagnosticContext(diagnosticContext).withRequestHeaders(requestHeaders), isLive: isLive)
}
dem.onNetworkPhaseChanged = networkPhaseSink
try await host.load(
Expand Down Expand Up @@ -2252,6 +2257,7 @@ extension AetherEngine {
let sequentialOrigin = loadedOptions.sequentialOrigin
let heldSourceConnection = loadedOptions.heldSourceConnection
let sourceOpenPolicy = loadedOptions.sourceOpenPolicy
let diagnosticContext = loadedOptions.sourceDiagnosticsContext
let declaredDuration = loadedOptions.declaredDurationSeconds
// Built on the main actor, captured into the detach: surfaces source stall/reconnect to playbackPhase (#85).
let networkPhaseSink: @Sendable (ReaderNetworkPhase) -> Void = { [weak self] phase in
Expand All @@ -2265,7 +2271,7 @@ extension AetherEngine {
dem = pre
} else {
dem = Demuxer()
try dem.open(url: url, extraHeaders: sourceHTTPHeaders, profile: .playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: maxAnalyzeDuration).withSequentialOrigin(sequentialOrigin, declaredDuration: declaredDuration).withHeldSourceConnection(heldSourceConnection).withSourceOpenPolicy(sourceOpenPolicy).withRequestHeaders(requestHeaders))
try dem.open(url: url, extraHeaders: sourceHTTPHeaders, profile: .playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: maxAnalyzeDuration).withSequentialOrigin(sequentialOrigin, declaredDuration: declaredDuration).withHeldSourceConnection(heldSourceConnection).withSourceOpenPolicy(sourceOpenPolicy).withDiagnosticContext(diagnosticContext).withRequestHeaders(requestHeaders))
}
dem.onNetworkPhaseChanged = networkPhaseSink
try await host.load(
Expand Down Expand Up @@ -2549,6 +2555,7 @@ extension AetherEngine {
let reloadProfile = DemuxerOpenProfile.playback.withProbeBudget(
probesize: loadedOptions.probesize, maxAnalyzeDuration: loadedOptions.maxAnalyzeDuration)
.withSourceOpenPolicy(loadedOptions.sourceOpenPolicy)
.withDiagnosticContext(loadedOptions.sourceDiagnosticsContext)
var customPreopened: Demuxer? = nil
if isCustomSource, let reader = customReader {
let hint = customFormatHint
Expand Down
9 changes: 7 additions & 2 deletions Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,9 @@ public enum SessionReloadRefusal: String, Sendable, Equatable, CustomStringConve
/// deciding which half it belongs in.
enum SessionOptionCorrection {

/// The fields that NAME the session rather than tune it. Each opens the source on a different
/// pipeline, and the engine writes the last two itself, so a host write races its determination.
/// The fields that NAME the session rather than tune it: source pipeline and diagnostic identity.
static let loadIdentityFields: [String] = [
"sourceDiagnosticsContext",
"isLive",
"audioOnly",
"nativeRemoteHLS",
Expand Down Expand Up @@ -333,6 +333,10 @@ enum SessionOptionCorrection {
/// torn down, so it does not ride on reflection.
static func refusedFields(from current: LoadOptions, to proposed: LoadOptions) -> [String] {
var refused: [String] = []
// Internal reopens retain the load's request identity; a new identity needs a new load.
if proposed.sourceDiagnosticsContext != current.sourceDiagnosticsContext {
refused.append("sourceDiagnosticsContext")
}
if proposed.isLive != current.isLive { refused.append("isLive") }
if proposed.audioOnly != current.audioOnly { refused.append("audioOnly") }
if proposed.nativeRemoteHLS != current.nativeRemoteHLS { refused.append("nativeRemoteHLS") }
Expand Down Expand Up @@ -443,6 +447,7 @@ enum SessionOptionCorrection {
/// right for a tuning lever and wrong for an identity one, so the choice has to be made
/// deliberately. Update this list and, if the field names the session, `loadIdentityFields`.
static let knownFields: [String] = [
"sourceDiagnosticsContext",
"omitCriteriaColorExtensions", "suppressDisplayCriteria", "displayCriteriaOwner", "httpHeaders", "nativeVideoFrameDiagnostics", "nativeVideoFrameRecovery", "hostRetriesExhaustedLiveReplay",
"keepDvh1TagWithoutDV", "forceDolbyVisionOnNonDVDisplay", "dolbyVisionHandling", "preferDualLayerDolbyVision", "matchContentEnabled",
"panelIsInHDRMode", "attemptsHDRMasterOnUnprovenPanel", "panelPresentsDolbyVision",
Expand Down
46 changes: 24 additions & 22 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3986,7 +3986,8 @@ public final class AetherEngine: ObservableObject {
guard let reader = try await HLSVODIngestReader.makeIfHEVCMPEGTS(
playlistURL: playlistURL,
httpHeaders: options.httpHeaders,
persistentHTTP1: options.heldSourceConnection
persistentHTTP1: options.heldSourceConnection,
diagnosticContext: options.sourceDiagnosticsContext
) else { return .notTaken }
try checkLoadCurrent(generation)
EngineLog.emit(
Expand Down Expand Up @@ -4029,10 +4030,12 @@ public final class AetherEngine: ObservableObject {
defer { if let gen = attempt.generation { waitingLoadGenerations.remove(gen) } }
do {
let probe = try await withTaskCancellationHandler {
try await loadSession(
source: source, startPosition: startPosition, options: options,
audioSourceStreamIndex: audioSourceStreamIndex, discTitleID: discTitleID,
attempt: attempt)
try await SourceNetworkDiagnostics.$context.withValue(options.sourceDiagnosticsContext) {
try await loadSession(
source: source, startPosition: startPosition, options: options,
audioSourceStreamIndex: audioSourceStreamIndex, discTitleID: discTitleID,
attempt: attempt)
}
} onCancel: {
Task { @MainActor [weak self] in self?.abandonCancelledLoad(attempt) }
}
Expand Down Expand Up @@ -4161,7 +4164,8 @@ public final class AetherEngine: ObservableObject {
)
options.nativeRemoteHLS = false
source = .custom(
HLSLiveIngestReader(playlistURL: candidate, httpHeaders: options.httpHeaders),
HLSLiveIngestReader(playlistURL: candidate, httpHeaders: options.httpHeaders,
diagnosticContext: options.sourceDiagnosticsContext),
formatHint: "mpegts"
)
}
Expand Down Expand Up @@ -4463,7 +4467,8 @@ public final class AetherEngine: ObservableObject {
return try await load(
source: .custom(
HLSLiveIngestReader(playlistURL: livePlaylistURL,
httpHeaders: loadedOptions.httpHeaders),
httpHeaders: loadedOptions.httpHeaders,
diagnosticContext: loadedOptions.sourceDiagnosticsContext),
formatHint: "mpegts"
),
startPosition: startPosition,
Expand Down Expand Up @@ -4504,6 +4509,7 @@ public final class AetherEngine: ObservableObject {
declaredDuration: options.declaredDurationSeconds)
.withHeldSourceConnection(options.heldSourceConnection)
.withSourceOpenPolicy(options.sourceOpenPolicy)
.withDiagnosticContext(options.sourceDiagnosticsContext)
.withRequestHeaders(requestHeaders)
switch source {
case .url(let u):
Expand Down Expand Up @@ -4628,20 +4634,12 @@ public final class AetherEngine: ObservableObject {
// reader's is (`publishLoadFailure`), not a stream the probe could not read: a host that
// takes a custom probe failure to mean the format moves the channel to another route for
// good, over a 509 an IPTV panel gives while another device holds the account's last slot.
if let ingest = reader as? HLSLiveIngestReader,
case .playlistUnreachable(let status)? = ingest.terminalError {
if status > 0 {
let kind: PlaybackErrorKind = AVIOReader.isRateLimitStatus(status) ? .sourceRateLimited : .sourceRefused
publishError(PlaybackErrorInfo(kind: kind,
message: "Failed to load: the live playlist answered HTTP \(status)",
underlyingCode: status))
} else {
publishError(.liveSourceUnavailable, "Failed to load: the live playlist could not be reached")
}
throw DemuxerError.openFailed(code: -1)
if let ingest = reader as? HLSLiveIngestReader, let failure = ingest.terminalFailure {
publishLoadFailure(failure, isLiveStart: true)
throw failure
}
publishError(.customSourceProbeFailed, "Failed to load: custom source probe failed", underlying: probeFailure)
throw DemuxerError.openFailed(code: -1)
throw probeFailure ?? DemuxerError.openFailed(code: -1)
}

// The reader already spent the configured initial and recovery budgets. Reopening
Expand Down Expand Up @@ -4676,7 +4674,8 @@ public final class AetherEngine: ObservableObject {
return try await load(
source: .custom(
HLSLiveIngestReader(playlistURL: livePlaylistURL,
httpHeaders: loadedOptions.httpHeaders),
httpHeaders: loadedOptions.httpHeaders,
diagnosticContext: loadedOptions.sourceDiagnosticsContext),
formatHint: "mpegts"
),
startPosition: startPosition,
Expand Down Expand Up @@ -4732,8 +4731,11 @@ public final class AetherEngine: ObservableObject {
// Live fail-fast: a failed probe means the AVIOReader burned its full reconnect budget.
// Proceeding would dispatch on codec NONE and grind another ~30 s before erroring.
if options.isLive, !probeOpened {
publishError(.liveSourceUnavailable, "Live source unavailable")
throw DemuxerError.openFailed(code: -5)
// A failed initial probe is not a runtime outage. Keep the transport,
// HTTP or demux cause so hosts can choose recovery without guessing.
let failure = probeFailure ?? DemuxerError.openFailed(code: -5)
publishLoadFailure(failure, isLiveStart: true)
throw failure
}

// Forward-only custom sources cannot rewind; audio-switch and background-reload stay no-op for them.
Expand Down
Loading
Loading