From 6f1608d7ad08c0594bc52e1161839983e81d814e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Okan=20Yu=CC=88cel?= Date: Sun, 18 Oct 2026 01:23:17 +0300 Subject: [PATCH 1/2] fix(diagnostics): preserve source failures and request ownership --- CHANGELOG.md | 13 +- .../AetherEngine/AetherEngine+Loading.swift | 13 +- .../AetherEngine+ReloadWithOptions.swift | 9 +- Sources/AetherEngine/AetherEngine.swift | 46 +- Sources/AetherEngine/Demuxer/AVIOReader.swift | 91 ++- Sources/AetherEngine/Demuxer/Demuxer.swift | 45 +- .../SourceNetworkDiagnostics.swift | 178 ++++++ .../IO/HLSIngest/BoundedPlaylistFetch.swift | 26 +- .../IO/HLSIngest/HLSCarriageProbe.swift | 59 +- .../IO/HLSIngest/HLSIngestError.swift | 112 +++- .../IO/HLSIngest/HLSLiveIngestReader.swift | 50 +- .../IO/HLSIngest/HLSVODIngestReader.swift | 31 +- .../HLSIngest/PersistentHTTP1Transport.swift | 63 +- .../Native/NativeAVPlayerHost.swift | 32 +- Sources/AetherEngine/PlaybackErrorInfo.swift | 45 +- Sources/AetherEngine/PlayerState.swift | 2 + .../AetherEngine/Video/HLSVideoEngine.swift | 3 + .../DocumentedConstantsTests.swift | 7 + .../HLSIngestDiagnosticsTests.swift | 160 +++++ .../Issue460ReloadWithOptionsTests.swift | 64 ++ .../LostSourceReadErrorTests.swift | 45 +- .../PlaybackErrorInfoTests.swift | 74 ++- .../PublicAPIDocumentationTests.swift | 1 + .../SequentialOriginReaderTests.swift | 27 +- .../SourceFailureDiagnosticsTests.swift | 594 ++++++++++++++++++ docs/api.md | 87 ++- 26 files changed, 1732 insertions(+), 145 deletions(-) create mode 100644 Sources/AetherEngine/Diagnostics/SourceNetworkDiagnostics.swift create mode 100644 Tests/AetherEngineTests/HLSIngestDiagnosticsTests.swift create mode 100644 Tests/AetherEngineTests/SourceFailureDiagnosticsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c140b6d..a3a795cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index d70c71f5..d0f82104 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -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 @@ -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 { @@ -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, @@ -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`. @@ -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 @@ -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( @@ -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 @@ -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( @@ -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 diff --git a/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift b/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift index 8e24ae2e..0eaa4039 100644 --- a/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift +++ b/Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift @@ -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", @@ -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") } @@ -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", diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 73a8de07..6e927843 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -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( @@ -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) } } @@ -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" ) } @@ -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, @@ -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): @@ -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 @@ -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, @@ -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. diff --git a/Sources/AetherEngine/Demuxer/AVIOReader.swift b/Sources/AetherEngine/Demuxer/AVIOReader.swift index 664ad8e7..70eb5a2b 100644 --- a/Sources/AetherEngine/Demuxer/AVIOReader.swift +++ b/Sources/AetherEngine/Demuxer/AVIOReader.swift @@ -573,6 +573,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { /// is not the one the open is waiting on: without it the open fails as FFmpeg's invalid data and a /// self-signed origin is indistinguishable from a corrupt file. Guarded by `streamLock`. private var transportSecurityCode = 0 + private var streamURLFailureCode: Int? // MARK: - Persistent Mode (single forward-streaming connection, playback path) @@ -807,6 +808,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { // Connection state. private var connEnded = false private var connStatus = 0 + private var connURLFailureCode: Int? // AE#792: the header store's revision when the current generation's request was built. private var connHeaderRevision: UInt64 = 0 // Retry-After seconds from a rate-limit status, honoured before reconnect. @@ -1176,16 +1178,20 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { /// #240: which reader this is, for the connection log. Several readers run against the same /// origin at once and the line used to name none of them. private let label: String + private let diagnosticContext: SourceNetworkDiagnostics.Context? private let sourceOpenPolicy: SourceOpenPolicy private let openDiagnosticID = String(UUID().uuidString.prefix(8)) /// Only static controlled probes use this; playback retains its existing transport policy. private let probeControl: ProbeControl? private let probeRequestSession: URLSession? + private let persistentRequestSession: URLSession? private let probeDrainLock = NSLock() private var drainingProbeRequest = false - init(url: URL, extraHeaders: [String: String] = [:], label: String = "source", chunkSize: Int = 4 * 1024 * 1024, prefetchEnabled: Bool = true, isLive: Bool = false, chunkRequestTimeout: TimeInterval = 35, chunkMaxRetries: Int = 3, boundedInitialFetch: Int64? = nil, sequentialOnly: Bool = false, connStallTimeout: TimeInterval = AVIOReader.connStallTimeoutDefault, windowHighWater: Int? = nil, streamHighWater: Int? = nil, detourIdleSeconds: TimeInterval? = nil, detourFetchBudget: TimeInterval? = nil, heldConnection: Bool = false, probeControl: ProbeControl? = nil, probeRequestSession: URLSession? = nil, sourceOpenPolicy: SourceOpenPolicy = .init(), requestHeaderStore: RequestHeaderStore? = nil, backoffScale: Double = 1, pinIdleSeconds: TimeInterval = AVIOReader.pinIdleRepinSecondsDefault, liveRefusalRetryBudget: TimeInterval = AVIOReader.liveRefusalRetryBudget) { + init(url: URL, extraHeaders: [String: String] = [:], label: String = "source", chunkSize: Int = 4 * 1024 * 1024, prefetchEnabled: Bool = true, isLive: Bool = false, chunkRequestTimeout: TimeInterval = 35, chunkMaxRetries: Int = 3, boundedInitialFetch: Int64? = nil, sequentialOnly: Bool = false, connStallTimeout: TimeInterval = AVIOReader.connStallTimeoutDefault, windowHighWater: Int? = nil, streamHighWater: Int? = nil, detourIdleSeconds: TimeInterval? = nil, detourFetchBudget: TimeInterval? = nil, heldConnection: Bool = false, probeControl: ProbeControl? = nil, probeRequestSession: URLSession? = nil, sourceOpenPolicy: SourceOpenPolicy = .init(), requestHeaderStore: RequestHeaderStore? = nil, backoffScale: Double = 1, pinIdleSeconds: TimeInterval = AVIOReader.pinIdleRepinSecondsDefault, liveRefusalRetryBudget: TimeInterval = AVIOReader.liveRefusalRetryBudget, persistentRequestSession: URLSession? = nil, diagnosticContext: SourceNetworkDiagnostics.Context? = SourceNetworkDiagnostics.context) { + self.diagnosticContext = diagnosticContext self.sourceOpenPolicy = sourceOpenPolicy + self.persistentRequestSession = persistentRequestSession self.probeControl = probeControl self.probeRequestSession = probeControl == nil ? nil : probeRequestSession self.url = url @@ -1398,6 +1404,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { close() throw AVIOReaderError.hlsPlaylistOnVODPath } + if isLive, !gotData { try failIfStreamingRefused(fallbackStatus: 0) } var tookFallback = false if !isLive { // Atomically decide, under winCond, whether the optimistic connection resolved @@ -1654,8 +1661,14 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { let refused = streamRefusedStatus let ended = streamEnded let empty = streamBuffer.isEmpty && streamBytesRead == 0 + let streamCode = ended && empty ? streamURLFailureCode : nil streamLock.unlock() - let status = refused != 0 ? refused : ((ended && empty) ? fallbackStatus : 0) + winCond.lock() + let persistentEmpty = connEnded && window.isEmpty + let persistentCode = persistentEmpty ? connURLFailureCode : nil + let persistentStatus = persistentEmpty && connStatus >= 400 ? connStatus : 0 + winCond.unlock() + let status = refused != 0 ? refused : ((ended && empty) ? fallbackStatus : (isLive ? persistentStatus : 0)) // AE#495: a handshake the system refused outranks a status, because there was never a // response to carry one. Same reason this function exists at all: the demuxer would be handed // nothing and report invalid data. @@ -1671,6 +1684,12 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { close() throw AVIOReaderError.transportSecurityFailed(code: tlsCode) } + if let code = streamCode ?? persistentCode, code != NSURLErrorCancelled, status == 0 { + markClosed() + close() + if (-1206 ... -1200).contains(code) { throw AVIOReaderError.transportSecurityFailed(code: code) } + throw URLError(URLError.Code(rawValue: code)) + } guard status != 0 else { return } EngineLog.emit( "[AVIOReader] \(label) source refused: HTTP \(status); failing the open typed", @@ -1736,6 +1755,8 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { activeTransfer = nil window.removeAll() connEnded = true + // The replacement owns its failures; do not carry this retired connection's error. + connURLFailureCode = nil winCond.broadcast() return (false, transfer, status, ignoredLength) } @@ -1757,6 +1778,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { activeTransfer = nil window.removeAll() connEnded = true + connURLFailureCode = nil winCond.broadcast() return (contentLength, transfer) } @@ -3170,6 +3192,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { let delegate = TailPrefetchDelegate( expectedLength: Self.tailPrefetchBytes, + diagnosticContext: diagnosticContext, extraHeaders: headers(for: request.url) ) // #281 retest: one line per open, and the line the field needs. The advertised way to check @@ -3419,6 +3442,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { now: TimeInterval(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000) connRangeEnd = resolvedBound.map { offset + $0 - 1 } connStatus = 0 + connURLFailureCode = nil connRetryAfter = 0 connStartedAt = DispatchTime.now() // #93: time-to-first-data per generation connFirstDataSeen = false @@ -3492,12 +3516,13 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { } else { let delegate = PersistentReadDelegate( reader: self, + diagnosticContext: diagnosticContext, generation: generation, extraHeaders: headers(for: request.url), ticket: ticket, originURL: requestURLForBudget ) - let task = Self.persistentSession.dataTask(with: request) + let task = (persistentRequestSession ?? Self.persistentSession).dataTask(with: request) task.delegate = delegate transfer = task } @@ -3964,6 +3989,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { winCond.lock() let isCurrentGen = (generation == connGeneration) if isCurrentGen { + connURLFailureCode = SourceNetworkDiagnostics.urlErrorCode(error) connEnded = true // #220: the refill below keys off there being no live connection, so the finished // task must not stay installed. @@ -4029,6 +4055,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { let semaphore = DispatchSemaphore(value: 0) let delegate = StreamingDelegate( + diagnosticContext: diagnosticContext, extraHeaders: headers(for: request.url), onResponse: { [weak self] response in // Advisory length for the EOF/EIO distinction; -1 (chunked / unknown) leaves the @@ -4104,6 +4131,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { return } self.streamLock.lock() + self.streamURLFailureCode = SourceNetworkDiagnostics.urlErrorCode(error) if error != nil, !self.isClosed { self.streamFailed = true } self.streamEnded = true self.streamLock.unlock() @@ -4477,7 +4505,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { defer { lease.release() } guard !scope.isStopped, !isClosed, remaining() > 0 else { return -1 } request.timeoutInterval = max(0.001, remaining()) - let delegate = ProbeDelegate(extraHeaders: headers(for: request.url)) + let delegate = ProbeDelegate(extraHeaders: headers(for: request.url), diagnosticContext: diagnosticContext) let task = Self.probeSession.dataTask(with: request) task.delegate = delegate let completion = DispatchSemaphore(value: 0) @@ -4541,7 +4569,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { let lease = OriginRequestBudget.Lease(ticket) defer { lease.release() } - let delegate = ProbeDelegate(extraHeaders: headers(for: request.url)) + let delegate = ProbeDelegate(extraHeaders: headers(for: request.url), diagnosticContext: diagnosticContext) let task = (probeRequestSession ?? Self.probeSession).dataTask(with: request) task.delegate = delegate @@ -4844,7 +4872,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { defer { lease.release() } let delegate = ChunkFetchDelegate(extraHeaders: headers(for: request.url), - bodyLimit: Self.expectedBodyBytes(for: request)) + bodyLimit: Self.expectedBodyBytes(for: request), diagnosticContext: diagnosticContext) let task = (probeRequestSession ?? Self.chunkSession).dataTask(with: request) task.delegate = delegate delegate.onBodyReserve = { [weak self] reserve in @@ -5165,6 +5193,7 @@ extension URLSessionDataTask: PersistentTransfer { } private final class PersistentReadDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + private let sourceTrace: SourceNetworkDiagnostics.Trace weak var reader: AVIOReader? let generation: Int let extraHeaders: [String: String] @@ -5177,8 +5206,9 @@ private final class PersistentReadDelegate: NSObject, URLSessionDataDelegate, @u /// The URL this connection was opened against, for the one-per-origin transport line. private let originURL: URL - init(reader: AVIOReader, generation: Int, extraHeaders: [String: String], + init(reader: AVIOReader, diagnosticContext: SourceNetworkDiagnostics.Context?, generation: Int, extraHeaders: [String: String], ticket: OriginRequestBudget.Ticket?, originURL: URL) { + self.sourceTrace = .init(role: .persistentRead, context: diagnosticContext) self.reader = reader self.generation = generation self.extraHeaders = extraHeaders @@ -5218,6 +5248,7 @@ private final class PersistentReadDelegate: NSObject, URLSessionDataDelegate, @u newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { + sourceTrace.redirect(task: task, response: response, request: request) completionHandler(redirectPreservingHeaders( task: task, newRequest: request, extraHeaders: extraHeaders)) } @@ -5228,6 +5259,7 @@ private final class PersistentReadDelegate: NSObject, URLSessionDataDelegate, @u didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void ) { + sourceTrace.response(task: dataTask, response: response) guard let http = response as? HTTPURLResponse, let reader else { completionHandler(.cancel) return @@ -5255,6 +5287,7 @@ private final class PersistentReadDelegate: NSObject, URLSessionDataDelegate, @u task: URLSessionTask, didCompleteWithError error: Error? ) { + sourceTrace.completed(task: task, error: error) releaseTicket() reader?.persistentConnectionEnded(error: error, generation: generation) } @@ -5267,6 +5300,7 @@ private final class PersistentReadDelegate: NSObject, URLSessionDataDelegate, @u task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics ) { + sourceTrace.metrics(task: task, metrics: metrics) ReaderTransportLog.note(metrics, for: originURL) let hops = metrics.transactionMetrics.compactMap(AVIOReader.hopTiming) if let line = AVIOReader.slowFirstByteLine( @@ -5284,6 +5318,7 @@ private final class PersistentReadDelegate: NSObject, URLSessionDataDelegate, @u /// dispatch_data is released per delivery. @unchecked Sendable: ownership /// via semaphore ensures no concurrent access to mutable fields. private final class ChunkFetchDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + private let sourceTrace: SourceNetworkDiagnostics.Trace let extraHeaders: [String: String] /// Most this fetch will ever buffer, nil when the request is open-ended (#255). Derived from /// the request, never from the response: a declared length is the origin's claim about the @@ -5300,7 +5335,8 @@ private final class ChunkFetchDelegate: NSObject, URLSessionDataDelegate, @unche /// Reports the up-front reservation each response asked for (#255 test hook). var onBodyReserve: ((Int) -> Void)? - init(extraHeaders: [String: String], bodyLimit: Int?) { + init(extraHeaders: [String: String], bodyLimit: Int?, diagnosticContext: SourceNetworkDiagnostics.Context?) { + self.sourceTrace = .init(role: .chunkFetch, context: diagnosticContext) self.extraHeaders = extraHeaders self.bodyLimit = bodyLimit } @@ -5321,6 +5357,7 @@ private final class ChunkFetchDelegate: NSObject, URLSessionDataDelegate, @unche newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { + sourceTrace.redirect(task: task, response: response, request: request) completionHandler(redirectPreservingHeaders( task: task, newRequest: request, extraHeaders: extraHeaders)) } @@ -5331,6 +5368,7 @@ private final class ChunkFetchDelegate: NSObject, URLSessionDataDelegate, @unche didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void ) { + sourceTrace.response(task: dataTask, response: response) self.response = response if let http = response as? HTTPURLResponse { let reserve = AVIOReader.bodyReservation( @@ -5387,14 +5425,20 @@ private final class ChunkFetchDelegate: NSObject, URLSessionDataDelegate, @unche task: URLSessionTask, didCompleteWithError error: Error? ) { + sourceTrace.completed(task: task, error: error) self.error = error onCompletion?(response as? HTTPURLResponse) } + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + sourceTrace.metrics(task: task, metrics: metrics) + } + } // MARK: - Streaming Delegate private final class StreamingDelegate: NSObject, URLSessionDataDelegate { + private let sourceTrace: SourceNetworkDiagnostics.Trace let onData: @Sendable (Data) -> Void let onComplete: @Sendable (Error?) -> Void /// Response hook (advisory Content-Length capture on the sequential-origin path). @@ -5410,6 +5454,7 @@ private final class StreamingDelegate: NSObject, URLSessionDataDelegate { let extraHeaders: [String: String] init( + diagnosticContext: SourceNetworkDiagnostics.Context?, extraHeaders: [String: String] = [:], onResponse: (@Sendable (URLResponse) -> Void)? = nil, onRefused: (@Sendable (Int, URL?) -> Void)? = nil, @@ -5417,6 +5462,7 @@ private final class StreamingDelegate: NSObject, URLSessionDataDelegate { onComplete: @escaping @Sendable (Error?) -> Void ) { self.extraHeaders = extraHeaders + self.sourceTrace = .init(role: .streaming, context: diagnosticContext) self.onResponse = onResponse self.onRefused = onRefused self.onData = onData @@ -5430,6 +5476,7 @@ private final class StreamingDelegate: NSObject, URLSessionDataDelegate { newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { + sourceTrace.redirect(task: task, response: response, request: request) completionHandler(redirectPreservingHeaders( task: task, newRequest: request, extraHeaders: extraHeaders)) } @@ -5449,6 +5496,7 @@ private final class StreamingDelegate: NSObject, URLSessionDataDelegate { didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void ) { + sourceTrace.response(task: dataTask, response: response) // Redirects never reach here (willPerformHTTPRedirection follows them), so anything but // a 200/206 is the origin's verdict, not media: a 401/403 refusal, a 404, a 429, a 5xx. // Hang up at the header so the error page never enters the stream buffer, where FFmpeg @@ -5468,6 +5516,7 @@ private final class StreamingDelegate: NSObject, URLSessionDataDelegate { } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + sourceTrace.completed(task: task, error: error) #if DEBUG if let error { EngineLog.emit("[AVIOReader] Stream error: \(error.localizedDescription)", category: .demux) @@ -5475,6 +5524,10 @@ private final class StreamingDelegate: NSObject, URLSessionDataDelegate { #endif onComplete(error) } + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + sourceTrace.metrics(task: task, metrics: metrics) + } + } // MARK: - Probe Delegate @@ -5483,6 +5536,7 @@ private final class StreamingDelegate: NSObject, URLSessionDataDelegate { /// captures total from Content-Range, cancels before the body streams. /// @unchecked Sendable: single-use per probe, semaphore ownership prevents concurrency. private final class ProbeDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + private let sourceTrace: SourceNetworkDiagnostics.Trace let extraHeaders: [String: String] var totalSize: Int64? var statusCode: Int = 0 @@ -5491,7 +5545,8 @@ private final class ProbeDelegate: NSObject, URLSessionDataDelegate, @unchecked var onCompletion: ((HTTPURLResponse?) -> Void)? var onResolved: ((URL) -> Void)? - init(extraHeaders: [String: String]) { + init(extraHeaders: [String: String], diagnosticContext: SourceNetworkDiagnostics.Context?) { + self.sourceTrace = .init(role: .probe, context: diagnosticContext) self.extraHeaders = extraHeaders } @@ -5511,6 +5566,7 @@ private final class ProbeDelegate: NSObject, URLSessionDataDelegate, @unchecked newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { + sourceTrace.redirect(task: task, response: response, request: request) completionHandler(redirectPreservingHeaders( task: task, newRequest: request, extraHeaders: extraHeaders)) } @@ -5521,6 +5577,7 @@ private final class ProbeDelegate: NSObject, URLSessionDataDelegate, @unchecked didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void ) { + sourceTrace.response(task: dataTask, response: response) defer { completionHandler(.cancel) } guard let http = response as? HTTPURLResponse else { return } let status = http.statusCode @@ -5536,9 +5593,14 @@ private final class ProbeDelegate: NSObject, URLSessionDataDelegate, @unchecked } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + sourceTrace.completed(task: task, error: error) self.error = error onCompletion?(response) } + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + sourceTrace.metrics(task: task, metrics: metrics) + } + } /// Which origins have already shown they cannot answer a suffix range, so the next open does not @@ -5621,6 +5683,7 @@ final class SuffixRangeSupport: @unchecked Sendable { /// whole file. That body is rejected at the header, before a byte of it is accepted, and the /// collected length is capped besides, so a lying `Content-Range` cannot grow this either. private final class TailPrefetchDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + private let sourceTrace: SourceNetworkDiagnostics.Trace enum Outcome { case span(Int64, Data) /// Named so the log says WHICH way an origin declined, since "no suffix ranges", "a 200 with @@ -5650,7 +5713,8 @@ private final class TailPrefetchDelegate: NSObject, URLSessionDataDelegate, @unc /// silent failure would be a caller waiting out its whole budget for bytes that are never coming. var onOutcome: ((Outcome) -> Void)? - init(expectedLength: Int, extraHeaders: [String: String]) { + init(expectedLength: Int, diagnosticContext: SourceNetworkDiagnostics.Context?, extraHeaders: [String: String]) { + self.sourceTrace = .init(role: .tailPrefetch, context: diagnosticContext) self.expectedLength = expectedLength self.extraHeaders = extraHeaders } @@ -5664,6 +5728,7 @@ private final class TailPrefetchDelegate: NSObject, URLSessionDataDelegate, @unc ) { // Same policy as every other delegate here: credential headers do not follow a redirect // to another host (the #126 cross-origin token replay). + sourceTrace.redirect(task: task, response: response, request: request) completionHandler(redirectPreservingHeaders( task: task, newRequest: request, extraHeaders: extraHeaders)) } @@ -5674,6 +5739,7 @@ private final class TailPrefetchDelegate: NSObject, URLSessionDataDelegate, @unc didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void ) { + sourceTrace.response(task: dataTask, response: response) guard let http = response as? HTTPURLResponse else { rejection = ("no HTTP response", .declinedByOrigin) completionHandler(.cancel) @@ -5707,6 +5773,7 @@ private final class TailPrefetchDelegate: NSObject, URLSessionDataDelegate, @unc } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + sourceTrace.completed(task: task, error: error) let outcome = self.outcome(error: error) onOutcome?(outcome) onOutcome = nil @@ -5726,6 +5793,10 @@ private final class TailPrefetchDelegate: NSObject, URLSessionDataDelegate, @unc } return .span(start, buffer) } + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + sourceTrace.metrics(task: task, metrics: metrics) + } + } // MARK: - C Callbacks diff --git a/Sources/AetherEngine/Demuxer/Demuxer.swift b/Sources/AetherEngine/Demuxer/Demuxer.swift index 00353a96..6bed2290 100644 --- a/Sources/AetherEngine/Demuxer/Demuxer.swift +++ b/Sources/AetherEngine/Demuxer/Demuxer.swift @@ -82,6 +82,8 @@ struct DemuxerOpenProfile: Sendable { /// distinguished them. Defaults to the pump, since every other path builds its profile explicitly. var readerLabel: String = "pump" var sourceOpenPolicy: SourceOpenPolicy = .init() + /// Captured by the load before detached opens; retained by each fresh session reader. + var diagnosticContext: SourceNetworkDiagnostics.Context? = nil /// AE#792: the session's live header set. A reader opened with one reads its headers from it per /// request instead of keeping the `extraHeaders` it was opened with, so a session-scoped open (the /// playback demuxer, the producer's reopens) follows `updateRequestHeaders(_:)`. Nil everywhere else. @@ -93,6 +95,12 @@ struct DemuxerOpenProfile: Sendable { return copy } + func withDiagnosticContext(_ context: SourceNetworkDiagnostics.Context?) -> DemuxerOpenProfile { + var copy = self + copy.diagnosticContext = context + return copy + } + func withRequestHeaders(_ store: RequestHeaderStore?) -> DemuxerOpenProfile { var copy = self copy.requestHeaders = store @@ -675,7 +683,7 @@ public final class Demuxer: @unchecked Sendable { self.openProfile = profile beginOpenTiming() self.auditSource = isLive ? nil : (url, extraHeaders) - let isHTTP = url.scheme == "http" || url.scheme == "https" + let isHTTP = ["http", "https"].contains(url.scheme?.lowercased() ?? "") if isHTTP { try openHTTP(url: url, extraHeaders: extraHeaders, isLive: isLive, selectTitleID: selectTitleID) @@ -806,7 +814,8 @@ public final class Demuxer: @unchecked Sendable { sequentialOnly: openProfile.avioSequentialOnly, heldConnection: openProfile.avioHeldConnection, sourceOpenPolicy: openProfile.sourceOpenPolicy, - requestHeaderStore: openProfile.requestHeaders + requestHeaderStore: openProfile.requestHeaders, + diagnosticContext: openProfile.diagnosticContext ) reader.onNetworkPhaseChanged = onNetworkPhaseChanged reader.playIntentProvider = playIntentProvider @@ -3054,11 +3063,41 @@ public final class Demuxer: @unchecked Sendable { } } -enum DemuxerError: Error, CustomStringConvertible, LocalizedError { +enum DemuxerError: Error, CustomNSError, CustomStringConvertible, LocalizedError { case openFailed(code: Int32) case streamInfoFailed(code: Int32) case readFailed(code: Int32) + static var errorDomain: String { "AetherEngine.DemuxerError" } + static let ffmpegErrorCodeKey = "AetherFFmpegErrorCode" + var errorUserInfo: [String: Any] { + let code: Int32 + switch self { + case .openFailed(let value), .streamInfoFailed(let value), .readFailed(let value): code = value + } + return [NSLocalizedDescriptionKey: description, Self.ffmpegErrorCodeKey: Int(code)] + } + // Preserve Swift's implicit NSError codes from before CustomNSError conformance. + var errorCode: Int { + switch self { + case .openFailed: 0 + case .streamInfoFailed: 1 + case .readFailed: 2 + } + } + + static func preservedFFmpegCode(in error: Error?) -> Int? { + var next = error as NSError? + for _ in 0..<6 { + guard let current = next else { break } + if current.domain == errorDomain, + let code = current.userInfo[ffmpegErrorCodeKey] as? Int, + code < 0, Int32(exactly: code) != nil { return code } + next = current.userInfo[NSUnderlyingErrorKey] as? NSError + } + return nil + } + /// AE#283: this is the most common error at the load boundary, and the AVERROR code is the whole /// diagnosis (INVALIDDATA vs a POSIX errno vs EOF). Rendering it keeps a refused transcode /// distinguishable from a corrupt file. diff --git a/Sources/AetherEngine/Diagnostics/SourceNetworkDiagnostics.swift b/Sources/AetherEngine/Diagnostics/SourceNetworkDiagnostics.swift new file mode 100644 index 00000000..a3d34823 --- /dev/null +++ b/Sources/AetherEngine/Diagnostics/SourceNetworkDiagnostics.swift @@ -0,0 +1,178 @@ +import Foundation + +/// Observes real source requests. Hosts must redact URLs before persistence; +/// no headers, response bodies or localized error messages are included. +public enum SourceNetworkDiagnostics { + /// Host-owned opaque identifiers. They are never sent as HTTP headers. + public struct Context: Sendable, Equatable { + public let playbackID: UUID + public let attemptID: UUID + public init(playbackID: UUID, attemptID: UUID) { + self.playbackID = playbackID + self.attemptID = attemptID + } + } + + @TaskLocal public static var context: Context? + private static let requestContextKey = "AetherEngine.SourceNetworkDiagnostics.context" + + static func request(_ request: URLRequest, context: Context?) -> URLRequest { + let copy = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest + if let context { + URLProtocol.setProperty([context.playbackID.uuidString, context.attemptID.uuidString], + forKey: requestContextKey, in: copy) + } else { + URLProtocol.removeProperty(forKey: requestContextKey, in: copy) + } + return copy as URLRequest + } + + static func context(for request: URLRequest) -> Context? { + guard let values = URLProtocol.property(forKey: requestContextKey, in: request) as? [String], + values.count == 2, let playbackID = UUID(uuidString: values[0]), + let attemptID = UUID(uuidString: values[1]) else { return nil } + return Context(playbackID: playbackID, attemptID: attemptID) + } + + /// Stable, extensible diagnostic role values; raw values are API. + public struct Role: RawRepresentable, Sendable, Equatable, Hashable, Codable { + public let rawValue: String + public init(rawValue: String) { self.rawValue = rawValue } + public static let persistentRead = Role(rawValue: "PersistentRead") + public static let chunkFetch = Role(rawValue: "ChunkFetch") + public static let streaming = Role(rawValue: "Streaming") + public static let probe = Role(rawValue: "Probe") + public static let tailPrefetch = Role(rawValue: "TailPrefetch") + public static let hlsPlaylist = Role(rawValue: "HLSPlaylist") + public static let hlsSegment = Role(rawValue: "HLSSegment") + public static let hlsKey = Role(rawValue: "HLSKey") + public static let boundedBody = Role(rawValue: "BoundedBody") + } + + /// Stable, extensible diagnostic phase values; raw values are API. + public struct Phase: RawRepresentable, Sendable, Equatable, Hashable, Codable { + public let rawValue: String + public init(rawValue: String) { self.rawValue = rawValue } + public static let response = Phase(rawValue: "response") + public static let redirect = Phase(rawValue: "redirect") + public static let completed = Phase(rawValue: "completed") + public static let metrics = Phase(rawValue: "metrics") + public static let bodyLimitExceeded = Phase(rawValue: "body_limit_exceeded") + public static let abandoned = Phase(rawValue: "abandoned") + } + + /// Immutable snapshot of one request event. + public struct Event: Sendable { + public let requestID: UUID + public let role: Role + public let phase: Phase + public let originalURL: URL? + public let url: URL? + public let status: Int? + public let urlErrorCode: Int? + public let bytes: Int64 + public let elapsedMilliseconds: Int + public let dnsMilliseconds: Int? + public let connectMilliseconds: Int? + public let tlsMilliseconds: Int? + public let responseMilliseconds: Int? + public let reusedConnection: Bool? + public let context: Context? + + init(requestID: UUID, role: Role, phase: Phase, originalURL: URL?, url: URL?, + status: Int?, urlErrorCode: Int?, bytes: Int64, elapsedMilliseconds: Int, + dnsMilliseconds: Int? = nil, connectMilliseconds: Int? = nil, + tlsMilliseconds: Int? = nil, responseMilliseconds: Int? = nil, + reusedConnection: Bool? = nil, context: Context? = nil) { + self.requestID = requestID + self.role = role + self.phase = phase + self.originalURL = originalURL + self.url = url + self.status = status + self.urlErrorCode = urlErrorCode + self.bytes = bytes + self.elapsedMilliseconds = elapsedMilliseconds + self.dnsMilliseconds = dnsMilliseconds + self.connectMilliseconds = connectMilliseconds + self.tlsMilliseconds = tlsMilliseconds + self.responseMilliseconds = responseMilliseconds + self.reusedConnection = reusedConnection + self.context = context + } + } + + public static var handler: (@Sendable (Event) -> Void)? { + get { lock.lock(); defer { lock.unlock() }; return observer } + set { lock.lock(); observer = newValue; lock.unlock() } + } + private static let lock = NSLock() + nonisolated(unsafe) private static var observer: (@Sendable (Event) -> Void)? + + static func urlErrorCode(_ error: Error?) -> Int? { + var next = error as NSError? + for _ in 0..<8 { + guard let current = next else { return nil } + if current.domain == NSURLErrorDomain { return current.code } + next = current.userInfo[NSUnderlyingErrorKey] as? NSError + } + return nil + } + + final class Trace: Sendable { + private let id = UUID() + private let role: Role + private let start = ProcessInfo.processInfo.systemUptime + private let context: Context? + init(role: Role, context: Context? = SourceNetworkDiagnostics.context) { + self.role = role + self.context = context + } + + func record(_ phase: Phase, originalURL: URL, url: URL, status: Int? = nil, + bytes: Int64 = 0, error: Error? = nil) { + handler?(Event(requestID: id, role: role, phase: phase, + originalURL: originalURL, url: url, status: status, + urlErrorCode: urlErrorCode(error), bytes: bytes, + elapsedMilliseconds: Int(max(0, ProcessInfo.processInfo.systemUptime - start) * 1_000), + context: context)) + } + + private func event(_ phase: Phase, task: URLSessionTask, url: URL? = nil, + status: Int? = nil, error: Error? = nil, + transaction: URLSessionTaskTransactionMetrics? = nil) -> Event { + func milliseconds(_ start: Date?, _ end: Date?) -> Int? { + guard let start, let end else { return nil } + return Int(max(0, end.timeIntervalSince(start)) * 1_000) + } + return Event(requestID: id, role: role, phase: phase, + originalURL: task.originalRequest?.url, url: url ?? task.currentRequest?.url, + status: status ?? (task.response as? HTTPURLResponse)?.statusCode, + urlErrorCode: urlErrorCode(error), bytes: task.countOfBytesReceived, + elapsedMilliseconds: Int(max(0, ProcessInfo.processInfo.systemUptime - start) * 1_000), + dnsMilliseconds: milliseconds(transaction?.domainLookupStartDate, transaction?.domainLookupEndDate), + connectMilliseconds: milliseconds(transaction?.connectStartDate, transaction?.connectEndDate), + tlsMilliseconds: milliseconds(transaction?.secureConnectionStartDate, transaction?.secureConnectionEndDate), + responseMilliseconds: milliseconds(transaction?.requestStartDate, transaction?.responseStartDate), + reusedConnection: transaction?.isReusedConnection, + context: context) + } + func response(task: URLSessionTask, response: URLResponse) { + handler?(event(.response, task: task, url: response.url, status: (response as? HTTPURLResponse)?.statusCode)) + } + func redirect(task: URLSessionTask, response: HTTPURLResponse, request: URLRequest) { + handler?(event(.redirect, task: task, url: request.url, status: response.statusCode)) + } + func completed(task: URLSessionTask, error: Error?, phase: Phase = .completed) { + handler?(event(phase, task: task, error: error)) + } + func metrics(task: URLSessionTask, metrics: URLSessionTaskMetrics) { + guard let receive = handler else { return } + for transaction in metrics.transactionMetrics { + receive(event(.metrics, task: task, url: transaction.request.url, + status: (transaction.response as? HTTPURLResponse)?.statusCode, + transaction: transaction)) + } + } + } +} diff --git a/Sources/AetherEngine/IO/HLSIngest/BoundedPlaylistFetch.swift b/Sources/AetherEngine/IO/HLSIngest/BoundedPlaylistFetch.swift index cf9368ca..09ecc108 100644 --- a/Sources/AetherEngine/IO/HLSIngest/BoundedPlaylistFetch.swift +++ b/Sources/AetherEngine/IO/HLSIngest/BoundedPlaylistFetch.swift @@ -14,7 +14,7 @@ enum BoundedPlaylistFetch { -> (Data, URLResponse) { do { - return try await BoundedFetch.data(for: request, session: session, limit: limit) + return try await BoundedFetch.data(for: request, session: session, limit: limit, role: .hlsPlaylist) } catch let error as BoundedFetch.Exceeded { throw HLSIngestError.playlistInvalid(reason: "playlist exceeds \(error.limit) bytes") } @@ -54,10 +54,10 @@ enum BoundedFetch { /// The body and response of `request`. A non-2xx answer comes back with an empty body, since every /// caller decides on the status alone. Throws `Exceeded` for a body over `limit`. - static func data(for request: URLRequest, session: URLSession, limit: Int) async throws + static func data(for request: URLRequest, session: URLSession, limit: Int, role: SourceNetworkDiagnostics.Role = .boundedBody) async throws -> (Data, URLResponse) { - let delegate = BoundedBodyDelegate(limit: limit) + let delegate = BoundedBodyDelegate(limit: limit, role: role, context: SourceNetworkDiagnostics.context(for: request)) defer { withExtendedLifetime(delegate) {} } let task = session.dataTask(with: request) task.delegate = delegate @@ -97,6 +97,7 @@ private final class BoundedBodyDelegate: EngineTLS.RedirectScrubbingDelegate, UR } private let limit: Int + private let trace: SourceNetworkDiagnostics.Trace private var buffer = Data() private var response: URLResponse? private var verdict: Verdict? @@ -107,8 +108,9 @@ private final class BoundedBodyDelegate: EngineTLS.RedirectScrubbingDelegate, UR private var onOutcome: ((Outcome) -> Void)? private var landed: Outcome? - init(limit: Int) { + init(limit: Int, role: SourceNetworkDiagnostics.Role, context: SourceNetworkDiagnostics.Context?) { self.limit = limit + trace = .init(role: role, context: context) super.init() } @@ -145,6 +147,7 @@ private final class BoundedBodyDelegate: EngineTLS.RedirectScrubbingDelegate, UR func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { self.response = response + trace.response(task: dataTask, response: response) let status = (response as? HTTPURLResponse)?.statusCode ?? -1 guard (200..<300).contains(status) else { verdict = .refused @@ -173,6 +176,9 @@ private final class BoundedBodyDelegate: EngineTLS.RedirectScrubbingDelegate, UR } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + // Refusal/body limits deliberately cancel the task; do not report that as user cancellation. + trace.completed(task: task, error: verdict == nil ? error : nil, + phase: verdict == .exceeded ? .bodyLimitExceeded : .completed) switch verdict { case .refused?: if let response { deliver(.refused(response)) } else { deliver(.failed(URLError(.badServerResponse))) } @@ -188,4 +194,16 @@ private final class BoundedBodyDelegate: EngineTLS.RedirectScrubbingDelegate, UR } } } + + override func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + trace.redirect(task: task, response: response, request: request) + super.urlSession(session, task: task, willPerformHTTPRedirection: response, + newRequest: request, completionHandler: completionHandler) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + trace.metrics(task: task, metrics: metrics) + } } diff --git a/Sources/AetherEngine/IO/HLSIngest/HLSCarriageProbe.swift b/Sources/AetherEngine/IO/HLSIngest/HLSCarriageProbe.swift index 8550a127..e6674583 100644 --- a/Sources/AetherEngine/IO/HLSIngest/HLSCarriageProbe.swift +++ b/Sources/AetherEngine/IO/HLSIngest/HLSCarriageProbe.swift @@ -129,31 +129,46 @@ enum HLSCarriageProbe { static func classifySegmentHead( url: URL, httpHeaders: [String: String], - session: URLSession + session: URLSession, + diagnosticContext: SourceNetworkDiagnostics.Context? = SourceNetworkDiagnostics.context ) async throws -> MPEGTransportStreamCodecProbe.Verdict { - var request = makeRequest(url, httpHeaders: httpHeaders) + var request = SourceNetworkDiagnostics.request(makeRequest(url, httpHeaders: httpHeaders), context: diagnosticContext) request.setValue("bytes=0-\(maximumSegmentHeadBytes - 1)", forHTTPHeaderField: "Range") - let (bytes, response) = try await session.bytes(for: request) - defer { bytes.task.cancel() } - let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - guard status == 200 || status == 206 else { - throw HLSIngestError.playlistUnreachable(status: status) + let trace = SourceNetworkDiagnostics.Trace(role: .hlsSegment, context: SourceNetworkDiagnostics.context(for: request)) + var task: URLSessionTask? + var terminalError: Error? + defer { + if let task { trace.completed(task: task, error: terminalError) } + else { trace.record(.completed, originalURL: url, url: url, error: terminalError) } } - var data = Data() - data.reserveCapacity(segmentHeadChunkBytes) - var nextVerdictAt = segmentHeadChunkBytes - for try await byte in bytes { - data.append(byte) - guard data.count >= nextVerdictAt else { continue } - let verdict = MPEGTransportStreamCodecProbe.classify(data) - if verdict != .inconclusive { return verdict } - guard data.count < maximumSegmentHeadBytes else { break } - nextVerdictAt = min(maximumSegmentHeadBytes, data.count + segmentHeadChunkBytes) - } - guard !data.isEmpty else { - throw HLSIngestError.unsupportedSegmentFormat + do { + let (bytes, response) = try await session.bytes(for: request) + task = bytes.task + trace.response(task: bytes.task, response: response) + defer { bytes.task.cancel() } + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + guard status == 200 || status == 206 else { + throw HLSIngestError.playlistUnreachable(status: status) + } + var data = Data() + data.reserveCapacity(segmentHeadChunkBytes) + var nextVerdictAt = segmentHeadChunkBytes + for try await byte in bytes { + data.append(byte) + guard data.count >= nextVerdictAt else { continue } + let verdict = MPEGTransportStreamCodecProbe.classify(data) + if verdict != .inconclusive { return verdict } + guard data.count < maximumSegmentHeadBytes else { break } + nextVerdictAt = min(maximumSegmentHeadBytes, data.count + segmentHeadChunkBytes) + } + guard !data.isEmpty else { + throw HLSIngestError.unsupportedSegmentFormat + } + return MPEGTransportStreamCodecProbe.classify(data) + } catch { + terminalError = error + throw error } - return MPEGTransportStreamCodecProbe.classify(data) } /// Resolves the playlist down to the segment whose PMT answers the question, or settles the verdict @@ -229,6 +244,6 @@ enum HLSCarriageProbe { for (field, value) in httpHeaders { request.setValue(value, forHTTPHeaderField: field) } - return request + return SourceNetworkDiagnostics.request(request, context: SourceNetworkDiagnostics.context) } } diff --git a/Sources/AetherEngine/IO/HLSIngest/HLSIngestError.swift b/Sources/AetherEngine/IO/HLSIngest/HLSIngestError.swift index 044fa1fa..d24c58c8 100644 --- a/Sources/AetherEngine/IO/HLSIngest/HLSIngestError.swift +++ b/Sources/AetherEngine/IO/HLSIngest/HLSIngestError.swift @@ -1,7 +1,56 @@ import Foundation -/// Terminal errors from the live HLS ingest. Every case causes a host fallback to the Jellyfin-mediated path. Phase-1 limits (encryption, fMP4) fall back deliberately rather than half-work. -public enum HLSIngestError: Error, Equatable, CustomStringConvertible, LocalizedError { +/// Finite diagnostics only; payload strings and playlist contents never become telemetry. +public enum HLSIngestDiagnosticReason: String, Sendable, CaseIterable { + case missingEXTM3U = "missing_extm3u" + case missingTargetDuration = "missing_target_duration" + case noSegments = "no_segments" + case noVariants = "no_variants" + case noUsableVariant = "no_usable_variant" + case expectedMediaPlaylist = "expected_media_playlist" + case missingEndList = "missing_endlist" + case invalidSegmentURI = "invalid_segment_uri" + case invalidMediaSequence = "invalid_media_sequence" + case invalidTargetDuration = "invalid_target_duration" + case invalidSegmentDuration = "invalid_segment_duration" + case durationLimitExceeded = "duration_limit_exceeded" + case playlistSizeLimitExceeded = "playlist_size_limit_exceeded" + case playlistLineLimitExceeded = "playlist_line_limit_exceeded" + case bodySizeLimitExceeded = "body_size_limit_exceeded" + case invalidPlaylistEncoding = "invalid_playlist_encoding" + case invalidResponse = "invalid_response" + case httpResponseRefused = "http_response_refused" + case playlistInvalid = "playlist_invalid_other" + case encryptionUnsupported = "encryption_unsupported" + case invalidKeyURI = "invalid_key_uri" + case invalidKeyLength = "invalid_key_length" + case keySizeLimitExceeded = "key_size_limit_exceeded" + case decryptionFailed = "decryption_failed" + case segmentFormatUnsupported = "segment_format_unsupported" + case ingestStalled = "ingest_stalled" + case demuxedAudioUnsupported = "demuxed_audio_unsupported" + + static let userInfoKey = "AetherHLSDiagnosticReason" + + /// Inspect only this SDK's domain/key and accept only known values, even through NSError wrappers. + static func preserved(in error: Error?) -> Self? { + var next = error as NSError? + for _ in 0..<6 { + guard let current = next else { break } + if current.domain == HLSIngestError.errorDomain, + let code = current.userInfo[userInfoKey] as? String, + let reason = Self(rawValue: code) { return reason } + next = current.userInfo[NSUnderlyingErrorKey] as? NSError + } + return nil + } +} + +/// Terminal errors from the HLS ingest. Hosts choose recovery or fallback from the failure kind; +/// a malformed playlist or a transport failure does not establish a format incompatibility. +public enum HLSIngestError: Error, Equatable, CustomStringConvertible, LocalizedError, CustomNSError { + /// An HLS request was refused: the status may belong to a playlist, segment or AES key. + /// The historical case name and NSError code are retained for source compatibility. case playlistUnreachable(status: Int) case playlistInvalid(reason: String) /// SAMPLE-AES / SAMPLE-AES-CTR, or AES-128 tag with no URI. Plain AES-128 clear-key is handled by `HLSSegmentDecryptor`. @@ -30,4 +79,63 @@ public enum HLSIngestError: Error, Equatable, CustomStringConvertible, Localized /// "The operation couldn't be completed. (HLSIngestError error 0.)" and the HTTP status the /// reader already resolved is lost. public var errorDescription: String? { description } + + public static var errorDomain: String { "AetherEngine.HLSIngestError" } + // Preserve Swift's implicit NSError codes from before CustomNSError conformance. + public var errorCode: Int { + switch self { + case .playlistUnreachable: 0 + case .playlistInvalid: 1 + case .segmentDecryptFailed: 2 + case .encryptedNotSupported: 3 + case .unsupportedSegmentFormat: 4 + case .ingestStalled: 5 + case .demuxedAudioNotSupported: 6 + } + } + public var errorUserInfo: [String: Any] { + [NSLocalizedDescriptionKey: description, + HLSIngestDiagnosticReason.userInfoKey: diagnosticReason.rawValue] + } + + /// Keep the existing error codes and messages intact: this field never selects playback policy. + public var diagnosticReason: HLSIngestDiagnosticReason { + switch self { + case .playlistUnreachable(let status): + return (100...599).contains(status) ? .httpResponseRefused : .invalidResponse + case .playlistInvalid(let reason): + switch reason { + case "missing #EXTM3U": return .missingEXTM3U + case "missing TARGETDURATION": return .missingTargetDuration + case "no segments": return .noSegments + case "master playlist without variants": return .noVariants + case "no usable variant": return .noUsableVariant + case "selected variant is not a media playlist", "expected media playlist on refresh": + return .expectedMediaPlaylist + case "finite VOD requires EXT-X-ENDLIST": return .missingEndList + case "unresolvable segment URI": return .invalidSegmentURI + case "media sequence out of range": return .invalidMediaSequence + case "TARGETDURATION out of range": return .invalidTargetDuration + case "EXTINF out of range", "segment duration must be positive": return .invalidSegmentDuration + case "non-UTF8 playlist", "playlist is not bounded UTF-8", "rendition payload is not UTF-8": return .invalidPlaylistEncoding + default: + if reason.hasPrefix("playlist line exceeds ") { return .playlistLineLimitExceeded } + if reason.hasPrefix("playlist exceeds ") { return .playlistSizeLimitExceeded } + if reason == "segment body over its limit" || reason.hasPrefix("segment exceeds ") || reason.hasPrefix("body exceeds ") { + return .bodySizeLimitExceeded + } + if reason.hasPrefix("program duration exceeds ") { return .durationLimitExceeded } + return .playlistInvalid + } + case .encryptedNotSupported: return .encryptionUnsupported + case .segmentDecryptFailed(let reason): + if reason == "unresolvable key URI" { return .invalidKeyURI } + if reason.hasPrefix("key length ") { return .invalidKeyLength } + if reason.hasPrefix("key exceeds ") { return .keySizeLimitExceeded } + return .decryptionFailed + case .unsupportedSegmentFormat: return .segmentFormatUnsupported + case .ingestStalled: return .ingestStalled + case .demuxedAudioNotSupported: return .demuxedAudioUnsupported + } + } } diff --git a/Sources/AetherEngine/IO/HLSIngest/HLSLiveIngestReader.swift b/Sources/AetherEngine/IO/HLSIngest/HLSLiveIngestReader.swift index ed14ee01..7371d660 100644 --- a/Sources/AetherEngine/IO/HLSIngest/HLSLiveIngestReader.swift +++ b/Sources/AetherEngine/IO/HLSIngest/HLSLiveIngestReader.swift @@ -32,7 +32,7 @@ public final class HLSLiveIngestReader: IOReader, LiveIngestSourceInfo, @uncheck private var started = false private var closed = false // All _-prefixed vars are protected by startLock. - private var _terminalError: HLSIngestError? + private var _terminalFailure: Error? /// Written before any segment byte reaches the FIFO; first write wins. private var _upstreamTargetDuration: Double? /// Tracks observed segment-arrival cadence for LL-HLS shaping (AetherEngine#167). Updated whenever new @@ -90,7 +90,12 @@ public final class HLSLiveIngestReader: IOReader, LiveIngestSourceInfo, @uncheck private var sniffedFirstSegment = false public var terminalError: HLSIngestError? { - startLock.withLock { _terminalError } + // Keep the public terminal-state contract; the engine consumes the original cause below. + terminalFailure.map { ($0 as? HLSIngestError) ?? .playlistUnreachable(status: -1) } + } + + var terminalFailure: Error? { + startLock.withLock { _terminalFailure } } public var upstreamTargetDuration: Double? { @@ -218,8 +223,10 @@ public final class HLSLiveIngestReader: IOReader, LiveIngestSourceInfo, @uncheck /// reader, so header-enforcing IPTV origins (Referer / User-Agent / Authorization, #119) accept the /// ingest the same way they accept the AVPlayer bypass (AetherEngine#168). Credential headers go /// only to `playlistURL`'s origin with no TLS downgrade (audit NET-7). - public convenience init(playlistURL: URL, httpHeaders: [String: String]) { - self.init(playlistURL: playlistURL, httpHeaders: httpHeaders, role: .mainVideo) + public convenience init(playlistURL: URL, httpHeaders: [String: String], + diagnosticContext: SourceNetworkDiagnostics.Context? = SourceNetworkDiagnostics.context) { + self.init(playlistURL: playlistURL, httpHeaders: httpHeaders, role: .mainVideo, + diagnosticContext: diagnosticContext) } /// #199: fresh reader over the same playlist URL and headers, for the in-engine live reopen of an @@ -228,13 +235,18 @@ public final class HLSLiveIngestReader: IOReader, LiveIngestSourceInfo, @uncheck /// audio reader's lifetime is owned by its parent and never reopens independently. func makeFreshMainReader() -> HLSLiveIngestReader? { guard role == .mainVideo else { return nil } - let fresh = HLSLiveIngestReader(playlistURL: playlistURL, httpHeaders: httpHeaders, role: .mainVideo) + let fresh = HLSLiveIngestReader(playlistURL: playlistURL, httpHeaders: httpHeaders, role: .mainVideo, + diagnosticContext: diagnosticContext) fresh.setJoinCushion(joinCushionSeconds) return fresh } + private let diagnosticContext: SourceNetworkDiagnostics.Context? + init(playlistURL: URL, httpHeaders: [String: String] = [:], role: Role, - credentialOrigin: URL? = nil) { + credentialOrigin: URL? = nil, + diagnosticContext: SourceNetworkDiagnostics.Context? = SourceNetworkDiagnostics.context) { + self.diagnosticContext = diagnosticContext self.playlistURL = playlistURL self.httpHeaders = httpHeaders self.credentialOrigin = credentialOrigin ?? playlistURL @@ -397,14 +409,14 @@ public final class HLSLiveIngestReader: IOReader, LiveIngestSourceInfo, @uncheck } catch is CancellationError { // teardown } catch let error as HLSIngestError { - startLock.withLock { _terminalError = error } + startLock.withLock { _terminalFailure = error } EngineLog.emit("[HLSIngest] terminal: \(error)", category: .engine) fifo.cancel() } catch { if Task.isCancelled || (error as? URLError)?.code == .cancelled { return // teardown rides through as cancellation, not a terminal error } - startLock.withLock { _terminalError = .playlistUnreachable(status: -1) } + startLock.withLock { _terminalFailure = error } EngineLog.emit("[HLSIngest] terminal (transport): \(error.localizedDescription)", category: .engine) fifo.cancel() } @@ -575,7 +587,7 @@ public final class HLSLiveIngestReader: IOReader, LiveIngestSourceInfo, @uncheck ) installCompanion(HLSLiveIngestReader( playlistURL: audioURL, httpHeaders: httpHeaders, role: .companionAudio, - credentialOrigin: credentialOrigin)) + credentialOrigin: credentialOrigin, diagnosticContext: diagnosticContext)) } // AE#359: the variant's SUBTITLES group, resolved to absolute playlist URLs and published as // metadata. Nothing is fetched here; the host decides whether a subtitle track is ever wanted. @@ -789,7 +801,7 @@ public final class HLSLiveIngestReader: IOReader, LiveIngestSourceInfo, @uncheck httpHeaders, grantedFor: credentialOrigin, sentTo: url) { request.setValue(value, forHTTPHeaderField: field) } - return request + return SourceNetworkDiagnostics.request(request, context: diagnosticContext) } /// Fetch + parse a playlist. Returns parsed playlist and final URL after redirects (relative segment URIs resolve against it). @@ -809,7 +821,7 @@ public final class HLSLiveIngestReader: IOReader, LiveIngestSourceInfo, @uncheck } private func fetchSegment(_ url: URL, duration: Double) async throws -> Data { - var lastStatus = -1 + var lastFailure: Error = HLSIngestError.playlistUnreachable(status: -1) let limit = BoundedFetch.segmentLimit(forDuration: duration) let startedAt = Date() var budget = Self.unreachableRetryBudget @@ -822,13 +834,14 @@ public final class HLSLiveIngestReader: IOReader, LiveIngestSourceInfo, @uncheck let (data, response): (Data, URLResponse) do { (data, response) = try await budgeted(url, label: "ingest-segment") { - try await BoundedFetch.data(for: self.makeRequest(url), session: self.session, limit: limit) + try await BoundedFetch.data(for: self.makeRequest(url), session: self.session, limit: limit, role: .hlsSegment) } } catch is BoundedFetch.Exceeded { // Audit NET-112: an endless body is the origin's answer, not a blip to retry. throw HLSIngestError.playlistInvalid(reason: "segment exceeds \(limit) bytes") } - lastStatus = (response as? HTTPURLResponse)?.statusCode ?? -1 + let lastStatus = (response as? HTTPURLResponse)?.statusCode ?? -1 + lastFailure = HLSIngestError.playlistUnreachable(status: lastStatus) status = lastStatus if (200..<300).contains(lastStatus) { return data } if lastStatus == 404 { return Data() } // slid out of provider window; tracker advances regardless @@ -838,14 +851,17 @@ public final class HLSLiveIngestReader: IOReader, LiveIngestSourceInfo, @uncheck retryAfter = (response as? HTTPURLResponse)? .value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init) } catch let error as HLSIngestError { throw error } - catch { /* transport blip: retry */ } + catch { + if Task.isCancelled || (error as? URLError)?.code == .cancelled { throw error } + lastFailure = error + } attempt += 1 // Two quick retries for a blip, as before; then the steady pace of a refused refresh, // within the same budget the refresh would get for this failure. let delay = attempt <= 2 ? 0.5 * Double(attempt) : Self.retryWait(retryAfter: retryAfter) budget = max(budget, Self.retryBudget(forStatus: status)) guard Date().addingTimeInterval(delay) < startedAt.addingTimeInterval(budget) else { - throw HLSIngestError.playlistUnreachable(status: lastStatus) + throw lastFailure } try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } @@ -873,14 +889,14 @@ public final class HLSLiveIngestReader: IOReader, LiveIngestSourceInfo, @uncheck do { (data, response) = try await budgeted(url, label: "ingest-key") { try await BoundedFetch.data( - for: self.makeRequest(url), session: self.session, limit: BoundedFetch.keyLimit) + for: self.makeRequest(url), session: self.session, limit: BoundedFetch.keyLimit, role: .hlsKey) } } catch is BoundedFetch.Exceeded { throw HLSIngestError.segmentDecryptFailed(reason: "key exceeds \(BoundedFetch.keyLimit) bytes") } let status = (response as? HTTPURLResponse)?.statusCode ?? -1 guard (200..<300).contains(status) else { - throw HLSIngestError.segmentDecryptFailed(reason: "key fetch HTTP \(status)") + throw HLSIngestError.playlistUnreachable(status: status) } guard data.count == kCCKeySizeAES128 else { throw HLSIngestError.segmentDecryptFailed(reason: "key length \(data.count) != 16") diff --git a/Sources/AetherEngine/IO/HLSIngest/HLSVODIngestReader.swift b/Sources/AetherEngine/IO/HLSIngest/HLSVODIngestReader.swift index 1e9d2edf..b1f70f56 100644 --- a/Sources/AetherEngine/IO/HLSIngest/HLSVODIngestReader.swift +++ b/Sources/AetherEngine/IO/HLSIngest/HLSVODIngestReader.swift @@ -25,6 +25,7 @@ final class HLSVODIngestReader: TimeSeekableIOReader, @unchecked Sendable { /// narrows once the first segment's real size is known (AE#255: bound by bytes, not by count). private static let maximumInFlightBytes = 24 * 1024 * 1024 + private let diagnosticContext: SourceNetworkDiagnostics.Context? private let playlistURL: URL private let httpHeaders: [String: String] private let session: URLSession @@ -65,8 +66,10 @@ final class HLSVODIngestReader: TimeSeekableIOReader, @unchecked Sendable { playlistURL: URL, httpHeaders: [String: String], session: URLSession? = nil, - persistentHTTP1: Bool = false + persistentHTTP1: Bool = false, + diagnosticContext: SourceNetworkDiagnostics.Context? = SourceNetworkDiagnostics.context ) { + self.diagnosticContext = diagnosticContext self.playlistURL = playlistURL self.httpHeaders = httpHeaders transport = persistentHTTP1 ? PersistentHTTP1Transport(label: "vod") : nil @@ -95,13 +98,15 @@ final class HLSVODIngestReader: TimeSeekableIOReader, @unchecked Sendable { playlistURL: URL, httpHeaders: [String: String], session: URLSession? = nil, - persistentHTTP1: Bool = false + persistentHTTP1: Bool = false, + diagnosticContext: SourceNetworkDiagnostics.Context? = SourceNetworkDiagnostics.context ) async throws -> HLSVODIngestReader? { let reader = HLSVODIngestReader( playlistURL: playlistURL, httpHeaders: httpHeaders, session: session, - persistentHTTP1: persistentHTTP1 + persistentHTTP1: persistentHTTP1, + diagnosticContext: diagnosticContext ) var accepted = false defer { @@ -568,7 +573,8 @@ final class HLSVODIngestReader: TimeSeekableIOReader, @unchecked Sendable { guard let transport else { throw CancellationError() } let body: PersistentHTTP1Transport.Body do { - body = try await transport.open(url, headers: scopedHeaders(url), limit: limit) + body = try await transport.open(url, headers: scopedHeaders(url), limit: limit, + diagnosticContext: diagnosticContext, role: .hlsSegment) } catch is BoundedFetch.Exceeded { throw HLSIngestError.playlistInvalid(reason: "body exceeds \(limit) bytes") } @@ -682,7 +688,7 @@ final class HLSVODIngestReader: TimeSeekableIOReader, @unchecked Sendable { for (field, value) in scopedHeaders(url) { request.setValue(value, forHTTPHeaderField: field) } - return request + return SourceNetworkDiagnostics.request(request, context: diagnosticContext) } private func scopedHeaders(_ url: URL) -> [String: String] { @@ -694,7 +700,8 @@ final class HLSVODIngestReader: TimeSeekableIOReader, @unchecked Sendable { let response: URLResponse if let transport { do { - let answer = try await transport.get(url, headers: scopedHeaders(url), limit: Self.maximumPlaylistBytes) + let answer = try await transport.get(url, headers: scopedHeaders(url), limit: Self.maximumPlaylistBytes, + diagnosticContext: diagnosticContext, role: .hlsPlaylist) (data, response) = (answer.body, answer.response) } catch let error as BoundedFetch.Exceeded { throw HLSIngestError.playlistInvalid(reason: "playlist exceeds \(error.limit) bytes") @@ -714,15 +721,16 @@ final class HLSVODIngestReader: TimeSeekableIOReader, @unchecked Sendable { } /// A segment or a key body, cut off at `limit` while it arrives (audit NET-112). - private func fetch(_ url: URL, limit: Int) async throws -> Data { + private func fetch(_ url: URL, limit: Int, role: SourceNetworkDiagnostics.Role = .hlsSegment) async throws -> Data { let data: Data let response: URLResponse do { if let transport { - let answer = try await transport.get(url, headers: scopedHeaders(url), limit: limit) + let answer = try await transport.get(url, headers: scopedHeaders(url), limit: limit, + diagnosticContext: diagnosticContext, role: role) (data, response) = (answer.body, answer.response) } else { - (data, response) = try await BoundedFetch.data(for: makeRequest(url), session: session, limit: limit) + (data, response) = try await BoundedFetch.data(for: makeRequest(url), session: session, limit: limit, role: role) } } catch is BoundedFetch.Exceeded { throw HLSIngestError.playlistInvalid(reason: "body exceeds \(limit) bytes") @@ -770,7 +778,8 @@ final class HLSVODIngestReader: TimeSeekableIOReader, @unchecked Sendable { return try await HLSCarriageProbe.classifySegmentHead( url: url, httpHeaders: RedirectHeaderPolicy.scoped(httpHeaders, grantedFor: playlistURL, sentTo: url), - session: session + session: session, + diagnosticContext: diagnosticContext ) } @@ -786,7 +795,7 @@ final class HLSVODIngestReader: TimeSeekableIOReader, @unchecked Sendable { if let cached = keyCacheLock.withLock({ keyCache[keyURL.absoluteString] }) { key = cached } else { - let fetched = try await fetch(keyURL, limit: BoundedFetch.keyLimit) + let fetched = try await fetch(keyURL, limit: BoundedFetch.keyLimit, role: .hlsKey) guard fetched.count == kCCKeySizeAES128 else { throw HLSIngestError.segmentDecryptFailed(reason: "key length is not 16 bytes") } diff --git a/Sources/AetherEngine/IO/HLSIngest/PersistentHTTP1Transport.swift b/Sources/AetherEngine/IO/HLSIngest/PersistentHTTP1Transport.swift index 65b58f83..bc02d028 100644 --- a/Sources/AetherEngine/IO/HLSIngest/PersistentHTTP1Transport.swift +++ b/Sources/AetherEngine/IO/HLSIngest/PersistentHTTP1Transport.swift @@ -53,8 +53,11 @@ final class PersistentHTTP1Transport: @unchecked Sendable { /// GET `url` with `headers`, redirects followed under the #126 header policy, the whole body /// collected. A non-2xx answer comes back with its status and an empty body. Throws /// `BoundedFetch.Exceeded` for a 2xx body over `limit`. - func get(_ url: URL, headers: [String: String], limit: Int) async throws -> Response { - let body = try await open(url, headers: headers, limit: limit) + func get(_ url: URL, headers: [String: String], limit: Int, + diagnosticContext: SourceNetworkDiagnostics.Context? = nil, + role: SourceNetworkDiagnostics.Role = .boundedBody) async throws -> Response { + let body = try await open(url, headers: headers, limit: limit, + diagnosticContext: diagnosticContext, role: role) var data = Data() while let chunk = try await body.next() { data.append(chunk) @@ -65,13 +68,28 @@ final class PersistentHTTP1Transport: @unchecked Sendable { /// GET `url` and return as soon as the response head is in. A 2xx body is read through the /// returned `Body`, which holds the origin until it ends or is abandoned; every other answer has /// been read to its end already and its `Body` is empty. - func open(_ url: URL, headers: [String: String], limit: Int) async throws -> Body { + func open(_ url: URL, headers: [String: String], limit: Int, + diagnosticContext: SourceNetworkDiagnostics.Context? = nil, + role: SourceNetworkDiagnostics.Role = .boundedBody) async throws -> Body { + let trace = SourceNetworkDiagnostics.Trace(role: role, context: diagnosticContext) + do { + return try await open(url, headers: headers, limit: limit, trace: trace) + } catch { + trace.record(error is BoundedFetch.Exceeded ? .bodyLimitExceeded : .completed, + originalURL: url, url: url, error: error) + throw error + } + } + + private func open(_ url: URL, headers: [String: String], limit: Int, + trace: SourceNetworkDiagnostics.Trace) async throws -> Body { var target = url var hops = 0 while true { let hopHeaders = RedirectHeaderPolicy.headersToReplay( extraHeaders: headers, originalURL: url, redirectURL: target) let (connection, head, reused) = try await request(target, headers: hopHeaders) + trace.record(.response, originalURL: url, url: target, status: head.status) let success = (200..<300).contains(head.status) let location = HeldSourceConnection.redirectLocation(head) guard let response = HeldSourceConnection.makeResponse(head, url: target) else { @@ -94,10 +112,12 @@ final class PersistentHTTP1Transport: @unchecked Sendable { guard let next = URL(string: location, relativeTo: target)?.absoluteURL else { throw PersistentHTTP1Connection.ConnectionError.malformed("unresolvable Location: \(location)") } + trace.record(.redirect, originalURL: url, url: next, status: head.status) hops += 1 target = next continue } + trace.record(.completed, originalURL: url, url: target, status: head.status) return Body(finished: response) } let framing = PersistentHTTP1Connection.framing(for: head) @@ -107,7 +127,7 @@ final class PersistentHTTP1Transport: @unchecked Sendable { } return Body( transport: self, connection: connection, head: head, response: response, - framing: framing, limit: limit, reused: reused) + framing: framing, limit: limit, reused: reused, trace: trace, originalURL: url) } } @@ -260,6 +280,8 @@ final class PersistentHTTP1Transport: @unchecked Sendable { private let head: HeldSourceConnection.ResponseHead? private let limit: Int private let reused: Bool + private let trace: SourceNetworkDiagnostics.Trace? + private let originalURL: URL? private let started = Date() private let lock = NSLock() private var framing: PersistentHTTP1Connection.Framing @@ -277,6 +299,8 @@ final class PersistentHTTP1Transport: @unchecked Sendable { head = nil limit = 0 reused = false + trace = nil + originalURL = nil framing = .empty released = true } @@ -288,7 +312,9 @@ final class PersistentHTTP1Transport: @unchecked Sendable { response: HTTPURLResponse, framing: PersistentHTTP1Connection.Framing, limit: Int, - reused: Bool + reused: Bool, + trace: SourceNetworkDiagnostics.Trace, + originalURL: URL ) { self.transport = transport self.connection = connection @@ -297,12 +323,14 @@ final class PersistentHTTP1Transport: @unchecked Sendable { self.framing = framing self.limit = limit self.reused = reused + self.trace = trace + self.originalURL = originalURL if case .identity(let length) = framing { expectedLength = length } else { expectedLength = nil } } deinit { // A body dropped unread must not hold its origin. - finish(refusal: "body dropped mid-read") + finish(refusal: "body dropped mid-read", phase: .abandoned) } /// The next piece of the body, nil at its end. @@ -334,7 +362,7 @@ final class PersistentHTTP1Transport: @unchecked Sendable { reading = false return abandoned } - finish(refusal: (wasAbandoned ? "drain failed: " : "") + EngineLog.summary(of: error)) + finish(refusal: (wasAbandoned ? "drain failed: " : "") + EngineLog.summary(of: error), error: error) if wasAbandoned { throw CancellationError() } throw error } @@ -361,7 +389,7 @@ final class PersistentHTTP1Transport: @unchecked Sendable { finish(refusal: refusal) return nil case .overLimit: - finish(refusal: "body over the \(limit) byte limit") + finish(refusal: "body over the \(limit) byte limit", phase: .bodyLimitExceeded) throw BoundedFetch.Exceeded(limit: limit) case .drain: // The read in flight when the body was abandoned is back; the drain goes on from @@ -434,7 +462,7 @@ final class PersistentHTTP1Transport: @unchecked Sendable { return } } catch { - finish(refusal: "drain failed: \(EngineLog.summary(of: error))") + finish(refusal: "drain failed: \(EngineLog.summary(of: error))", error: error) return } } @@ -442,14 +470,15 @@ final class PersistentHTTP1Transport: @unchecked Sendable { /// Releases the claim once, from whichever of the end, a failure, the drain or its deadline /// gets there first. - private func finish(refusal: String?, drained: Bool = false) { + private func finish(refusal: String?, drained: Bool = false, + phase: SourceNetworkDiagnostics.Phase = .completed, error: Error? = nil) { guard let connection else { return } - let first = lock.withLock { () -> Bool in - guard !released else { return false } + let outcome = lock.withLock { () -> (bytes: Int64, abandoned: Bool)? in + guard !released else { return nil } released = true - return true + return (received, abandoned) } - guard first else { return } + guard let outcome else { return } if drained, refusal == nil { EngineLog.emit( "[HLSVODIngest] AE#732 connection \(connection.label) drained, kept for the next request", @@ -460,6 +489,12 @@ final class PersistentHTTP1Transport: @unchecked Sendable { } else { connection.retire(reason: refusal ?? "transport gone") } + // Emit outside the body/pool locks, once, after the request releases its origin. + if let trace, let originalURL { + trace.record(outcome.abandoned ? .abandoned : phase, + originalURL: originalURL, url: response.url ?? originalURL, + status: response.statusCode, bytes: outcome.bytes, error: error) + } } /// "received/expected bytes in N s", for the log lines and the stall error. diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index ec603f4f..a29c2397 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -72,6 +72,7 @@ final class NativeAVPlayerHost { /// What the current session was loaded under, carried across an item swap (#440 round 5). See /// `SessionLoadContract` and `swapItem`. private(set) var sessionContract = SessionLoadContract() + private var sourceDiagnosticContext: SourceNetworkDiagnostics.Context? /// Where the current item was placed when it was mounted: the explicit start seek `load` makes, /// or nil when it joined without one (a live rejoin). Recorded on every mount, the in-place swaps @@ -491,7 +492,7 @@ final class NativeAVPlayerHost { /// session, so none of them gets to re-decide whether the session is live or what headers it sends. func swapItem(url: URL, startPosition: Double?, skipInitialSeek: Bool = false) { load(url: url, startPosition: startPosition, skipInitialSeek: skipInitialSeek, - inPlaceSwap: true, contract: sessionContract) + inPlaceSwap: true, diagnosticContext: sourceDiagnosticContext, contract: sessionContract) } /// Load the loopback HLS-fMP4 URL into AVPlayer. DisplayCriteriaController.apply must run first so the HDR pipeline is configured before the first segment fetch. @@ -502,11 +503,13 @@ final class NativeAVPlayerHost { /// keeps transport intent, clocks and the old item alive until replaceCurrentItem hands /// AVPlayer the fresh one. func load(url: URL, startPosition: Double?, perFrameHDR: Bool = true, skipInitialSeek: Bool = false, - inPlaceSwap: Bool = false, contract: SessionLoadContract) { + inPlaceSwap: Bool = false, diagnosticContext: SourceNetworkDiagnostics.Context? = nil, + contract: SessionLoadContract) { let outgoingDiagnostics = inPlaceSwap ? itemDiagnostics : nil unloadCurrentItem(inPlaceSwap: inPlaceSwap) self.sessionContract = contract + self.sourceDiagnosticContext = diagnosticContext mountedStartPosition = skipInitialSeek ? nil : (startPosition ?? 0) mountSeekPending = Self.mountHoldsClock(inPlaceSwap: inPlaceSwap, skipInitialSeek: skipInitialSeek, startPosition: startPosition, isLive: contract.isLive) @@ -624,7 +627,7 @@ final class NativeAVPlayerHost { // frame; a healthy stream's watchdog disarms and cancels it, an unjudgeable one has its verdict // ready by the time the watchdog arms. if armIngestFallback { - startCarriageProbe(asset: asset, url: url, httpHeaders: httpHeaders) + startCarriageProbe(asset: asset, url: url, httpHeaders: httpHeaders, diagnosticContext: diagnosticContext) } playerItem = item let diagnostics = AVPlayerItemDiagnostics(item: item, pool: diagnosticPool, read: diagnosticRead) @@ -2576,7 +2579,8 @@ final class NativeAVPlayerHost { /// not what a per-token connection cap counts, so the playlist stage runs against the mount; a segment /// fetch is, so it waits for readyToPlay (see `awaitReadyForDeferredProbe`). @MainActor - private func startCarriageProbe(asset: AVURLAsset, url: URL, httpHeaders: [String: String]) { + private func startCarriageProbe(asset: AVURLAsset, url: URL, httpHeaders: [String: String], + diagnosticContext: SourceNetworkDiagnostics.Context?) { let sid = sessionID carriageProbeTask = Task { @MainActor [weak self] in let variants = (try? await asset.load(.variants)) ?? [] @@ -2586,12 +2590,14 @@ final class NativeAVPlayerHost { let codecs = variants.compactMap { $0.videoAttributes }.flatMap { $0.codecTypes } guard RemoteHLSIngestFallback.shouldProbeCarriage( advertisesVideo: advertises, advertisedVideoCodecs: codecs) else { return } - let evidence = await HLSCarriageProbe.classifyFromPlaylists( - playlistURL: url, - httpHeaders: httpHeaders, - advertisesFragmentedMP4OnlyVideo: - RemoteHLSIngestFallback.advertisesFragmentedMP4OnlyVideo(codecs) - ) + let evidence = await SourceNetworkDiagnostics.$context.withValue(diagnosticContext) { + await HLSCarriageProbe.classifyFromPlaylists( + playlistURL: url, + httpHeaders: httpHeaders, + advertisesFragmentedMP4OnlyVideo: + RemoteHLSIngestFallback.advertisesFragmentedMP4OnlyVideo(codecs) + ) + } guard let self, !Task.isCancelled, self.sessionID == sid else { return } switch evidence { case .settled(let verdict): @@ -2617,8 +2623,10 @@ final class NativeAVPlayerHost { case .ready: break } - let verdict = await HLSCarriageProbe.classifyDeferredSegmentHead( - url: segmentURL, httpHeaders: httpHeaders, credentialOrigin: url) + let verdict = await SourceNetworkDiagnostics.$context.withValue(diagnosticContext) { + await HLSCarriageProbe.classifyDeferredSegmentHead( + url: segmentURL, httpHeaders: httpHeaders, credentialOrigin: url) + } guard !Task.isCancelled, self.sessionID == sid else { return } self.publishCarriageProbeVerdict(verdict, sid: sid, from: "segment PMT") } diff --git a/Sources/AetherEngine/PlaybackErrorInfo.swift b/Sources/AetherEngine/PlaybackErrorInfo.swift index d29b304c..ff3e4b5c 100644 --- a/Sources/AetherEngine/PlaybackErrorInfo.swift +++ b/Sources/AetherEngine/PlaybackErrorInfo.swift @@ -37,6 +37,10 @@ public struct PlaybackErrorKind: RawRepresentable, Sendable, Equatable, Hashable /// A custom `IOReader`'s initial probe failed. The host built that reader, so only the host can /// re-point it. public static let customSourceProbeFailed = PlaybackErrorKind(rawValue: "customSourceProbeFailed") + /// The HLS ingest cannot handle the source's segment/encryption layout. + public static let hlsFormatUnsupported = PlaybackErrorKind(rawValue: "hlsFormatUnsupported") + /// An HLS ingest validation/decryption/stall failure; the stable underlying code identifies it. + public static let hlsIngestFailed = PlaybackErrorKind(rawValue: "hlsIngestFailed") /// A live probe that burned its full reconnect budget without opening. public static let liveSourceUnavailable = PlaybackErrorKind(rawValue: "liveSourceUnavailable") /// An HLS playlist handed to the raw live path by a custom reader, which the engine cannot re-point @@ -111,17 +115,25 @@ public struct PlaybackErrorInfo: Sendable, Equatable { public let underlyingDomain: String? /// `NSError.code` of the failure underneath, or a reader's own code where one exists. public let underlyingCode: Int? + /// Finite HLS subreason; unlike the human-readable message, safe for support telemetry. + public let hlsDiagnosticReason: HLSIngestDiagnosticReason? + /// Original negative FFmpeg error, separate from the stable NSError case code. + public let ffmpegErrorCode: Int? /// The same text `state` carries. public let message: String public init(kind: PlaybackErrorKind, message: String, underlyingDomain: String? = nil, - underlyingCode: Int? = nil) { + underlyingCode: Int? = nil, + hlsDiagnosticReason: HLSIngestDiagnosticReason? = nil, + ffmpegErrorCode: Int? = nil) { self.kind = kind self.message = message self.underlyingDomain = underlyingDomain self.underlyingCode = underlyingCode + self.hlsDiagnosticReason = hlsDiagnosticReason + self.ffmpegErrorCode = ffmpegErrorCode } /// Captures domain and code off a Foundation / AVFoundation error, which is the half of the @@ -131,7 +143,9 @@ public struct PlaybackErrorInfo: Sendable, Equatable { self.init(kind: kind, message: message, underlyingDomain: nsError?.domain, - underlyingCode: nsError?.code) + underlyingCode: nsError?.code, + hlsDiagnosticReason: .preserved(in: underlying), + ffmpegErrorCode: DemuxerError.preservedFFmpegCode(in: underlying)) } } @@ -165,9 +179,23 @@ extension AetherEngine { /// `sourceRateLimited`, not `sourceRefused` (#377): the source is not gone, the same request is /// expected to work later, and a host that reacts to a refusal by handing off to a second player /// would have that player metered by the same origin. Anything the reader did not type keeps the - /// historical `sourceOpenFailed` with whatever was underneath. + /// historical `sourceOpenFailed` with whatever was underneath. A failed live start keeps + /// `liveSourceUnavailable`; preserving its cause must not change the host's recovery contract. @MainActor - func publishLoadFailure(_ error: Error) { + func publishLoadFailure(_ error: Error, isLiveStart: Bool = false) { + if let ingest = error as? HLSIngestError { + switch ingest { + case .playlistUnreachable(let status) where status > 0: + publishLoadFailure(AVIOReaderError.httpStatus(status), isLiveStart: isLiveStart) + case _ where isLiveStart: + publishError(.liveSourceUnavailable, "Live source unavailable", underlying: ingest) + case .encryptedNotSupported, .unsupportedSegmentFormat, .demuxedAudioNotSupported: + publishError(.hlsFormatUnsupported, "HLS format unsupported", underlying: ingest) + default: + publishError(.hlsIngestFailed, "HLS ingest failed", underlying: ingest) + } + return + } // AE#495: trust first. A refused handshake is not a verdict on the resource, it is the absence // of a connection, and it can arrive either typed by the reader or as a URL error nested under // whatever gave up on top of it. @@ -193,6 +221,15 @@ extension AetherEngine { underlyingCode: status)) return } + if isLiveStart { + if let code = SourceNetworkDiagnostics.urlErrorCode(error) { + publishError(PlaybackErrorInfo(kind: .liveSourceUnavailable, message: "Live source unavailable", + underlyingDomain: NSURLErrorDomain, underlyingCode: code)) + } else { + publishError(.liveSourceUnavailable, "Live source unavailable", underlying: error) + } + return + } publishError(.sourceOpenFailed, "Failed to load: \(error.localizedDescription)", underlying: error) } } diff --git a/Sources/AetherEngine/PlayerState.swift b/Sources/AetherEngine/PlayerState.swift index 18ddee64..c9786faa 100644 --- a/Sources/AetherEngine/PlayerState.swift +++ b/Sources/AetherEngine/PlayerState.swift @@ -412,6 +412,8 @@ public enum DolbyVisionHandling: String, Sendable, Equatable, CaseIterable { } public struct LoadOptions: Sendable, Equatable { + /// Diagnostic identity only; retained with the source across internal reloads. + public var sourceDiagnosticsContext: SourceNetworkDiagnostics.Context? = nil /// Diagnostic lever: omit BT.2020 / transfer / YCbCr matrix from AVDisplayCriteria so AVPlayer re-reads color from the bitstream. Default off. public var omitCriteriaColorExtensions: Bool /// The engine writes no display criteria because AVKit does: `true` is `DisplayCriteriaOwner.avKit`, and the diff --git a/Sources/AetherEngine/Video/HLSVideoEngine.swift b/Sources/AetherEngine/Video/HLSVideoEngine.swift index 1510d42f..db3fb0f8 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine.swift @@ -1095,6 +1095,7 @@ public final class HLSVideoEngine: @unchecked Sendable { dvrWindowSeconds: Double? = nil, liveJoinProfile: LiveJoinProfile = .standard, sourceOpenPolicy: SourceOpenPolicy = .init(), + diagnosticContext: SourceNetworkDiagnostics.Context? = nil, liveStartupGraceSeconds: TimeInterval? = nil, liveStartupSingleSegmentMinimumSeconds: TimeInterval? = nil, liveEdgeCushionSeconds: Double? = nil, @@ -1133,6 +1134,7 @@ public final class HLSVideoEngine: @unchecked Sendable { .withSequentialOrigin(sequentialOrigin, declaredDuration: declaredDurationSeconds) .withHeldSourceConnection(heldSourceConnection) .withSourceOpenPolicy(sourceOpenPolicy) + .withDiagnosticContext(diagnosticContext) self.dvModeAvailable = dvModeAvailable self.displaySupportsHDR = displaySupportsHDR self.keepDvh1TagWithoutDV = keepDvh1TagWithoutDV @@ -1289,6 +1291,7 @@ public final class HLSVideoEngine: @unchecked Sendable { var restartReopenProfile: DemuxerOpenProfile { DemuxerOpenProfile.restartReopen .withSourceOpenPolicy(openProfile.sourceOpenPolicy) + .withDiagnosticContext(openProfile.diagnosticContext) .withSequentialOrigin(sequentialOrigin, declaredDuration: declaredDurationSeconds) .withHeldSourceConnection(heldSourceConnection) .withRequestHeaders(requestHeaders) diff --git a/Tests/AetherEngineTests/DocumentedConstantsTests.swift b/Tests/AetherEngineTests/DocumentedConstantsTests.swift index 297e2b1c..baeec4f1 100644 --- a/Tests/AetherEngineTests/DocumentedConstantsTests.swift +++ b/Tests/AetherEngineTests/DocumentedConstantsTests.swift @@ -18,6 +18,13 @@ import AetherLibavcodec /// Adding a number to the docs? Pin it here in the same commit. @MainActor final class DocumentedConstantsTests: XCTestCase { + func testDemuxNSErrorCaseCodesMatchDocumentation() throws { + XCTAssertEqual((DemuxerError.openFailed(code: -1) as NSError).code, 0) + XCTAssertEqual((DemuxerError.streamInfoFailed(code: -1) as NSError).code, 1) + XCTAssertEqual((DemuxerError.readFailed(code: -1) as NSError).code, 2) + assertDocumented("0 (open), 1 (stream information), and 2 (read)", try documentation()) + } + func testLiveReplayQuarantineBoundsMatchDocumentation() throws { XCTAssertEqual(LivePacketReplayGuard.maximumPendingBytes, 96 * 1_024 * 1_024) XCTAssertEqual(LivePacketReplayGuard.maximumPendingPackets, 10_000) diff --git a/Tests/AetherEngineTests/HLSIngestDiagnosticsTests.swift b/Tests/AetherEngineTests/HLSIngestDiagnosticsTests.swift new file mode 100644 index 00000000..7d8b185b --- /dev/null +++ b/Tests/AetherEngineTests/HLSIngestDiagnosticsTests.swift @@ -0,0 +1,160 @@ +import Foundation +import XCTest +@testable import AetherEngine + +final class HLSIngestDiagnosticsTests: XCTestCase { + func testAllCasesPreserveHistoricalNSErrorCodesAndPlaybackErrorInfo() { + // Codes produced by Swift's implicit bridging at upstream 834f5fd4, before CustomNSError. + let cases: [(HLSIngestError, Int, HLSIngestDiagnosticReason)] = [ + (.playlistUnreachable(status: 403), 0, .httpResponseRefused), + (.playlistInvalid(reason: "no segments"), 1, .noSegments), + (.segmentDecryptFailed(reason: "AES-128-CBC failed"), 2, .decryptionFailed), + (.encryptedNotSupported, 3, .encryptionUnsupported), + (.unsupportedSegmentFormat, 4, .segmentFormatUnsupported), + (.ingestStalled, 5, .ingestStalled), + (.demuxedAudioNotSupported, 6, .demuxedAudioUnsupported) + ] + for (error, code, reason) in cases { + let bridged = error as NSError + XCTAssertEqual(bridged.domain, "AetherEngine.HLSIngestError") + XCTAssertEqual(bridged.code, code, "Historical code changed for \(error)") + XCTAssertEqual(bridged.localizedDescription, error.description) + XCTAssertEqual(bridged.userInfo[HLSIngestDiagnosticReason.userInfoKey] as? String, reason.rawValue) + + let info = PlaybackErrorInfo(kind: .hlsIngestFailed, message: "HLS ingest failed", underlying: bridged) + XCTAssertEqual(info.underlyingDomain, "AetherEngine.HLSIngestError") + XCTAssertEqual(info.underlyingCode, code) + XCTAssertEqual(info.hlsDiagnosticReason, reason) + } + } + + func testPlaylistSubreasonsSurviveNSErrorWithoutChangingTheExistingCode() { + let cases: [(String, HLSIngestDiagnosticReason)] = [ + ("missing #EXTM3U", .missingEXTM3U), + ("missing TARGETDURATION", .missingTargetDuration), + ("no segments", .noSegments), + ("master playlist without variants", .noVariants), + ("no usable variant", .noUsableVariant), + ("selected variant is not a media playlist", .expectedMediaPlaylist), + ("expected media playlist on refresh", .expectedMediaPlaylist), + ("finite VOD requires EXT-X-ENDLIST", .missingEndList), + ("unresolvable segment URI", .invalidSegmentURI), + ("media sequence out of range", .invalidMediaSequence), + ("TARGETDURATION out of range", .invalidTargetDuration), + ("EXTINF out of range", .invalidSegmentDuration), + ("segment duration must be positive", .invalidSegmentDuration), + ("program duration exceeds 1.0s", .durationLimitExceeded), + ("playlist exceeds 10 bytes", .playlistSizeLimitExceeded), + ("playlist line exceeds 10 bytes", .playlistLineLimitExceeded), + ("segment exceeds 10 bytes", .bodySizeLimitExceeded), + ("segment body over its limit", .bodySizeLimitExceeded), + ("body exceeds 10 bytes", .bodySizeLimitExceeded), + ("non-UTF8 playlist", .invalidPlaylistEncoding), + ("playlist is not bounded UTF-8", .invalidPlaylistEncoding), + ("https://private.invalid/password?token=secret", .playlistInvalid) + ] + for (text, reason) in cases { + let error = HLSIngestError.playlistInvalid(reason: text) + let bridged = error as NSError + XCTAssertEqual(bridged.code, 1) + XCTAssertEqual(bridged.domain, HLSIngestError.errorDomain) + XCTAssertEqual(error.diagnosticReason, reason) + XCTAssertEqual(bridged.userInfo[HLSIngestDiagnosticReason.userInfoKey] as? String, reason.rawValue) + let wrapper = NSError(domain: "AetherEngine.DemuxerError", code: 1, + userInfo: [NSUnderlyingErrorKey: bridged]) + let info = PlaybackErrorInfo(kind: .customSourceProbeFailed, message: "private", underlying: wrapper) + XCTAssertEqual(info.hlsDiagnosticReason, reason) + XCTAssertEqual(info.underlyingDomain, wrapper.domain) + XCTAssertEqual(info.underlyingCode, wrapper.code) + } + } + + func testParserRetainsTheSpecificMalformedPlaylistCause() { + let cases: [(String, HLSIngestDiagnosticReason)] = [ + ("private credentials", .missingEXTM3U), + ("#EXTM3U\n#EXTINF:4,\nsegment.ts", .missingTargetDuration), + ("#EXTM3U\n#EXT-X-TARGETDURATION:4", .noSegments), + ("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1", .noVariants), + ("#EXTM3U\n#EXT-X-TARGETDURATION:inf\n#EXTINF:4,\nsegment.ts", .invalidTargetDuration), + ("#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXTINF:nan,\nsegment.ts", .invalidSegmentDuration) + ] + for (body, reason) in cases { + XCTAssertThrowsError(try HLSPlaylistParser.parse(body)) { error in + XCTAssertEqual(HLSIngestDiagnosticReason.preserved(in: error), reason) + } + } + } + + func testResponseEncryptionAndSegmentReasonsAreFinite() { + let cases: [(HLSIngestError, HLSIngestDiagnosticReason, Int)] = [ + (.playlistUnreachable(status: -1), .invalidResponse, 0), + (.playlistUnreachable(status: 403), .httpResponseRefused, 0), + (.encryptedNotSupported, .encryptionUnsupported, 3), + (.segmentDecryptFailed(reason: "unresolvable key URI"), .invalidKeyURI, 2), + (.segmentDecryptFailed(reason: "key length 8 != 16"), .invalidKeyLength, 2), + (.segmentDecryptFailed(reason: "key length is not 16 bytes"), .invalidKeyLength, 2), + (.segmentDecryptFailed(reason: "key exceeds 64 bytes"), .keySizeLimitExceeded, 2), + (.segmentDecryptFailed(reason: "https://private.invalid/token"), .decryptionFailed, 2), + (.unsupportedSegmentFormat, .segmentFormatUnsupported, 4), + (.ingestStalled, .ingestStalled, 5), + (.demuxedAudioNotSupported, .demuxedAudioUnsupported, 6) + ] + for (error, reason, code) in cases { + XCTAssertEqual(error.diagnosticReason, reason) + XCTAssertEqual((error as NSError).code, code) + } + } + + func testUntrustedReasonMetadataIsRejectedAndTraversalIsBounded() { + let privateMetadata = [HLSIngestDiagnosticReason.userInfoKey: "https://private.invalid/password"] + XCTAssertNil(HLSIngestDiagnosticReason.preserved(in: NSError( + domain: HLSIngestError.errorDomain, code: 1, userInfo: privateMetadata))) + XCTAssertNil(HLSIngestDiagnosticReason.preserved(in: NSError( + domain: "unrelated.domain", code: 2, + userInfo: [HLSIngestDiagnosticReason.userInfoKey: "no_segments"]))) + var wrapped: NSError = HLSIngestError.playlistInvalid(reason: "no segments") as NSError + for _ in 0..<6 { + wrapped = NSError(domain: "wrapper", code: 1, userInfo: [NSUnderlyingErrorKey: wrapped]) + } + XCTAssertNil(HLSIngestDiagnosticReason.preserved(in: wrapped), "Traversal must have a fixed bound") + } + func testEverySourceReasonLiteralHasASpecificDiagnosticMapping() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Sources") + let enumerator = try XCTUnwrap(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) + let pattern = try NSRegularExpression(pattern: #"HLSIngestError\.(playlistInvalid|segmentDecryptFailed)\(reason:\s*"((?:\\.|[^"\\])*)""#) + let interpolation = try NSRegularExpression(pattern: #"\\\([^)]*\)"#) + var count = 0 + for case let file as URL in enumerator where file.pathExtension == "swift" { + let source = try String(contentsOf: file, encoding: .utf8) + let ns = source as NSString + for match in pattern.matches(in: source, range: NSRange(location: 0, length: ns.length)) { + count += 1 + let kind = ns.substring(with: match.range(at: 1)) + let literal = ns.substring(with: match.range(at: 2)) + let reasons: [String] + if literal == #"\(tag) out of range"# { + // Expand the parser's dynamic tag from its actual call sites, not a duplicate list. + let tags = try NSRegularExpression(pattern: #"tag:\s*"([^"]+)""#) + reasons = tags.matches(in: source, range: NSRange(location: 0, length: ns.length)) + .map { ns.substring(with: $0.range(at: 1)) + " out of range" } + XCTAssertFalse(reasons.isEmpty) + } else { + reasons = [interpolation.stringByReplacingMatches(in: literal, + range: NSRange(location: 0, length: (literal as NSString).length), withTemplate: "1")] + } + for reason in reasons { + let error: HLSIngestError = kind == "playlistInvalid" + ? .playlistInvalid(reason: reason) : .segmentDecryptFailed(reason: reason) + XCTAssertNotEqual(error.diagnosticReason, .playlistInvalid, "Unmapped reason at \(file.lastPathComponent): \(literal)") + if error.diagnosticReason == .decryptionFailed { + // This is a real decrypt failure, whereas an unknown key error would hit the same fallback. + XCTAssertEqual(reason, "AES-128-CBC failed", "Unmapped decrypt reason at \(file.lastPathComponent): \(literal)") + } + } + } + } + XCTAssertGreaterThan(count, 0, "Source scan must exercise real throw sites") + } + +} diff --git a/Tests/AetherEngineTests/Issue460ReloadWithOptionsTests.swift b/Tests/AetherEngineTests/Issue460ReloadWithOptionsTests.swift index a517e8c1..9e59de9c 100644 --- a/Tests/AetherEngineTests/Issue460ReloadWithOptionsTests.swift +++ b/Tests/AetherEngineTests/Issue460ReloadWithOptionsTests.swift @@ -51,6 +51,39 @@ struct Issue460SessionOptionCorrectionTests { #expect(SessionOptionCorrection.refusedFields(from: base, to: sequential) == ["sequentialOrigin"]) } + @Test("diagnostic identity cannot be added, removed or changed by an internal reload") + func diagnosticIdentityChangesAreRefused() { + let context = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + var current = LoadOptions() + var proposed = current + proposed.sourceDiagnosticsContext = context + #expect(SessionOptionCorrection.refusedFields(from: current, to: proposed) == ["sourceDiagnosticsContext"]) + current = proposed + let replacements: [SourceNetworkDiagnostics.Context?] = [ + nil, + .init(playbackID: UUID(), attemptID: context.attemptID), + .init(playbackID: context.playbackID, attemptID: UUID()) + ] + for replacement in replacements { + proposed.sourceDiagnosticsContext = replacement + #expect(SessionOptionCorrection.refusedFields(from: current, to: proposed) == ["sourceDiagnosticsContext"]) + } + } + + @Test("an equal diagnostic identity permits a tuning correction") + func equalDiagnosticIdentityAllowsTuning() { + let context = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + var current = LoadOptions() + current.sourceDiagnosticsContext = context + var proposed = current + proposed.sourceDiagnosticsContext = .init(playbackID: context.playbackID, attemptID: context.attemptID) + #expect(SessionOptionCorrection.refusedFields(from: current, to: proposed).isEmpty) + #expect(SessionOptionCorrection.changedFields(from: current, to: proposed).isEmpty) + proposed.audioDelaySeconds = 0.5 + #expect(SessionOptionCorrection.refusedFields(from: current, to: proposed).isEmpty) + #expect(SessionOptionCorrection.changedFields(from: current, to: proposed) == ["audioDelaySeconds"]) + } + @Test("several identity changes are all named, not just the first") func identityChangesAreAllNamed() { let base = LoadOptions() @@ -150,6 +183,35 @@ struct Issue460ReloadWithOptionsTests { #expect(engine.loadedOptions.isLive) } + @Test("a diagnostic identity correction refuses before teardown or tuning installation") + func diagnosticIdentityRefusesBeforeTeardown() async throws { + let engine = try AetherEngine() + var seeded = LoadOptions() + seeded.sourceDiagnosticsContext = .init(playbackID: UUID(), attemptID: UUID()) + engine.setLoadedOptionsForTesting(seeded) + let url = URL(string: "https://example.test/movie.mkv")! + engine.loadedURL = url + engine.state = .playing + let generation = engine.loadGeneration + do { + try await engine.reloadAtCurrentPosition { + $0.sourceDiagnosticsContext = .init(playbackID: UUID(), attemptID: UUID()) + $0.audioDelaySeconds = 0.5 + } + Issue.record("expected a diagnostic identity refusal") + } catch let error as AetherEngineError { + guard case .loadIdentityNotCorrectable(let fields) = error else { + Issue.record("expected loadIdentityNotCorrectable, got \(error)") + return + } + #expect(fields == ["sourceDiagnosticsContext"]) + } + #expect(engine.loadedOptions == seeded) + #expect(engine.loadedURL == url) + #expect(engine.state == .playing) + #expect(engine.loadGeneration == generation) + } + @Test("a refused correction does not install the options it refused") func refusedCorrectionDoesNotInstall() async throws { let engine = try AetherEngine() @@ -191,12 +253,14 @@ struct Issue460ReloadWithOptionsTests { let engine = try AetherEngine() var seeded = LoadOptions() seeded.httpHeaders = ["Authorization": "Bearer stale"] + seeded.sourceDiagnosticsContext = .init(playbackID: UUID(), attemptID: UUID()) engine.setLoadedOptionsForTesting(seeded) var corrected = seeded corrected.httpHeaders["Authorization"] = "Bearer fresh" engine.applySessionOptionCorrection(corrected) #expect(engine.loadedOptions.httpHeaders["Authorization"] == "Bearer fresh") + #expect(engine.loadedOptions.sourceDiagnosticsContext == seeded.sourceDiagnosticsContext) } } diff --git a/Tests/AetherEngineTests/LostSourceReadErrorTests.swift b/Tests/AetherEngineTests/LostSourceReadErrorTests.swift index 764e4519..d63525ad 100644 --- a/Tests/AetherEngineTests/LostSourceReadErrorTests.swift +++ b/Tests/AetherEngineTests/LostSourceReadErrorTests.swift @@ -13,8 +13,9 @@ struct LostSourceReadErrorTests { let port: UInt16 private let listener: LoopbackListener private let bodyBytes: Int - private let lock = NSLock() + private let lock = NSCondition() private var stopped = false + private var bodyCutRequested = false private var isStopped: Bool { lock.lock() defer { lock.unlock() } @@ -35,10 +36,18 @@ struct LostSourceReadErrorTests { func stop() { lock.lock() stopped = true + lock.broadcast() lock.unlock() listener.stop() } + func cutBody() { + lock.lock() + bodyCutRequested = true + lock.broadcast() + lock.unlock() + } + private func serve(_ fd: Int32) { var buf = [UInt8](repeating: 0, count: 16 * 1024) var request = Data() @@ -64,6 +73,13 @@ struct LostSourceReadErrorTests { sent += n } } + if !isHead { + // Prove a mid-read loss: CFNetwork may otherwise reject the truncated body + // before delivering any data, correctly turning this into an open failure. + lock.lock() + while !bodyCutRequested && !stopped { lock.wait() } + lock.unlock() + } // Half-close, as `ThrottledOriginServer.serveThenDrop` does: a full close can turn into // an RST that discards bytes the reader has not been handed yet. shutdown(fd, SHUT_WR) @@ -81,15 +97,30 @@ struct LostSourceReadErrorTests { } } - @Test("a length-less stream cut off mid-body reports EIO, not EOF", .timeLimit(.minutes(1))) - func cutOffLengthlessStreamIsAnError() throws { + @Test("a length-less stream cut off mid-body reports EIO, not EOF", + .timeLimit(.minutes(1)), arguments: [false, true]) + func cutOffLengthlessStreamIsAnError(sequentialOnly: Bool) async throws { let origin = try #require(LengthlessResettingOrigin(bodyBytes: 256 * 1024)) defer { origin.stop() } let reader = AVIOReader(url: URL(string: "http://127.0.0.1:\(origin.port)/live.ts")!, - prefetchEnabled: false) - defer { reader.markClosed(); reader.close() } - try reader.open() - #expect(drain(reader) == FFmpegErr.eio) + prefetchEnabled: false, sequentialOnly: sequentialOnly) + let outcome = try await withTaskCancellationHandler { + try await ProbeTestJob { + defer { reader.markClosed(); reader.close() } + try reader.open() + var firstByte: UInt8 = 0 + let firstRead = withUnsafeMutablePointer(to: &firstByte) { reader.read(into: $0, size: 1) } + guard firstRead == 1 else { return (firstRead, firstRead) } + origin.cutBody() + return (firstRead, drain(reader)) + }.outcome() + } onCancel: { + reader.markClosed() + origin.stop() + } + let (firstRead, last) = try outcome.get() + try #require(firstRead == 1) + #expect(last == FFmpegErr.eio) } @Test("a seekable read whose fetch fails reports EIO, not EOF", .timeLimit(.minutes(1))) diff --git a/Tests/AetherEngineTests/PlaybackErrorInfoTests.swift b/Tests/AetherEngineTests/PlaybackErrorInfoTests.swift index ba1d387c..b7a4d292 100644 --- a/Tests/AetherEngineTests/PlaybackErrorInfoTests.swift +++ b/Tests/AetherEngineTests/PlaybackErrorInfoTests.swift @@ -26,6 +26,7 @@ struct PlaybackErrorInfoTests { #expect(observed?.kind == .customSourceProbeFailed) #expect(observed?.underlyingDomain == "AetherEngine.DemuxerError") #expect(observed?.underlyingCode != nil) + #expect(observed?.ffmpegErrorCode != nil) #expect(observed == engine.errorInfo) } } @@ -44,10 +45,33 @@ struct PlaybackErrorInfoTests { } @MainActor - @Test("A live HLS join that reaches nothing publishes an unavailable live source", .timeLimit(.minutes(1))) + @Test("A live HLS join retains the actual connection error", .timeLimit(.minutes(1))) func unreachableLiveJoinIsUnavailable() async throws { let observed = try await failedLiveIngestLoad(try #require(URL(string: "http://127.0.0.1:1/live/1.m3u8"))) #expect(observed?.kind == .liveSourceUnavailable) + #expect(observed?.underlyingDomain == NSURLErrorDomain) + #expect(observed?.underlyingCode == NSURLErrorCannotConnectToHost) + } + + @MainActor + @Test("HLS format limits, invalid bodies and key HTTP refusals stay distinct") + func hlsFailureClassification() throws { + let engine = try AetherEngine() + defer { engine.stop() } + for (error, kind, code) in [ + (HLSIngestError.unsupportedSegmentFormat, PlaybackErrorKind.hlsFormatUnsupported, 4), + (.encryptedNotSupported, .hlsFormatUnsupported, 3), + (.playlistInvalid(reason: "private"), .hlsIngestFailed, 1), + (.segmentDecryptFailed(reason: "private"), .hlsIngestFailed, 2), + (.playlistUnreachable(status: 403), .sourceRefused, 403) + ] { + engine.publishLoadFailure(error) + #expect(engine.errorInfo?.kind == kind) + #expect(engine.errorInfo?.underlyingCode == code) + } + engine.publishLoadFailure(URLError(.secureConnectionFailed)) + #expect(engine.errorInfo?.underlyingDomain == NSURLErrorDomain) + #expect(engine.errorInfo?.underlyingCode == -1200) } /// Loads `url` live through the engine's own HLS ingest, as a known HLS playlist on the live path @@ -63,11 +87,14 @@ struct PlaybackErrorInfoTests { defer { token.cancel() } var options = LoadOptions() options.isLive = true + let reader = HLSLiveIngestReader(playlistURL: url) do { _ = try await engine.load( - source: .custom(HLSLiveIngestReader(playlistURL: url), formatHint: "mpegts"), options: options) + source: .custom(reader, formatHint: "mpegts"), options: options) Issue.record("A refused live playlist must fail the load") - } catch {} + } catch { + #expect(reader.terminalError != nil, "Existing hosts must still observe a terminal reader") + } return observed } @@ -111,6 +138,46 @@ struct PlaybackErrorInfoTests { #expect(info.kind == .nativeItemFailed) #expect(info.underlyingDomain == "CoreMediaErrorDomain") #expect(info.underlyingCode == -12939) + #expect(info.ffmpegErrorCode == nil) + } + + @Test("Demux diagnostics preserve historical case codes and a separate FFmpeg cause") + func capturesDemuxDiagnostics() { + let cases: [(DemuxerError, Int)] = [ + (.openFailed(code: -1094995529), 0), + (.streamInfoFailed(code: -1094995529), 1), + (.readFailed(code: -1094995529), 2) + ] + for (error, code) in cases { + let info = PlaybackErrorInfo(kind: .customSourceProbeFailed, message: "private", underlying: error) + #expect(info.underlyingDomain == "AetherEngine.DemuxerError") + #expect(info.underlyingCode == code) + #expect(info.ffmpegErrorCode == -1094995529) + } + let wrapper = NSError(domain: "wrapper", code: 42, + userInfo: [NSUnderlyingErrorKey: DemuxerError.readFailed(code: -5) as NSError]) + let info = PlaybackErrorInfo(kind: .sourceOpenFailed, message: "private", underlying: wrapper) + #expect(info.underlyingDomain == "wrapper") + #expect(info.underlyingCode == 42) + #expect(info.ffmpegErrorCode == -5) + } + + @Test("FFmpeg metadata requires the SDK domain, a negative Int32 and bounded wrappers") + func rejectsInvalidDemuxDiagnostics() { + let invalidValues: [Any] = ["private", -1.5, Double.nan, Double.infinity, 0, 1, Int(Int32.min) - 1] + for value in invalidValues { + let error = NSError(domain: DemuxerError.errorDomain, code: 0, + userInfo: [DemuxerError.ffmpegErrorCodeKey: value]) + #expect(PlaybackErrorInfo(kind: .sourceOpenFailed, message: "private", underlying: error).ffmpegErrorCode == nil) + } + let wrongDomain = NSError(domain: "unrelated", code: 0, + userInfo: [DemuxerError.ffmpegErrorCodeKey: -5]) + #expect(PlaybackErrorInfo(kind: .sourceOpenFailed, message: "private", underlying: wrongDomain).ffmpegErrorCode == nil) + var wrapped = DemuxerError.openFailed(code: -5) as NSError + for _ in 0..<6 { + wrapped = NSError(domain: "wrapper", code: 42, userInfo: [NSUnderlyingErrorKey: wrapped]) + } + #expect(PlaybackErrorInfo(kind: .sourceOpenFailed, message: "private", underlying: wrapped).ffmpegErrorCode == nil) } @Test("An engine-authored failure has no underlying domain, which is how a host tells the halves apart") @@ -118,6 +185,7 @@ struct PlaybackErrorInfoTests { let info = PlaybackErrorInfo(kind: .liveSourceUnavailable, message: "Live source unavailable") #expect(info.underlyingDomain == nil) #expect(info.underlyingCode == nil) + #expect(info.ffmpegErrorCode == nil) // The same constructor path the hosts use when there is nothing underneath. let fromNilError = PlaybackErrorInfo(kind: .liveSourceUnavailable, message: "Live source unavailable", diff --git a/Tests/AetherEngineTests/PublicAPIDocumentationTests.swift b/Tests/AetherEngineTests/PublicAPIDocumentationTests.swift index 62fe3bf1..721c2c99 100644 --- a/Tests/AetherEngineTests/PublicAPIDocumentationTests.swift +++ b/Tests/AetherEngineTests/PublicAPIDocumentationTests.swift @@ -52,6 +52,7 @@ final class PublicAPIDocumentationTests: XCTestCase { "Diagnostics/EngineDiagnostics.swift", "Diagnostics/LiveTelemetry.swift", "Diagnostics/EngineLog.swift", + "Diagnostics/SourceNetworkDiagnostics.swift", "Network/EngineTLS.swift", "FrameExtractor/FrameExtractor.swift", "Recording/RecordingState.swift", diff --git a/Tests/AetherEngineTests/SequentialOriginReaderTests.swift b/Tests/AetherEngineTests/SequentialOriginReaderTests.swift index 75882329..6b947b64 100644 --- a/Tests/AetherEngineTests/SequentialOriginReaderTests.swift +++ b/Tests/AetherEngineTests/SequentialOriginReaderTests.swift @@ -46,8 +46,8 @@ struct SequentialOriginReaderTests { #expect(server.rangeHeaderPresence == [false]) } - @Test("a body that ends short of its Content-Length reports EIO, not EOF") - func shortBodyReportsEIO() throws { + @Test("a body that ends short of its Content-Length reports EIO, not EOF", .timeLimit(.minutes(1))) + func shortBodyReportsEIO() async throws { let total: Int64 = 8 * 1024 * 1024 let servedBeforeDrop: Int64 = 2 * 1024 * 1024 let maybeServer = ThrottledOriginServer(totalSize: total, throttleUs: 0, @@ -56,10 +56,25 @@ struct SequentialOriginReaderTests { defer { server.stop() } let reader = AVIOReader(url: URL(string: "http://127.0.0.1:\(server.port)/archive.ts")!, sequentialOnly: true) - defer { reader.markClosed(); reader.close() } - try reader.open() - - let (read, last) = drain(reader, upTo: total) + let outcome = try await withTaskCancellationHandler { + try await ProbeTestJob<(Int64, Int32)?> { + defer { reader.markClosed(); reader.close() } + do { + try reader.open() + } catch let error as URLError where error.code == .networkConnectionLost { + // No body reached the reader: open preserves the original transport failure. + return nil + } + return drain(reader, upTo: total) + }.outcome() + } onCancel: { + reader.markClosed() + server.stop() + } + let drained = try outcome.get() + #expect(server.requestLog.count == 1) + #expect(server.rangeHeaderPresence == [false]) + guard let (read, last) = drained else { return } // Whatever arrived is short of the promise, and the assertions stop there. How much // arrives is URLSession's call: it hands over what it has buffered when the body is cut // off, and on a loaded runner that can be nothing at all. `>= 1 MiB` fell first diff --git a/Tests/AetherEngineTests/SourceFailureDiagnosticsTests.swift b/Tests/AetherEngineTests/SourceFailureDiagnosticsTests.swift new file mode 100644 index 00000000..572bac4b --- /dev/null +++ b/Tests/AetherEngineTests/SourceFailureDiagnosticsTests.swift @@ -0,0 +1,594 @@ +import Foundation +import Testing +@testable import AetherEngine + +@Suite("Source failure diagnostics", .serialized) +struct SourceFailureDiagnosticsTests { + private final class CorrelationEvents: @unchecked Sendable { + private let lock = NSLock() + private var values: [SourceNetworkDiagnostics.Event] = [] + func add(_ value: SourceNetworkDiagnostics.Event) { lock.withLock { values.append(value) } } + func snapshot() -> [SourceNetworkDiagnostics.Event] { lock.withLock { values } } + } + + /// Feed the real task delegate a response before failing the transport. URLProtocol buffers + /// its response until completion, so its ordinary client API cannot model a mid-body failure. + private final class FailedPartialResponse: URLProtocol, @unchecked Sendable { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + guard let task = task as? URLSessionDataTask, + let delegate = task.delegate as? URLSessionDataDelegate else { + client?.urlProtocol(self, didFailWithError: URLError(.unknown)) + return + } + let response = HTTPURLResponse(url: request.url!, statusCode: 206, httpVersion: "HTTP/1.1", + headerFields: ["Content-Range": "bytes 0-4095/*", "Content-Length": "4096"])! + delegate.urlSession?(.shared, dataTask: task, didReceive: response) { _ in + self.client?.urlProtocol(self, didFailWithError: URLError(.networkConnectionLost)) + } + } + override func stopLoading() {} + } + + @Test("An abandoned ranged failure does not override a streaming replacement", + .timeLimit(.minutes(1)), arguments: [200, 403]) + func retiredConnectionDoesNotFailStreamingReplacement(status: Int) async throws { + let body = Data(repeating: 0x47, count: 8192) + let server = try #require(ScriptedOriginServer { request in + if request.range != nil || request.method == "HEAD" { + return .init(status: 200, declaredLength: nil, close: true) + } + return .init(status: status, declaredLength: nil, close: true, + body: status == 200 ? body : nil) + }) + defer { server.stop() } + let url = URL(string: "http://127.0.0.1:\(server.port)/\(UUID().uuidString).bin")! + let events = CorrelationEvents() + let previous = SourceNetworkDiagnostics.handler + SourceNetworkDiagnostics.handler = { event in + guard event.originalURL == url else { return } + events.add(event) + } + defer { SourceNetworkDiagnostics.handler = previous } + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FailedPartialResponse.self] + let session = URLSession(configuration: configuration, delegate: EngineTLS.sessionDelegate, delegateQueue: nil) + defer { session.invalidateAndCancel() } + let reader = AVIOReader(url: url, boundedInitialFetch: 4096, persistentRequestSession: session) + let result = try await withTaskCancellationHandler { + try await ProbeTestJob { + defer { reader.markClosed(); reader.close() } + try reader.open() + var prefix = Data(count: 16) + let count = prefix.withUnsafeMutableBytes { + reader.read(into: $0.baseAddress!.assumingMemoryBound(to: UInt8.self), size: 16) + } + return (reader.isSeekable, count > 0 ? Data(prefix.prefix(Int(count))) : Data()) + }.outcome() + } onCancel: { + reader.markClosed() + server.stop() + } + if status == 200 { + let (seekable, prefix) = try result.get() + #expect(!seekable) + #expect(prefix == body.prefix(16)) + } else { + #expect(throws: AVIOReaderError.httpStatus(status)) { try result.get() } + } + #expect(events.snapshot().contains { + $0.role == .persistentRead && $0.phase == .completed && $0.urlErrorCode == NSURLErrorNetworkConnectionLost + }) + #expect(events.snapshot().contains { + $0.role == .persistentRead && $0.phase == .response && $0.status == 206 + }) + #expect(server.requests.contains { $0.method == "GET" && $0.range == nil }) + } + + @Test("A delayed old HLS request keeps its attempt after a retry and a different playback", .timeLimit(.minutes(1))) + func delayedHLSRequestKeepsOriginalAttempt() async throws { + let events = CorrelationEvents() + let previous = SourceNetworkDiagnostics.handler + SourceNetworkDiagnostics.handler = { events.add($0) } + defer { SourceNetworkDiagnostics.handler = previous } + let releaseOld = DispatchSemaphore(value: 0) + let server = try #require(ScriptedOriginServer { request in + // ScriptedOriginServer runs each reply on its own detached serving thread. + if request.range == "bytes=0-0" { releaseOld.wait() } + return .init(status: 200, declaredLength: 0) + }) + defer { releaseOld.signal(); server.stop() } + let session = URLSession(configuration: .ephemeral, delegate: EngineTLS.sessionDelegate, delegateQueue: nil) + defer { session.invalidateAndCancel() } + let playbackID = UUID() + let old = SourceNetworkDiagnostics.Context(playbackID: playbackID, attemptID: UUID()) + let retry = SourceNetworkDiagnostics.Context(playbackID: playbackID, attemptID: UUID()) + let replacement = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + let baseURL = URL(string: "http://127.0.0.1:\(server.port)/\(UUID().uuidString)/")! + let oldURL = baseURL.appendingPathComponent("old.m3u8") + let retryURL = baseURL.appendingPathComponent("retry.m3u8") + let replacementURL = baseURL.appendingPathComponent("replacement.m3u8") + let fixtureURLs = [oldURL, retryURL, replacementURL] + var originalRequest = URLRequest(url: oldURL) + originalRequest.setValue("bytes=0-0", forHTTPHeaderField: "Range") + let oldRequest = SourceNetworkDiagnostics.request(originalRequest, context: old) + let oldFetch = Task { + // The request was already created for the outgoing attempt. Even + // a newer ambient context at fetch time must not replace its owner. + try await SourceNetworkDiagnostics.$context.withValue(retry) { + try await BoundedPlaylistFetch.data(for: oldRequest, session: session, limit: 1_024) + } + } + defer { oldFetch.cancel() } + try await waitFor { server.requests.contains { $0.range == "bytes=0-0" } } + let retryRequest = SourceNetworkDiagnostics.request(URLRequest(url: retryURL), context: retry) + _ = try await SourceNetworkDiagnostics.$context.withValue(replacement) { + try await BoundedPlaylistFetch.data(for: retryRequest, session: session, limit: 1_024) + } + let replacementRequest = SourceNetworkDiagnostics.request(URLRequest(url: replacementURL), context: replacement) + _ = try await BoundedPlaylistFetch.data(for: replacementRequest, session: session, limit: 1_024) + #expect(!events.snapshot().contains { $0.originalURL == oldURL && $0.phase == .completed }) + releaseOld.signal() + let (_, oldResponse) = try await oldFetch.value + #expect((oldResponse as? HTTPURLResponse)?.statusCode == 200) + + // Other suites can emit the same phase and role through the process-wide handler. + let unrelatedURL = baseURL.appendingPathComponent("unrelated.m3u8") + SourceNetworkDiagnostics.Trace(role: .hlsPlaylist, context: replacement) + .record(.completed, originalURL: unrelatedURL, url: unrelatedURL, status: 200) + let completed = events.snapshot().filter { + $0.phase == .completed && $0.role == .hlsPlaylist + && $0.originalURL.map { fixtureURLs.contains($0) } == true + } + #expect(completed.map(\.originalURL) == [retryURL, replacementURL, oldURL]) + #expect(Set(completed.map(\.requestID)).count == 3) + for (url, expected) in [(oldURL, old), (retryURL, retry), (replacementURL, replacement)] { + let requestEvents = events.snapshot().filter { $0.originalURL == url } + #expect(requestEvents.contains { $0.phase == .response }) + #expect(requestEvents.contains { $0.phase == .completed }) + #expect(requestEvents.allSatisfy { $0.context == expected }) + #expect(Set(requestEvents.map(\.requestID)).count == 1) + } + } + + @Test("Playlist segment and key requests preserve the reader's original context and headers") + func hlsRequestsKeepCapturedContextAndHeaders() throws { + let original = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + let replacement = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + let baseURL = URL(string: "https://origin.example/live/main.m3u8")! + let headers = ["Authorization": "Bearer private-fixture-token", "User-Agent": "fixture-agent"] + let reader = SourceNetworkDiagnostics.$context.withValue(original) { + HLSLiveIngestReader(playlistURL: baseURL, httpHeaders: headers) + } + defer { reader.close() } + try SourceNetworkDiagnostics.$context.withValue(replacement) { + let fresh = try #require(reader.makeFreshMainReader()) + defer { fresh.close() } + for path in ["main.m3u8", "segment.ts", "encryption.key"] { + let url = baseURL.deletingLastPathComponent().appendingPathComponent(path) + for owner in [reader, fresh] { + let request = owner.makeRequest(url) + #expect(SourceNetworkDiagnostics.context(for: request) == original) + #expect(request.url == url) + #expect(request.httpMethod == "GET") + #expect(request.allHTTPHeaderFields?.count == headers.count) + for (name, value) in headers { #expect(request.value(forHTTPHeaderField: name) == value) } + #expect(request.value(forHTTPHeaderField: "playback_id") == nil) + #expect(request.value(forHTTPHeaderField: "attempt_id") == nil) + } + } + let crossOrigin = reader.makeRequest(URL(string: "https://cdn.example/segment.ts")!) + #expect(SourceNetworkDiagnostics.context(for: crossOrigin) == original) + #expect(crossOrigin.value(forHTTPHeaderField: "Authorization") == nil) + } + } + + @Test("Trace captures once and an unbound request cannot borrow an ambient playback") + func tracesFreezeContextAndKeepUnboundRequestsUnattributed() throws { + let events = CorrelationEvents() + let previous = SourceNetworkDiagnostics.handler + SourceNetworkDiagnostics.handler = { events.add($0) } + defer { SourceNetworkDiagnostics.handler = previous } + let original = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + let replacement = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + let url = URL(string: "https://origin.example/\(UUID().uuidString)/segment.ts")! + let trace = SourceNetworkDiagnostics.$context.withValue(original) { + SourceNetworkDiagnostics.Trace(role: .hlsSegment) + } + let tagged = SourceNetworkDiagnostics.request(URLRequest(url: url), context: original) + let unbound = SourceNetworkDiagnostics.request(tagged, context: nil) + #expect(SourceNetworkDiagnostics.context(for: tagged) == original) + #expect(SourceNetworkDiagnostics.context(for: unbound) == nil) + SourceNetworkDiagnostics.$context.withValue(replacement) { + trace.record(.response, originalURL: url, url: url, status: 200) + trace.record(.completed, originalURL: url, url: url, status: 200) + let unboundTrace = SourceNetworkDiagnostics.Trace(role: .hlsKey, + context: SourceNetworkDiagnostics.context(for: unbound)) + unboundTrace.record(.completed, originalURL: url, url: url, status: 200) + } + let unrelatedURL = url.deletingLastPathComponent().appendingPathComponent("unrelated.ts") + SourceNetworkDiagnostics.Trace(role: .hlsSegment, context: original) + .record(.completed, originalURL: unrelatedURL, url: unrelatedURL, status: 200) + let captured = events.snapshot().filter { $0.originalURL == url } + #expect(captured.count == 3) + #expect(captured[0].context == original && captured[1].context == original) + #expect(captured[0].requestID == captured[1].requestID) + #expect(captured[2].context == nil) + #expect(captured[2].requestID != captured[0].requestID) + } + + @Test("A segment carriage probe completes once with its captured context", .timeLimit(.minutes(1)), arguments: [200, 403]) + func carriageProbeCompletesWithCapturedContext(status: Int) async throws { + let events = CorrelationEvents() + let previous = SourceNetworkDiagnostics.handler + SourceNetworkDiagnostics.handler = { events.add($0) } + defer { SourceNetworkDiagnostics.handler = previous } + let body = Data([0, 1, 2, 3]) + let server = try #require(ScriptedOriginServer { _ in + .init(status: status, declaredLength: Int64(body.count), close: true, body: body) + }) + defer { server.stop() } + let session = URLSession(configuration: .ephemeral, delegate: EngineTLS.sessionDelegate, delegateQueue: nil) + defer { session.invalidateAndCancel() } + let original = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + let replacement = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + let url = URL(string: "http://127.0.0.1:\(server.port)/head.ts")! + do { + _ = try await SourceNetworkDiagnostics.$context.withValue(replacement) { + try await HLSCarriageProbe.classifySegmentHead( + url: url, httpHeaders: [:], session: session, diagnosticContext: original) + } + #expect(status == 200) + } catch { + #expect(error as? HLSIngestError == .playlistUnreachable(status: status)) + #expect(status == 403) + } + let captured = events.snapshot().filter { $0.originalURL == url } + #expect(captured.filter { $0.phase == .response }.count == 1) + #expect(captured.filter { $0.phase == .completed }.count == 1) + #expect(captured.allSatisfy { $0.context == original && $0.role == .hlsSegment && $0.status == status }) + #expect(Set(captured.map(\.requestID)).count == 1) + } + + @Test("Held HTTP bodies complete once after consumption and keep their captured owner", + .timeLimit(.minutes(1)), arguments: [200, 403]) + func heldBodyCompletion(status: Int) async throws { + let payload = Data(repeating: 0x47, count: 4096) + let server = try #require(ScriptedOriginServer { _ in + .init(status: status, declaredLength: Int64(payload.count), close: true, body: payload) + }) + defer { server.stop() } + let transport = PersistentHTTP1Transport(label: "diagnostics-test") + defer { transport.close() } + let url = URL(string: "http://127.0.0.1:\(server.port)/\(UUID().uuidString).ts")! + let events = CorrelationEvents() + let previous = SourceNetworkDiagnostics.handler + SourceNetworkDiagnostics.handler = { if $0.originalURL == url { events.add($0) } } + defer { SourceNetworkDiagnostics.handler = previous } + let owner = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + let replacement = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + let body = try await SourceNetworkDiagnostics.$context.withValue(replacement) { + try await transport.open(url, headers: [:], limit: payload.count, + diagnosticContext: owner, role: .hlsSegment) + } + if status == 200 { + #expect(!events.snapshot().contains { $0.phase == .completed }) + } + var received = Data() + while let chunk = try await body.next() { received.append(chunk) } + #expect(received == (status == 200 ? payload : Data())) + #expect(try await body.next() == nil) + body.abandon() + let captured = events.snapshot() + #expect(captured.map(\.phase) == [.response, .completed]) + #expect(captured.allSatisfy { $0.context == owner && $0.status == status && $0.role == .hlsSegment }) + #expect(Set(captured.map(\.requestID)).count == 1) + #expect(captured.last?.bytes == Int64(received.count)) + } + + @Test("Held HTTP body limits report one terminal event before or during streaming", + .timeLimit(.minutes(1)), arguments: [true, false]) + func heldBodyLimit(declaredLength: Bool) async throws { + let payload = Data(repeating: 0x47, count: 4096) + let server = try #require(ScriptedOriginServer { _ in + .init(status: 200, declaredLength: declaredLength ? Int64(payload.count) : nil, + close: true, body: payload) + }) + defer { server.stop() } + let transport = PersistentHTTP1Transport(label: "diagnostics-limit-test") + defer { transport.close() } + let url = URL(string: "http://127.0.0.1:\(server.port)/\(UUID().uuidString).ts")! + let events = CorrelationEvents() + let previous = SourceNetworkDiagnostics.handler + SourceNetworkDiagnostics.handler = { if $0.originalURL == url { events.add($0) } } + defer { SourceNetworkDiagnostics.handler = previous } + do { + _ = try await transport.get(url, headers: [:], limit: 16, role: .hlsSegment) + Issue.record("An oversized body must fail") + } catch let error as BoundedFetch.Exceeded { + #expect(error.limit == 16) + } + let captured = events.snapshot() + #expect(captured.map(\.phase) == [.response, .bodyLimitExceeded]) + #expect(captured.first?.status == 200) + #expect(captured.allSatisfy { $0.context == nil && $0.role == .hlsSegment }) + #expect(Set(captured.map(\.requestID)).count == 1) + } + + @Test("Abandoning or dropping a held HTTP body reports once without claiming success", + .timeLimit(.minutes(1)), arguments: [true, false]) + func heldBodyAbandoned(explicit: Bool) async throws { + let server = try #require(ScriptedOriginServer { _ in + .init(status: 200, declaredLength: nil, close: true, body: Data(repeating: 0x47, count: 4096)) + }) + defer { server.stop() } + let transport = PersistentHTTP1Transport(label: "diagnostics-abandon-test") + defer { transport.close() } + let url = URL(string: "http://127.0.0.1:\(server.port)/\(UUID().uuidString).ts")! + let events = CorrelationEvents() + let previous = SourceNetworkDiagnostics.handler + SourceNetworkDiagnostics.handler = { if $0.originalURL == url { events.add($0) } } + defer { SourceNetworkDiagnostics.handler = previous } + var body: PersistentHTTP1Transport.Body? = try await transport.open(url, headers: [:], limit: 8192) + if explicit { + body?.abandon() + body?.abandon() + } + body = nil + try await waitFor { events.snapshot().contains { $0.phase == .abandoned } } + #expect(events.snapshot().map(\.phase) == [.response, .abandoned]) + #expect(Set(events.snapshot().map(\.requestID)).count == 1) + } + + @Test("Bounded HLS requests report real HTTP refusals without synthetic cancellation") + func boundedResponseEvidence() async throws { + final class Events: @unchecked Sendable { + let lock = NSLock() + var values: [SourceNetworkDiagnostics.Event] = [] + func add(_ value: SourceNetworkDiagnostics.Event) { lock.withLock { values.append(value) } } + func snapshot() -> [SourceNetworkDiagnostics.Event] { lock.withLock { values } } + } + let events = Events() + let previous = SourceNetworkDiagnostics.handler + SourceNetworkDiagnostics.handler = { events.add($0) } + defer { SourceNetworkDiagnostics.handler = previous } + let server = try #require(ScriptedOriginServer { _ in .init(status: 403, declaredLength: 0) }) + defer { server.stop() } + let session = URLSession(configuration: .ephemeral, delegate: EngineTLS.sessionDelegate, delegateQueue: nil) + defer { session.invalidateAndCancel() } + let url = URL(string: "http://127.0.0.1:\(server.port)/private.m3u8")! + let (_, response) = try await BoundedPlaylistFetch.data(for: URLRequest(url: url), session: session, limit: 1024) + #expect((response as? HTTPURLResponse)?.statusCode == 403) + let terminal = try #require(events.snapshot().first { + $0.originalURL == url && $0.phase == .completed && $0.role == .hlsPlaylist + }) + #expect(terminal.status == 403) + #expect(terminal.urlErrorCode == nil) + } + + @Test("Real source response events retain status and origin", .timeLimit(.minutes(1))) + func responseEvidence() async throws { + let events = CorrelationEvents() + let previous = SourceNetworkDiagnostics.handler + SourceNetworkDiagnostics.handler = { events.add($0) } + defer { SourceNetworkDiagnostics.handler = previous } + let server = try #require(ScriptedOriginServer { _ in .init(status: 403, declaredLength: 0) }) + defer { server.stop() } + let url = URL(string: "http://127.0.0.1:\(server.port)/\(UUID().uuidString)/test.ts")! + let reader = AVIOReader(url: url, isLive: true) + try await withTaskCancellationHandler { + let result = try await ProbeTestJob { + defer { reader.markClosed(); reader.close() } + try reader.open() + }.outcome() + #expect(throws: AVIOReaderError.httpStatus(403)) { try result.get() } + } onCancel: { + reader.markClosed() + } + let response = try #require(events.snapshot().first { + $0.phase == .response && $0.status == 403 && $0.originalURL == url && $0.role == .persistentRead + }) + #expect(response.originalURL?.port == Int(server.port)) + #expect(response.url?.host == "127.0.0.1") + #expect(response.urlErrorCode == nil) + #expect(response.role == .persistentRead) + } + + @MainActor + @Test("Live initial probe preserves HTTP refusal, including uppercase HTTP", arguments: [401, 403, 404, 429, 503]) + func liveProbePreservesRefusal(status: Int) async throws { + let server = try #require(ScriptedOriginServer { _ in + .init(status: status, declaredLength: 0) + }) + defer { server.stop() } + let engine = try AetherEngine() + defer { engine.stop() } + var options = LoadOptions() + options.isLive = true + let url = URL(string: "HTTP://127.0.0.1:\(server.port)/live/test.ts")! + do { + _ = try await engine.load(url: url, options: options) + Issue.record("A refused source must fail") + } catch { + #expect(engine.errorInfo?.underlyingCode == status) + #expect(engine.errorInfo?.kind == (AVIOReader.isRateLimitStatus(status) ? .sourceRateLimited : .sourceRefused)) + #expect(!server.requests.isEmpty, "Uppercase HTTP must use the HTTP reader") + } + } + + @Test("URL errors survive wrappers without reading localized messages") + func nestedTransportCode() { + for code in [-1001, -1003, -1004, -1006, -1009, -1202, -999] { + let error = NSError(domain: "Wrapper", code: 7, userInfo: [NSUnderlyingErrorKey: URLError(URLError.Code(rawValue: code))]) + #expect(SourceNetworkDiagnostics.urlErrorCode(error) == code) + } + #expect(SourceNetworkDiagnostics.urlErrorCode(NSError(domain: "private.example", code: -1003)) == nil) + } + + @Test("NSError keeps historical case codes and carries the original FFmpeg failure separately") + func demuxCodeSurvivesBridge() { + for code: Int32 in [-1094995529, -1128613112, -1296385272, -5] { + let cases: [(DemuxerError, Int)] = [ + (.openFailed(code: code), 0), (.streamInfoFailed(code: code), 1), (.readFailed(code: code), 2) + ] + for (failure, historicalCode) in cases { + let error = failure as NSError + #expect(error.domain == "AetherEngine.DemuxerError") + #expect(error.code == historicalCode) + #expect(error.userInfo[DemuxerError.ffmpegErrorCodeKey] as? Int == Int(code)) + #expect(error.localizedDescription == failure.description) + } + } + } + + @MainActor + @Test("Live initial connection failure keeps liveSourceUnavailable and the original URL error") + func liveConnectionFailure() async throws { + let server = try #require(ScriptedOriginServer { _ in .init(status: 200, declaredLength: 0) }) + let url = URL(string: "http://127.0.0.1:\(server.port)/live/test.ts")! + server.stop() + let engine = try AetherEngine() + defer { engine.stop() } + var options = LoadOptions() + options.isLive = true + do { + _ = try await engine.load(url: url, options: options) + Issue.record("Closed source must fail") + } catch { + #expect(engine.errorInfo?.kind == .liveSourceUnavailable) + #expect(engine.errorInfo?.underlyingDomain == NSURLErrorDomain) + #expect(engine.errorInfo?.underlyingCode == NSURLErrorCannotConnectToHost) + } + } + /// In-process ranged source: exercise the reader and real URLSession delegates without a server. + private final class RangedSource: URLProtocol, @unchecked Sendable { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + let range = (request.value(forHTTPHeaderField: "Range") ?? "bytes=0-") + .dropFirst(6).split(separator: "-", omittingEmptySubsequences: false) + let start = Int(range.first ?? "0") ?? 0 + let end = min(16_383, range.count > 1 ? (Int(range[1]) ?? 16_383) : 16_383) + guard start <= end else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + let response = HTTPURLResponse(url: request.url!, statusCode: 206, httpVersion: "HTTP/1.1", + headerFields: ["Content-Range": "bytes \(start)-\(end)/16384", "Content-Length": "\(end - start + 1)"])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data((start...end).map { UInt8($0 % 251) })) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} + } + + @Test("AVIO reconnects keep the reader owner across real thread boundaries, including nil", + .timeLimit(.minutes(1)), arguments: [true, false]) + func avioReconnectRetainsCapturedContext(attributed: Bool) async throws { + let owner = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + let replacement = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + let url = URL(string: "https://\(UUID().uuidString).invalid/video.bin")! + let events = CorrelationEvents() + let previous = SourceNetworkDiagnostics.handler + SourceNetworkDiagnostics.handler = { if $0.originalURL == url { events.add($0) } } + defer { SourceNetworkDiagnostics.handler = previous } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [RangedSource.self] + let session = URLSession(configuration: config) + defer { session.invalidateAndCancel() } + // One request suppresses speculative tail prefetch; only the playback connection is exercised. + OriginRequestBudget.shared.setHostLimit(1, for: url) + defer { OriginRequestBudget.shared.setHostLimit(nil, for: url) } + let reader = SourceNetworkDiagnostics.$context.withValue(attributed ? owner : nil) { + AVIOReader(url: url, boundedInitialFetch: 4096, persistentRequestSession: session) + } + let result = try await withTaskCancellationHandler { + try await SourceNetworkDiagnostics.$context.withValue(replacement) { + try await ProbeTestJob { + defer { reader.markClosed(); reader.close() } + #expect(SourceNetworkDiagnostics.context == nil) + try reader.open() + var bytes = [UInt8](repeating: 0, count: 16) + #expect(reader.read(into: &bytes, size: 16) == 16) + #expect(bytes == (0..<16).map { UInt8($0 % 251) }) + #expect(reader.seek(offset: 8192, whence: SEEK_SET) == 8192) + #expect(reader.read(into: &bytes, size: 16) == 16) + #expect(bytes == (8192..<8208).map { UInt8($0 % 251) }) + }.outcome() + } + } onCancel: { reader.markClosed(); session.invalidateAndCancel() } + try result.get() + try await waitFor { events.snapshot().filter { $0.phase == .completed }.count >= 2 } + let captured = events.snapshot() + let responses = captured.filter { $0.phase == .response } + #expect(responses.count == 2) + #expect(Set(responses.map(\.requestID)).count == 2) + #expect(captured.allSatisfy { $0.role == .persistentRead && $0.context == (attributed ? owner : nil) }) + } + + @MainActor + @Test("Session opens and fresh reopens retain their explicitly captured owner, including nil", arguments: [true, false]) + func sessionReopensRetainContext(attributed: Bool) { + let owner = attributed ? SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) : nil + let ambient = SourceNetworkDiagnostics.Context(playbackID: UUID(), attemptID: UUID()) + SourceNetworkDiagnostics.$context.withValue(ambient) { + let session = HLSVideoEngine(url: URL(string: "https://example.invalid/video.mkv")!, diagnosticContext: owner) + #expect(session.openProfile.diagnosticContext == owner) + #expect(session.restartReopenProfile.diagnosticContext == owner) + let headers = RequestHeaderStore(["User-Agent": "test"]) + let liveReopen = session.openProfile.withRequestHeaders(headers) + #expect(liveReopen.diagnosticContext == owner) + #expect(liveReopen.requestHeaders === headers) + } + } + + @MainActor + @Test("Live startup keeps its public kind and original cause; VOD retains its own classification") + func liveStartupFailureContract() throws { + let engine = try AetherEngine() + defer { engine.stop() } + for code in [NSURLErrorTimedOut, NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost] { + let error = NSError(domain: "Wrapper", code: 1, + userInfo: [NSUnderlyingErrorKey: NSError(domain: NSURLErrorDomain, code: code)]) + engine.publishLoadFailure(error, isLiveStart: true) + #expect(engine.errorInfo?.kind == .liveSourceUnavailable) + #expect(engine.errorInfo?.underlyingDomain == NSURLErrorDomain) + #expect(engine.errorInfo?.underlyingCode == code) + engine.publishLoadFailure(error) + #expect(engine.errorInfo?.kind == .sourceOpenFailed) + } + for status in [401, 403, 404, 429, 503, 509] { + for error: any Error in [AVIOReaderError.httpStatus(status), HLSIngestError.playlistUnreachable(status: status)] { + engine.publishLoadFailure(error, isLiveStart: true) + #expect(engine.errorInfo?.kind == (AVIOReader.isRateLimitStatus(status) ? .sourceRateLimited : .sourceRefused)) + #expect(engine.errorInfo?.underlyingCode == status) + } + } + for error: any Error in [URLError(.serverCertificateUntrusted), AVIOReaderError.transportSecurityFailed(code: NSURLErrorServerCertificateUntrusted)] { + engine.publishLoadFailure(error, isLiveStart: true) + #expect(engine.errorInfo?.kind == .sourceCertificateRejected) + #expect(engine.errorInfo?.underlyingDomain == NSURLErrorDomain) + #expect(engine.errorInfo?.underlyingCode == NSURLErrorServerCertificateUntrusted) + } + engine.publishLoadFailure(DemuxerError.openFailed(code: -5), isLiveStart: true) + #expect(engine.errorInfo?.kind == .liveSourceUnavailable) + #expect(engine.errorInfo?.underlyingCode == 0) + #expect(engine.errorInfo?.ffmpegErrorCode == -5) + engine.publishLoadFailure(HLSIngestError.playlistInvalid(reason: "no segments"), isLiveStart: true) + #expect(engine.errorInfo?.kind == .liveSourceUnavailable) + #expect(engine.errorInfo?.hlsDiagnosticReason == .noSegments) + } + + @Test("Diagnostic roles and phases preserve raw tokens and accept future values") + func extensibleDiagnosticTokens() throws { + let role = SourceNetworkDiagnostics.Role(rawValue: "FutureReader") + let phase = SourceNetworkDiagnostics.Phase(rawValue: "future_phase") + #expect(try JSONDecoder().decode(SourceNetworkDiagnostics.Role.self, from: JSONEncoder().encode(role)) == role) + #expect(try JSONDecoder().decode(SourceNetworkDiagnostics.Phase.self, from: JSONEncoder().encode(phase)) == phase) + #expect(SourceNetworkDiagnostics.Role.persistentRead.rawValue == "PersistentRead") + #expect(SourceNetworkDiagnostics.Phase.bodyLimitExceeded.rawValue == "body_limit_exceeded") + #expect(SourceNetworkDiagnostics.Phase.abandoned.rawValue == "abandoned") + } + +} diff --git a/docs/api.md b/docs/api.md index 19422b03..0fc5698e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -49,7 +49,37 @@ The message inside `.error` is worth logging verbatim, and it comes from two dif So the string is a payload, not a key, and `$errorInfo` is the key. It publishes a `PlaybackErrorInfo` beside the state: a `PlaybackErrorKind` naming what failed in a form that survives a locale and a release, plus the underlying `NSError` domain and code wherever a Foundation / AVFoundation failure is involved. A non-nil `underlyingDomain` also marks the messages whose text the OS has localized. -A failed custom-source probe publishes `.customSourceProbeFailed` with the probe error's `underlyingDomain` and `underlyingCode` when available. That information is set before the terminal `.error` state is delivered, so a state observer can read the cause from `errorInfo` without parsing the message. The engine's own live HLS ingest (`HLSLiveIngestReader`, which a known HLS playlist URL on the live path loads through) is the exception: when the probe failed because the live playlist was refused, the load publishes the origin's answer the way an HTTP reader's failed open does, `.sourceRateLimited` (429/503/509) or `.sourceRefused` with the status in `underlyingCode`, and `.liveSourceUnavailable` when the playlist could not be reached at all, so a host does not read a refusal as a stream it cannot play. +A failed custom-source probe publishes `.customSourceProbeFailed` with the probe error's `underlyingDomain` and `underlyingCode` when available. That information is set before the terminal `.error` state is delivered, so a state observer can read the cause from `errorInfo` without parsing the message. The engine's own live HLS ingest (`HLSLiveIngestReader`, which a known HLS playlist URL on the live path loads through) publishes its original terminal cause: `.sourceRateLimited` (429/503/509) or `.sourceRefused` with the HTTP status in `underlyingCode`, `.sourceCertificateRejected` for TLS trust failures, or `.sourceOpenFailed` with the original URL error domain and code for other transport failures. A transport failure does not establish that the media format is unsupported. + +Outside live startup, `.hlsFormatUnsupported` identifies an HLS segment, encryption or demuxed-audio layout +the ingest cannot handle. `.hlsIngestFailed` identifies invalid playlists, invalid +responses, decryption failures or an ingest stall. Both retain the underlying +`HLSIngestError`; hosts can use the optional subreason below for diagnosis. These +kinds describe the failure and do not select a host's retry or fallback policy. +Live startup retains `.liveSourceUnavailable` except for HTTP and TLS refusals; +the original cause remains available as described under [Diagnostics](#diagnostics). + +`PlaybackErrorInfo.hlsDiagnosticReason` preserves an optional finite +`HLSIngestDiagnosticReason` from an underlying `HLSIngestError`, including through +Foundation error wrappers. `HLSIngestError.diagnosticReason` exposes the same value +directly. Missing EXTM3U, missing TARGETDURATION, an empty segment list, invalid +response/encoding, unsupported encryption and key/decryption failures have +distinct stable raw values; an unknown playlist or decryption message becomes a +finite fallback, never a new token containing that message. This adds diagnostic +detail without changing the failure's classification or recovery policy. Explicit +`HLSIngestError` NSError codes match the historical Swift bridge; its error domain +and human-readable descriptions are preserved. The message remains arbitrary payload +and is not safe structured telemetry. + +`PlaybackErrorInfo.ffmpegErrorCode` optionally carries the original negative FFmpeg +result from a demux failure, including through an NSError wrapper. It is separate +from `underlyingCode`: the historical `AetherEngine.DemuxerError` case codes remain +0 (open), 1 (stream information), and 2 (read). A thrown demux NSError carries the +same FFmpeg value under `AetherFFmpegErrorCode` in `userInfo`. Missing or invalid +metadata leaves the optional field nil; the existing error domain, message and +case code keep their meaning. Applications that need FFmpeg-specific diagnostics +or compatibility decisions should use this field instead of interpreting a case +code as an FFmpeg result. Two kinds carry a number a host will want to read, and both mean the same thing happened: the origin answered the source request with an HTTP status instead of media, and `underlyingCode` is that status. `.sourceRefused` is the origin's verdict on the resource or on itself (a 401/403 refusal, which on a connection-capped IPTV panel most often means "the slot is still held", a 404, a 5xx). `.sourceRateLimited` is the same answer in the rate-limit shapes (429/503/509), split off because the recovery differs: the source is being metered, not lost, so the same request is expected to work later and a handoff to a second player meets the same meter (AE#377). Both are distinct from `.sourceOpenFailed`, which is what a corrupt or unreadable source produces; before `.sourceRefused` existed a refusal and a corrupt file arrived alike as "Invalid data found when processing input". @@ -250,6 +280,9 @@ Two refusals, both raised BEFORE any teardown, so a refused correction leaves th | `AetherEngineError.sessionNotReloadable(.dualLayerRouteNeedsFreshLoad)` | the correction changes `preferDualLayerDolbyVision` (or `dolbyVisionHandling` while the preference is on) on a custom `IOReader` Profile 7 session. The in-place rebuild keeps the route the session was loaded with, so the change could not take effect; call `load()` again with the new option. `SessionReloadRefusal` is a public enum: this is a new case, so a host that switches over it exhaustively must handle it (a `default:` or a new `case`). | | `AetherEngineError.sessionNotReloadable(.demuxedAudioLiveIsNativeOnly)` | the correction moves a demuxed-audio live session onto the software path, where the side-audio merge does not exist, so it would play silent. | +Changing `sourceDiagnosticsContext` also throws `loadIdentityNotCorrectable` before +teardown: it identifies the original load and its requests. Pass a different +context to a fresh `load()`; a tuning correction that keeps the context is allowed. Both are all-or-nothing: a correction refused for one field installs none of it. **Where a correction lands.** A URL source rebuilds through the full `load`, so every field applies. @@ -1216,6 +1249,55 @@ as well. ## Diagnostics +A failed live start keeps `PlaybackErrorKind.liveSourceUnavailable`, including its +original URL error in `underlyingDomain` / `underlyingCode`. HTTP refusals remain +`sourceRefused` / `sourceRateLimited`, and TLS trust failures remain +`sourceCertificateRejected`. Preserving a cause does not reclassify a dead live +channel as a VOD `sourceOpenFailed`. `HLSIngestError.playlistUnreachable(status:)` +also carries refused segment or AES key requests; its historical name and +NSError case code are retained. + +`SourceNetworkDiagnostics.handler` optionally observes real source-request events. +It is a process-wide observer and runs on the emitting thread, so a host callback +must be thread-safe and non-blocking. Events from simultaneous players can interleave; +filter by captured context or request identity rather than arrival order. `SourceNetworkDiagnostics.Event` carries a per-request `requestID`, +`role`, `phase`, status/URL error code, byte/timing metrics and optional `context`. +All event fields are immutable. `SourceNetworkDiagnostics.Role` and +`SourceNetworkDiagnostics.Phase` are string-backed structs with stable `rawValue` +tokens and `init(rawValue:)` for future values. Roles are `persistentRead` +(`PersistentRead`), `chunkFetch` (`ChunkFetch`), `streaming` (`Streaming`), +`probe` (`Probe`), `tailPrefetch` (`TailPrefetch`), `hlsPlaylist` (`HLSPlaylist`), +`hlsSegment` (`HLSSegment`), `hlsKey` (`HLSKey`) and `boundedBody` (`BoundedBody`). +Phases are `response`, `redirect`, `completed`, `metrics`, `bodyLimitExceeded` +(`body_limit_exceeded`) and `abandoned`. Hosts should handle unknown values. +It does not contain response bodies, header values or localized errors. Its +`originalURL` and `url` are raw source URLs for host-side correlation: the host must +redact them before persistence or forwarding and must choose its own logging and +retention policy. Installing a handler does not change request or TLS policy. + +For source ownership, set `LoadOptions.sourceDiagnosticsContext` to a +`SourceNetworkDiagnostics.Context(playbackID:attemptID:)` before `load()`. Both +UUIDs are opaque host-owned identifiers; the engine neither creates a host session +nor sends the identifiers in HTTP headers or URLs. The optional task-local +`SourceNetworkDiagnostics.context` supplies the same context while constructing a +standalone `HLSLiveIngestReader(playlistURL:httpHeaders:diagnosticContext:)` or +other instrumented reader. An explicit `diagnosticContext` selects that reader's +context instead. AVIO readers capture it at initialization and pass it explicitly +to probes, ranged reads, reconnects, chunk fetches, streaming and tail prefetch, +including requests started on detached or FFmpeg threads. HLS playlist, segment, key and carriage-probe requests capture +their context at creation, retain it through redirects/completion and retain the +source's context across internal reader reloads. A late event keeps the outgoing +attempt's identifiers. A request without context remains unattributed; a handler +must not fill it from whichever player happens to be active when it arrives. +The context is load identity: `reloadAtCurrentPosition(applying:)` refuses a changed +context before teardown, keeping the running session and its attribution intact. +The held HTTP/1 HLS transport emits the same event contract: a streamed body emits +`completed` only when it ends or fails, `body_limit_exceeded` when its bound is +exceeded, and `abandoned` when the consumer drops or abandons it. The byte count +excludes bytes discarded by a background drain. AVPlayer's own internal +network requests are not exposed, and a source path without instrumentation or +captured context cannot be attributed by this API. + `LoadOptions.nativeVideoFrameDiagnostics` opts into decoded-frame observation on SDR native loopback playback. It defaults to `false`; remote bypass, HDR, external playback, mobile background activity, PiP (including its foreground transition), pause and seek are excluded. An additional `AVPlayerItemVideoOutput` @@ -1289,6 +1371,7 @@ All flags default to safe values; the table is the full set. Depth for the media | Option | Default | What it does | | --- | --- | --- | +| `sourceDiagnosticsContext` | nil | Optional opaque `SourceNetworkDiagnostics.Context` retained by AVIO/HLS requests and internal reloads for host-side diagnostics. No wire headers, request-policy or playback effect. | | `httpHeaders` | empty | Extra headers on every probe, range and segment fetch. On `nativeRemoteHLS` they ride into the `AVURLAsset`, so header-enforcing IPTV origins work. Forwarded to sidecar subtitle fetches unless overridden. **Scoping differs by route.** On the plain `nativeRemoteHLS` bypass AVFoundation itself sends them to every host the playlists name (cross-host variants, segments and `EXT-X-KEY` URIs) and carries them across 302 redirects, so credential headers there reach every such host and the engine cannot narrow that. The per-origin credential scoping described for `HLSLiveIngestReader` applies only on the routes the engine fetches itself: the ingest, the disc reader, the origin relay, the subtitle proxy, the audio tap and the live subtitle renditions. A credential that must not reach every host a playlist names should not ride in `httpHeaders` on the bypass. | | `isLive` | false | Treat the source as live. Set it explicitly; duration-based auto-detection is too noisy. | | `dvrWindowSeconds` | nil | Timeshift window. nil means live-only and `seek` is a no-op. The window is a ceiling: the disk budget (a quarter of the free space, at most 2 GiB) bounds what is actually kept, so a long window on a high-bitrate channel or a small volume holds less than it asks for. With several engines running, each one's quarter is taken from the free space the others have not already claimed, so a tile added on a tight volume holds less history than the first one (AE#687). | @@ -1342,7 +1425,7 @@ All flags default to safe values; the table is the full set. Depth for the media | `sequentialOrigin` | false | Declare an origin that fabricates range answers: one long-lived unranged GET, no ranged probes, non-seekable pb. **Seeking is unavailable**; re-request the archive at a shifted start instead. A URL source the reader finds forward-only by itself (`Range` ignored, so it can only stream front to back; the open confirms a 200 answer with a one byte range, because some CDNs answer a cache miss that way) is served this way without the declaration when its container states a duration, which a stated `Content-Length` lets an MPEG-TS source estimate; `loadedOptions` then reads `true` with that duration declared, and without a duration it stays on the software path. An AirPlay hop on such a session swaps the item onto the LAN address instead of reopening the source, since a reopen could only start again from byte 0. | | `declaredDurationSeconds` | nil | Trusted duration, overriding the container's. Required alongside `sequentialOrigin` on VOD, where the tail read is gone. | | `maxConcurrentSourceRequests` | nil | Most requests the reader may have open against this origin at once, across every path it fetches on (pump ranges, detour blocks, size probes, tail prefetch, subtitle side reader, and the HLS live ingest's playlist, segment and key fetches, whose prefetch window narrows to it, AE#678). nil counts without capping and lowers the ceiling on its own after a 429/503/509. Set it when the provider states a limit; `1` also switches off the speculative parallel paths, which exist only to overlap with the pump. Counts **requests**, not TCP connections, because over HTTP/2 a session multiplexes every request onto one connection while the origin still counts each one (AE#377). It is also the only ceiling: several engines playing from one origin are bounded by this value and by what the origin refuses, not by a transport pool underneath it (AE#450). | -| `sourceOpenPolicy` | `SourceOpenPolicy()` | HTTP VOD opening budgets for the playback reader and its reopens: `firstByteTimeout` (15 s) bounds the first data wait, `sizeProbeTimeout` (25 s) bounds the one retry and the whole size discovery, slot waits included. Nonpositive or nonfinite values take the defaults, anything above 120 s is capped. A request that gets no response is retried once with an open-ended Range and that connection stays the playback connection; two unanswered requests fail as `.sourceOpenFailed` instead of demoting the source to forward-only. A real length-less or Range-ignoring response keeps the ordinary sequential handling. Single-request origins run the size probes serially, others race them and join the losers before playback starts. Live, `sequentialOrigin`, remote HLS and disposable probes keep their own budgets. | +| `sourceOpenPolicy` | `SourceOpenPolicy()` | HTTP VOD opening budgets for the playback reader and its reopens: `firstByteTimeout` (15 s) bounds the first data wait, `sizeProbeTimeout` (25 s) bounds the one retry and the whole size discovery, slot waits included. Nonpositive or nonfinite values take the defaults, anything above 120 s is capped. A request that gets no response is retried once with an open-ended Range and that connection stays the playback connection; two unanswered requests fail as `.sourceOpenFailed` instead of demoting the source to forward-only. A real length-less or Range-ignoring response keeps the ordinary sequential handling. Single-request origins run the size probes serially, others race them and join the losers before playback starts. When an open retires a failed ranged connection and switches to forward-only playback, the retired connection's transport error cannot fail the replacement; a refusal or transport failure from the replacement itself still reaches the host. Live, `sequentialOrigin`, remote HLS and disposable probes keep their own budgets. | | `heldSourceConnection` | false | Ask the source **once** and pull it, instead of ending the connection at the reader's window high water and asking again every 8 to 16 MB of drain (AE#377). For an origin that punishes repeated requests rather than concurrency: some CDNs refuse new requests for minutes at a stretch while serving an already open connection at full rate, and against one of those the request cadence is the defect, at any range size (measured at the reporting origin: 32 MB ranges raised to 256 MB, eight times fewer requests, the refusals unchanged). Where `maxConcurrentSourceRequests` bounds how many requests are in flight, this removes the second request. The read path is the engine's own HTTP/1.1 over a demand-driven stream task, so: **HTTP/1.1 only** (no ALPN, an HTTP/2-only origin is out of scope), the **system proxy configuration does not apply**, and TLS is the OS's through the same host trust decision as every other engine session but has not been exercised against a self-signed origin. A viewer who pauses ends the connection after five seconds and resuming costs one request at the frontier, because a held flow nobody reads is the process-wide Network.framework starvation of AE#310. Applies to the playback reader; the subtitle and enrichment side readers keep the default transport, since they park for minutes at a time. The open-time detection of an origin that ignores `Range` (see `sequentialOrigin`) does not cover it, because its open is open-ended. A finite HLS VOD on the HEVC-in-MPEG-TS ingest (AE#268) is covered too (AE#732): playlist, key and segment requests run over one persistent HTTP/1.1 connection per origin, reused across segments and fetched one at a time, for origins that refuse h2 or new connections but serve consecutive requests on one keep-alive connection. Each body is streamed into the ingest as it arrives. A full FIFO lets the current body finish but admits no next segment or key request until consumption makes room; seek, close or cancellation retires that wait. Only the wait for a response head has a wall-clock ceiling (45 s); a body ends on its size limit or on 30 s without a byte, never on its duration. A new connection opens only on a server close, an origin change, or a seek that abandons a body with more than 2 MB left or too slow to arrive within 3 s (otherwise the remainder is drained and the connection kept, with the next request waiting for it), and the log names each connection's protocol and request count. **Names the session**: the transport is chosen at open, so a reload that changes it is refused. | | `autoplay` | true | False mounts paused: the load skips the terminal `play()` and settles at `.paused` for a host that resumes later. It describes THIS MOUNT and nothing after it: the rebuilds a session makes on its own (`reloadAtCurrentPosition`, an option correction, the AirPlay LAN swap, an audio-delay nudge) come back in the transport state the session is in, not in this one. Correcting it through `reloadAtCurrentPosition(applying:)` therefore does nothing (the log names it as not applied rather than as applied, AE#464 round 3, and the call returns it as `sessionOwned` with `rebuilt` false rather than spending a teardown on it, AE#464 round 4); call `play()` or `pause()` instead. | From 7d643f26a7eacd961f6d7be28029b388012cca72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Okan=20Yu=CC=88cel?= Date: Thu, 22 Oct 2026 00:13:59 +0300 Subject: [PATCH 2/2] test(diagnostics): drop the stream after the reader opens --- .../StreamingBufferBoundTests.swift | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/Tests/AetherEngineTests/StreamingBufferBoundTests.swift b/Tests/AetherEngineTests/StreamingBufferBoundTests.swift index 93b56c38..a2ca9ded 100644 --- a/Tests/AetherEngineTests/StreamingBufferBoundTests.swift +++ b/Tests/AetherEngineTests/StreamingBufferBoundTests.swift @@ -61,18 +61,31 @@ struct StreamingBufferBoundTests { } @Test("a dropped connection is EIO without a cause of ours", .timeLimit(.minutes(1))) - func droppedConnectionHasNoCause() throws { + func droppedConnectionHasNoCause() async throws { let total: Int64 = 8 * 1024 * 1024 let maybe = ThrottledOriginServer(totalSize: total, throttleUs: 0, - respond: { _, _, _ in .serveThenDrop(afterBytes: 1024 * 1024) }) + respond: { _, _, _ in .serveThenGoSilent(afterBytes: 1024 * 1024) }) let server = try #require(maybe) defer { server.stop() } let reader = AVIOReader(url: URL(string: "http://127.0.0.1:\(server.port)/archive.ts")!, sequentialOnly: true) - defer { reader.markClosed(); reader.close() } - try reader.open() - - let result = drain(reader) + let result = try await withTaskCancellationHandler { + try await ProbeTestJob { + defer { reader.markClosed(); reader.close() } + try reader.open() + var firstByte: UInt8 = 0 + let firstRead = withUnsafeMutablePointer(to: &firstByte) { reader.read(into: $0, size: 1) } + try #require(firstRead == 1) + // Drop only after open succeeds: an earlier loss legitimately fails open with + // its URL error instead of exercising the established stream's EIO contract. + server.stop() + return drain(reader) + }.outcome().get() + } onCancel: { + reader.markClosed() + server.stop() + } + #expect(result.read < total) #expect(result.last == FFmpegErr.eio) #expect(reader.lastReadFailure == nil, "a lost connection is the network's, not the origin's flow control") }