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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ the public-API contract.

### 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). A transport failure at open (a reset or a timeout from
an origin still spinning up) is still ridden out by the reconnect ladder; its URL code is recorded
and attached to `liveSourceUnavailable` as `underlyingDomain` / `underlyingCode` only when a live
start finally gives up. 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. A held HTTP/1 open that fails
after its response head reports the hop that answered and its status. The observer does not alter
request policy and requires hosts to redact source URLs.

- **A plain-URL live open the origin answers with a status fails with that answer (#796, thanks @cmcpherson274).**
7.38.0 typed the refusal in the reader, but the live fail-fast after the probe dropped it, so a 503
reached the host as `liveSourceUnavailable` and `load` threw `DemuxerError.openFailed(-5)`. It now
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 @@ -596,6 +596,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 @@ -779,7 +780,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 @@ -886,6 +888,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 @@ -1946,6 +1949,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 @@ -2207,6 +2211,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 @@ -2224,7 +2229,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 @@ -2292,6 +2297,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 @@ -2305,7 +2311,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 @@ -2589,6 +2595,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
53 changes: 26 additions & 27 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4034,7 +4034,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 @@ -4077,10 +4078,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 @@ -4209,7 +4212,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 @@ -4511,7 +4515,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 @@ -4552,6 +4557,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 @@ -4676,20 +4682,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 @@ -4724,7 +4722,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 @@ -4781,13 +4780,13 @@ public final class AetherEngine: ObservableObject {
// Proceeding would dispatch on codec NONE and grind another ~30 s before erroring.
if options.isLive, !probeOpened {
// #796: a live open the origin answered with a status fails with that answer, as the VOD
// path's does. Without this a 503 from a metered panel reached the host as an outage.
if let readerError = probeFailure as? AVIOReaderError, case .httpStatus = readerError {
publishLoadFailure(readerError)
throw readerError
}
publishError(.liveSourceUnavailable, "Live source unavailable")
throw DemuxerError.openFailed(code: -5)
// path's does (`publishLoadFailure` types it as sourceRefused / sourceRateLimited and the
// reader's httpStatus is thrown). A live open that reaches nothing keeps
// liveSourceUnavailable: the reader rode its transport failures out on the reconnect
// ladder, and the URL error its last connection ended with is the cause it gives up with.
let failure = probeFailure ?? DemuxerError.openFailed(code: -5)
publishLoadFailure(failure, isLiveStart: true, transportFailureCode: probe.openTransportFailureCode)
throw failure
}

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