From 62591b0108621a71c372d5f8f91ed847460cacc4 Mon Sep 17 00:00:00 2001 From: Brandon Moore <16313090+thatcube@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:23:26 -0400 Subject: [PATCH 1/4] feat(probe): validate HDR10+ metadata and add shared probe controls Add opt-in whole-probe input, packet and deadline limits with caller cancellation for URL and custom readers. Validate codec metadata framing and ST 2094-40 payloads before confirming HDR10+, preserving Dolby Vision precedence and positive-only detail semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 14 +- Sources/AetherEngine/AetherEngine+Probe.swift | 160 ++- .../Audio/AtmosDetectionProbe.swift | 20 +- Sources/AetherEngine/Demuxer/AVIOReader.swift | 67 +- Sources/AetherEngine/Demuxer/Demuxer.swift | 22 +- Sources/AetherEngine/IO/IOReader.swift | 4 + Sources/AetherEngine/IO/ProbeIOReader.swift | 66 + Sources/AetherEngine/ProbeControl.swift | 259 ++++ .../Video/HDR10PlusDetectionProbe.swift | 61 +- .../Video/HDR10PlusMetadataScan.swift | 383 +++++- .../Video/HLSSegmentProducer.swift | 4 +- .../AtmosDetectionProbeIntegrationTests.swift | 16 +- .../DocumentedConstantsTests.swift | 11 + .../HDR10PlusDetectionOptionsTests.swift | 32 + .../HDR10PlusMetadataScanTests.swift | 472 +++++-- .../HDR10PlusProbeIntegrationTests.swift | 128 +- .../AetherEngineTests/ProbeControlTests.swift | 1166 +++++++++++++++++ .../PublicAPIDocumentationTests.swift | 1 + .../Support/ProbeControlTestSupport.swift | 306 +++++ .../Support/ProbeHTTPTestOrigin.swift | 250 ++++ docs/api.md | 58 +- docs/architecture.md | 3 +- docs/formats.md | 10 +- 23 files changed, 3291 insertions(+), 222 deletions(-) create mode 100644 Sources/AetherEngine/IO/ProbeIOReader.swift create mode 100644 Sources/AetherEngine/ProbeControl.swift create mode 100644 Tests/AetherEngineTests/ProbeControlTests.swift create mode 100644 Tests/AetherEngineTests/Support/ProbeControlTestSupport.swift create mode 100644 Tests/AetherEngineTests/Support/ProbeHTTPTestOrigin.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a12cf0c38..c51b8b917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,19 @@ the public-API contract. ## [Unreleased] -_Nothing yet._ +### Added + +- Optional `ProbeLimits` and `ProbeCancellation` on URL/custom metadata and HDR10+/Atmos detail + probes. Input and monotonic time limits cover opening, stream analysis, seeks and both passes; + cancellation reaches HTTP reads and cooperating custom readers. A whole-probe stop throws without + a partial result, and caller-owned readers are never closed. Existing calls keep their open policy. + +### Fixed + +- HDR10+ confirmation validates codec metadata structures and the registered ST 2094-40 payload + instead of matching a marker anywhere in compressed bytes. Playback and probing share the validator; + Dolby Vision stays primary. Per-pass byte limits now reject oversized packets before inspection or + decode, and a result arriving after the pass deadline cannot confirm metadata. ## [7.9.0] - 2026-09-20 diff --git a/Sources/AetherEngine/AetherEngine+Probe.swift b/Sources/AetherEngine/AetherEngine+Probe.swift index 0da5060ed..e685503a4 100644 --- a/Sources/AetherEngine/AetherEngine+Probe.swift +++ b/Sources/AetherEngine/AetherEngine+Probe.swift @@ -9,36 +9,35 @@ extension AetherEngine { // MARK: - Probe - /// One-shot container + stream metadata read; no HLS server or decoders. Network sources pull a HEAD probe + small initial range (typically a few MB). File sources read directly via FFmpeg's file protocol. + /// One-shot container + stream metadata read; no HLS server or detail-pass decoders. + /// Omit `limits` and `cancellation` to retain the existing open path. Opting in controls the entire + /// probe, starting before source I/O; see `ProbeLimits` and `ProbeCancellation`. /// /// - Parameters: /// - url: Media source (`file://`, `http://`, or `https://`). /// - options: Forwarded for `httpHeaders` only; other flags ignored (no playback session). - /// - Throws: Any error the demuxer raises during open / probe. + /// - limits: Optional shared input, packet and monotonic time limits. + /// - cancellation: Optional one-shot token, callable from another thread or a task cancellation handler. + /// - Throws: Open errors, `ProbeError` for a controlled stop, or `CancellationError`. public nonisolated static func probe( url: URL, - options: LoadOptions = .init() + options: LoadOptions = .init(), + limits: ProbeLimits? = nil, + cancellation: ProbeCancellation? = nil ) throws -> SourceProbe { - try probe(source: .url(url), options: options) + try probe(source: .url(url), options: options, detecting: [], + limits: limits, cancellation: cancellation) } /// `probe(url:)` for a custom byte source (AetherEngine#27). Caller retains reader ownership; cursor is left at an unspecified position and `close()` is NOT called. Pass a fresh (or rewound) reader to `load(source:)` afterwards. `SourceProbe.url` is `aether-custom://source` for custom readers. public nonisolated static func probe( source: MediaSource, - options: LoadOptions = .init() + options: LoadOptions = .init(), + limits: ProbeLimits? = nil, + cancellation: ProbeCancellation? = nil ) throws -> SourceProbe { - let demuxer = Demuxer() - let displayURL: URL - switch source { - case .url(let u): - try demuxer.open(url: u, extraHeaders: options.httpHeaders) - displayURL = u - case .custom(let reader, let formatHint): - try demuxer.open(reader: reader, formatHint: formatHint) - displayURL = URL(string: "aether-custom://source")! - } - defer { demuxer.close() } - return makeSourceProbe(demuxer: demuxer, displayURL: displayURL) + try probe(source: source, options: options, detecting: [], + limits: limits, cancellation: cancellation) } // MARK: - Bounded, opt-in Atmos/JOC detail probe @@ -54,8 +53,7 @@ extension AetherEngine { /// host specifically needs an authoritative "Dolby Atmos" badge (e.g. a details screen): it is strictly /// more expensive than `probe(url:)` (it opens a real EAC3 decoder and decodes at least one frame) and /// MUST NOT be used on the playback-start critical path. `probe(url:)` / `probe(source:)` themselves are - /// completely unmodified by this API and remain byte-for-byte the same lightweight demux-only probe -- - /// this is an additive, separate entry point, not a flag on the existing one. + /// demux-only unless detail detection is requested. Whole-probe controls are separately opt-in. /// /// The decode pass is bounded by `atmosDetection` (packet-count / byte / wall-clock caps -- see /// `AtmosDetectionOptions`): it stops at the first successfully decoded audio frame, or at whichever cap @@ -74,15 +72,18 @@ extension AetherEngine { /// packets / 8 MiB / 2 s wall clock (soft -- see `AtmosDetectionOptions` doc for the same /// AVIO-blocking caveat `Demuxer.seekBounded` already documents: a single blocking `av_read_frame()` /// on a stalled remote socket can still run past the wall-clock budget before the next check fires). - /// - Throws: Any error the demuxer raises during open / probe -- identical to `probe(url:)`. Decode-side + /// - Throws: Open errors, `ProbeError` for whole-probe stops, or `CancellationError`. Decode-side /// failures (bad EAC3 extradata, no decoder built, a malformed frame, EOF before any frame decodes) are /// NEVER thrown; they only affect whether Atmos gets confirmed. public nonisolated static func probeDetectingAtmos( url: URL, options: LoadOptions = .init(), - atmosDetection: AtmosDetectionOptions = .init() + atmosDetection: AtmosDetectionOptions = .init(), + limits: ProbeLimits? = nil, + cancellation: ProbeCancellation? = nil ) throws -> SourceProbe { - try probe(source: .url(url), options: options, detecting: .atmos, atmosDetection: atmosDetection) + try probe(source: .url(url), options: options, detecting: .atmos, atmosDetection: atmosDetection, + limits: limits, cancellation: cancellation) } /// `probeDetectingAtmos(url:)` for a custom byte source. Same reader-ownership contract as `probe(source:)`: @@ -90,9 +91,12 @@ extension AetherEngine { public nonisolated static func probeDetectingAtmos( source: MediaSource, options: LoadOptions = .init(), - atmosDetection: AtmosDetectionOptions = .init() + atmosDetection: AtmosDetectionOptions = .init(), + limits: ProbeLimits? = nil, + cancellation: ProbeCancellation? = nil ) throws -> SourceProbe { - try probe(source: source, options: options, detecting: .atmos, atmosDetection: atmosDetection) + try probe(source: source, options: options, detecting: .atmos, atmosDetection: atmosDetection, + limits: limits, cancellation: cancellation) } // MARK: - Bounded, opt-in detail probe @@ -107,9 +111,9 @@ extension AetherEngine { /// - **HDR10+**, whose ST 2094-40 metadata rides an in-band ITU-T T.35 SEI that no demuxer parses /// (`.hdr10Plus`, see `HDR10PlusDetectionOptions`). /// - /// Both cost reads past `avformat_find_stream_info`, which is why `probe(url:)` does neither and stays - /// byte-for-byte what it was. Neither is for the playback-start critical path. Asking for both runs both - /// over one open, one connection: the HDR10+ scan first, out of the packets `find_stream_info` already + /// Both cost reads past `avformat_find_stream_info`, which is why `probe(url:)` does neither. + /// Neither is for the playback-start critical path. Asking for both runs both + /// over one demuxer: the HDR10+ scan first, out of the packets `find_stream_info` already /// queued, then the queue-flushing seek the Atmos decode pass needs. /// /// Both passes are additive and one-directional. They can only ever SET `isAtmos` / @@ -119,20 +123,25 @@ extension AetherEngine { /// - Parameters: /// - url: Media source, forwarded verbatim to `probe(url:)`. /// - options: Forwarded verbatim to `probe(url:)` (`httpHeaders` only). - /// - detecting: Which extra passes to run. Empty is exactly `probe(url:)`. + /// - detecting: Which extra passes to run. Empty is the header probe with the same controls. /// - atmosDetection: Bounds + optional track override for the Atmos decode pass. Ignored without `.atmos`. /// - hdr10PlusDetection: Bounds for the HDR10+ scan. Ignored without `.hdr10Plus`. - /// - Throws: Only what `probe(url:)` throws (demuxer open / probe). Pass-side failures are never thrown: - /// they only mean a detail stays unconfirmed. + /// - limits: Whole-probe limits, shared across open, stream analysis, seeks and both passes. + /// - cancellation: Cancellation reaches HTTP I/O or the custom reader's `cancel()`. + /// - Throws: Open errors, `ProbeError` for whole-probe stops, or `CancellationError`. Ordinary + /// pass-side failures and per-pass caps only mean a detail stays unconfirmed. public nonisolated static func probe( url: URL, options: LoadOptions = .init(), detecting: ProbeDetail, atmosDetection: AtmosDetectionOptions = .init(), - hdr10PlusDetection: HDR10PlusDetectionOptions = .init() + hdr10PlusDetection: HDR10PlusDetectionOptions = .init(), + limits: ProbeLimits? = nil, + cancellation: ProbeCancellation? = nil ) throws -> SourceProbe { try probe(source: .url(url), options: options, detecting: detecting, - atmosDetection: atmosDetection, hdr10PlusDetection: hdr10PlusDetection) + atmosDetection: atmosDetection, hdr10PlusDetection: hdr10PlusDetection, + limits: limits, cancellation: cancellation) } /// `probe(url:detecting:)` for a custom byte source (SMB, WebDAV, a disc image, anything behind an @@ -143,19 +152,64 @@ extension AetherEngine { options: LoadOptions = .init(), detecting: ProbeDetail, atmosDetection: AtmosDetectionOptions = .init(), - hdr10PlusDetection: HDR10PlusDetectionOptions = .init() + hdr10PlusDetection: HDR10PlusDetectionOptions = .init(), + limits: ProbeLimits? = nil, + cancellation: ProbeCancellation? = nil ) throws -> SourceProbe { + let control = try limits != nil || cancellation != nil + ? ProbeControl(limits: limits, cancellation: cancellation) : nil let demuxer = Demuxer() + demuxer.probeControl = control + var ownedReader: IOReader? + func closeInput() { + demuxer.close() + ownedReader?.close() + ownedReader = nil + } + defer { + closeInput() + // Native calls have ended; join interruption callbacks before the host can reuse its reader. + control?.finish() + } let displayURL: URL - switch source { - case .url(let u): - try demuxer.open(url: u, extraHeaders: options.httpHeaders) - displayURL = u - case .custom(let reader, let formatHint): - try demuxer.open(reader: reader, formatHint: formatHint) - displayURL = URL(string: "aether-custom://source")! + do { + try control?.check() + switch source { + case .url(let u): + displayURL = u + if let control { + let reader: IOReader + if u.isFileURL { + guard let file = FileIOReader(url: u) else { throw DemuxerError.openFailed(code: -1) } + reader = file + } else if ["http", "https"].contains(u.scheme?.lowercased() ?? "") { + reader = ProbeHTTPReader(url: u, headers: options.httpHeaders, control: control) + } else { + throw ProbeError.unsupportedURL + } + ownedReader = reader + let counted = ProbeIOReader(reader: reader, control: control) + try control.check() + if let http = reader as? ProbeHTTPReader { try http.open() } + try control.check() + try demuxer.open(reader: counted, profile: probeOpenProfile(limits: limits)) + } else { + try demuxer.open(url: u, extraHeaders: options.httpHeaders) + } + case .custom(let reader, let formatHint): + displayURL = URL(string: "aether-custom://source")! + if let control { + try demuxer.open(reader: ProbeIOReader(reader: reader, control: control), + formatHint: formatHint, profile: probeOpenProfile(limits: limits)) + } else { + try demuxer.open(reader: reader, formatHint: formatHint) + } + } + try control?.check() + } catch { + try control?.check() + throw error } - defer { demuxer.close() } var probe = makeSourceProbe(demuxer: demuxer, displayURL: displayURL) @@ -165,12 +219,18 @@ extension AetherEngine { if detecting.contains(.hdr10Plus) { let outcome = Self.detectHDR10Plus( demuxer: demuxer, videoIndex: demuxer.videoStreamIndex, options: hdr10PlusDetection) + try control?.check() if outcome.carriesHDR10Plus { probe = Self.enrichHDR10Plus(base: probe) } } - if detecting.contains(.atmos) { + let targetIndex = Self.atmosDecodeTargetIndex( + options: atmosDetection, defaultAudioStreamIndex: demuxer.audioStreamIndex) + if detecting.contains(.atmos), + demuxer.stream(at: targetIndex)?.pointee.codecpar.pointee.codec_id == AV_CODEC_ID_EAC3, + !probe.audioTracks.contains(where: { $0.id == Int(targetIndex) && $0.isAtmos }) { + try control?.check() // Flush what is still queued before the decode pass starts. Those packets were read before // `detectAtmos` sets AVDISCARD_ALL, so libavformat hands them back regardless of the hint: on a // source whose audio does not sit at the head, the pass burns its whole foreign-packet fuse on @@ -179,17 +239,29 @@ extension AetherEngine { // A source that cannot seek is no worse off than before. (It also puts a source the HDR10+ scan // has just walked back at the start.) demuxer.seekBounded(to: 0, timeout: Self.atmosProbeFlushSeekTimeout) - let targetIndex = Self.atmosDecodeTargetIndex( - options: atmosDetection, defaultAudioStreamIndex: demuxer.audioStreamIndex) + try control?.check() let outcome = Self.detectAtmos(demuxer: demuxer, targetIndex: targetIndex, options: atmosDetection) + try control?.check() if outcome.confirmedAtmos { probe = Self.enrichAtmos(base: probe, confirmedTrackID: Int(targetIndex)) } } + closeInput() + try control?.complete() return probe } + private nonisolated static func probeOpenProfile(limits: ProbeLimits?) -> DemuxerOpenProfile { + guard let limits else { return .playback } + var profile = DemuxerOpenProfile.stillExtraction + // FFmpeg has a minimum probe size. The input seam still enforces smaller caller limits. + profile.probesize = max(32, min(profile.probesize, limits.maxInputBytes)) + profile.auditsRecordlessDolbyVision = false + profile.readerLabel = "probe" + return profile + } + /// Flip `isAtmos` to `true` on exactly the one confirmed audio track, leaving everything else identical. /// /// Mutates copies rather than rebuilding the structs field by field: both memberwise inits carry defaulted diff --git a/Sources/AetherEngine/Audio/AtmosDetectionProbe.swift b/Sources/AetherEngine/Audio/AtmosDetectionProbe.swift index 5e6a9cee7..4d75fd669 100644 --- a/Sources/AetherEngine/Audio/AtmosDetectionProbe.swift +++ b/Sources/AetherEngine/Audio/AtmosDetectionProbe.swift @@ -25,8 +25,8 @@ public struct AtmosDetectionOptions: Sendable, Equatable { /// empty-audio source. public var maxPackets: Int - /// Stop after this many cumulative packet bytes, independent of packet count. Guards a stream with - /// abnormally large packets from exhausting the `maxPackets` budget slowly. Default 8 MiB. + /// Maximum cumulative bytes offered to the decoder. A packet exceeding the remaining allowance is + /// rejected before decode. This is not an input-read or native allocation ceiling. Default 8 MiB. public var maxBytes: Int64 /// Soft wall-clock budget checked BETWEEN packet reads. This is NOT preemptive: a single blocking @@ -247,10 +247,21 @@ extension AetherEngine { guard let pkt = packet else { return stopped(.demuxEOF) } + let afterRead = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 + if afterRead >= options.timeBudget { + av_packet_unref(pkt) + av_packet_free_safe(pkt) + return stopped(.timeCap) + } var confirmed = false packetsSeen += 1 if pkt.pointee.stream_index == targetIndex { + guard Int64(pkt.pointee.size) <= options.maxBytes - bytesRead else { + av_packet_unref(pkt) + av_packet_free_safe(pkt) + return stopped(.byteCap) + } // Charge the decode budget only for packets actually offered to the decoder, so a coarse // interleave or a large leading video run can never starve the probe of audio. packetsRead += 1 @@ -272,6 +283,11 @@ extension AetherEngine { av_packet_free_safe(pkt) if confirmed { + let elapsed = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 + guard elapsed < options.timeBudget else { + return AtmosDetectionOutcome(stopReason: .timeCap, packetsRead: packetsRead, + bytesRead: bytesRead, decodedProfile: nil) + } return AtmosDetectionOutcome( stopReason: .frameDecoded, packetsRead: packetsRead, bytesRead: bytesRead, decodedProfile: lastProfile diff --git a/Sources/AetherEngine/Demuxer/AVIOReader.swift b/Sources/AetherEngine/Demuxer/AVIOReader.swift index 8b718d05c..3d0c16db6 100644 --- a/Sources/AetherEngine/Demuxer/AVIOReader.swift +++ b/Sources/AetherEngine/Demuxer/AVIOReader.swift @@ -1012,8 +1012,11 @@ 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 + /// Only static controlled probes use this; playback retains its existing transport policy. + private let probeControl: ProbeControl? - 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, heldConnection: Bool = 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, heldConnection: Bool = false, probeControl: ProbeControl? = nil) { + self.probeControl = probeControl self.url = url self.label = label self.extraHeaders = extraHeaders @@ -1056,6 +1059,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { } func open() throws { + try probeControl?.check() guard let buf = av_malloc(Int(Self.avioBufferSize)) else { throw AVIOReaderError.allocationFailed } @@ -1187,6 +1191,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { // resilience to all of those cases (issue #70 review #1/#3/#4). EngineLog.emit("[AVIOReader] Data connection resolved no size, falling back to probe", category: .demux, level: .verbose) fileSize = resolveInitialFileSize() + try probeControl?.check() } if isStreaming { startStreamingDownload() @@ -3348,8 +3353,17 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { // function does not return until the transfer ends. A streaming-mode source has no detour // or ranged probe to starve (they are all switched off on this path), so a held slot here // blocks nothing but a second reader on the same origin, which is the point. - let streamTicket = OriginRequestBudget.shared.acquire( - for: request.url ?? url, label: "\(label) stream", timeout: Self.pumpSlotWaitSeconds) + let streamTicket: OriginRequestBudget.Ticket? + do { + streamTicket = try requestTicket( + for: request.url ?? url, label: "\(label) stream", timeout: Self.pumpSlotWaitSeconds) + } catch { + streamLock.lock() + streamEnded = true + streamLock.unlock() + streamDataReady.signal() + return + } defer { OriginRequestBudget.shared.release(streamTicket) } let semaphore = DispatchSemaphore(value: 0) @@ -3618,6 +3632,15 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { } private func probeFileSize() -> Int64 { + if let probeControl { + // No staggered worker outlives a one-shot probe, and no fallback starts after a stop. + if let size = rangeProbeFileSize(range: "bytes=0-"), size > 0 { return size } + guard !probeControl.isStopped else { return -1 } + let head = headProbeFileSize() + if head > 0 { return head } + guard !probeControl.isStopped else { return -1 } + return rangeProbeFileSize(range: "bytes=0-1") ?? -1 + } // Staggered-concurrent ladder (#107 follow-up). The probes themselves are unchanged: // Range bytes=0- primary (AetherEngine#8: HEAD breaks on Cloudflare-fronted origins // returning 405), HEAD for live-transcode endpoints that reject Range, and the #126 @@ -3697,9 +3720,14 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { // on a metered origin is three requests where one was refused. The budget serialises them // (each waits its short slot, then proceeds), so the fan keeps its latency win on a healthy // origin and stops being a burst on a capped one. - let ticket = OriginRequestBudget.shared.acquire( - for: request.url ?? url, label: "\(label) size probe", - timeout: Self.shortFetchSlotWaitSeconds) + let ticket: OriginRequestBudget.Ticket? + do { + ticket = try requestTicket( + for: request.url ?? url, label: "\(label) size probe", + timeout: Self.shortFetchSlotWaitSeconds) + } catch { + return nil + } defer { OriginRequestBudget.shared.release(ticket) } let delegate = ProbeDelegate(extraHeaders: extraHeaders) @@ -3927,12 +3955,37 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { /// last reset. Written on the delegate queue, read once the fetch it belongs to has completed. nonisolated(unsafe) static var peakBodyReserveForTesting = 0 + private func requestTicket(for url: URL, label: String, + timeout: TimeInterval) throws -> OriginRequestBudget.Ticket? { + guard let probeControl else { + return OriginRequestBudget.shared.acquire(for: url, label: label, timeout: timeout) + } + guard !isClosed else { throw CancellationError() } + try probeControl.check() + guard let ticket = OriginRequestBudget.shared.tryAcquire(for: url, label: label) else { + probeControl.stop(ProbeError.sourceBusy) + throw ProbeError.sourceBusy + } + do { try probeControl.check() } + catch { + OriginRequestBudget.shared.release(ticket) + throw error + } + return ticket + } + + /// Only the probe adapter calls this, after cancelling the transfer. + func finishProbeTransfers() { + precondition(probeControl != nil) + prefetchQueue.sync {} + } + private func syncRequest(_ request: URLRequest, budget: TimeInterval = 35) throws -> (Data, URLResponse) { // #377: every short fetch the reader makes (detour blocks, size probes, HEAD) funnels // through here, so this is the one place that has to take an origin slot for all of them. // Scoped to the call: unlike the pump's, this request's life IS this function's. let slotURL = request.url ?? url - let ticket = OriginRequestBudget.shared.acquire( + let ticket = try requestTicket( for: slotURL, label: "\(label) fetch", timeout: Self.shortFetchSlotWaitSeconds) defer { OriginRequestBudget.shared.release(ticket) } diff --git a/Sources/AetherEngine/Demuxer/Demuxer.swift b/Sources/AetherEngine/Demuxer/Demuxer.swift index 1dac50e17..38e64c407 100644 --- a/Sources/AetherEngine/Demuxer/Demuxer.swift +++ b/Sources/AetherEngine/Demuxer/Demuxer.swift @@ -639,6 +639,14 @@ public final class Demuxer: @unchecked Sendable { private func applyProbeBudget(_ ctx: UnsafeMutablePointer) { ctx.pointee.probesize = openProfile.probesize ctx.pointee.max_analyze_duration = openProfile.maxAnalyzeDuration + if let probeControl { + ctx.pointee.interrupt_callback = AVIOInterruptCB( + callback: { opaque in + guard let opaque else { return 0 } + return Unmanaged.fromOpaque(opaque).takeUnretainedValue().isStopped ? 1 : 0 + }, + opaque: Unmanaged.passUnretained(probeControl).toOpaque()) + } } /// Demuxer fflags applied to every avformat_open_input. @@ -1457,9 +1465,17 @@ public final class Demuxer: @unchecked Sendable { /// The read itself. Caller holds `accessLock`. private func readPacketLocked() throws -> UnsafeMutablePointer? { guard let ctx = formatContext else { return nil } + try probeControl?.willReadPacket() var packet: UnsafeMutablePointer? = trackedPacketAlloc() guard packet != nil else { return nil } let ret = av_read_frame(ctx, packet) + do { + try probeControl?.check() + if ret >= 0, let packet { try probeControl?.receivedPacket(packet) } + } catch { + trackedPacketFree(&packet) + throw error + } if ret < 0 { trackedPacketFree(&packet) let isEOF = (ret == FFmpegErr.eof) @@ -1698,6 +1714,7 @@ public final class Demuxer: @unchecked Sendable { accessLock.lock() defer { accessLock.unlock() } guard let ctx = formatContext else { return false } + guard probeControl?.isStopped != true else { return false } // #409: the read position moves, so the repair drops its picture-order anchor and // re-anchors on the next keyframe (a seek always lands on one). compositionRepair?.noteSeek() @@ -1733,7 +1750,7 @@ public final class Demuxer: @unchecked Sendable { // matroska may return success with a partial index after abort; deadline flag // is authoritative, not ret. let capped = avioProvider?.readDeadlineFired ?? false - return ret >= 0 && !capped + return ret >= 0 && !capped && probeControl?.isStopped != true } /// How an off-actor reposition ended (#254). Named to mirror `SeekEvent.Outcome` so the engine's @@ -1994,6 +2011,9 @@ public final class Demuxer: @unchecked Sendable { avioProvider?.markClosed() } + /// Static metadata probes only. Strong ownership outlives the native interrupt callback. + var probeControl: ProbeControl? + func close() { avioProvider?.markClosed() // unblocks av_read_frame (tvOS suspends threads in background) accessLock.lock() diff --git a/Sources/AetherEngine/IO/IOReader.swift b/Sources/AetherEngine/IO/IOReader.swift index 8fca9b53e..6a131e575 100644 --- a/Sources/AetherEngine/IO/IOReader.swift +++ b/Sources/AetherEngine/IO/IOReader.swift @@ -11,6 +11,10 @@ public protocol IOReader: AnyObject, Sendable { func close() /// Unblock a pending `read` so teardown does not hang. Network readers cancel the in-flight request; memory/file readers can leave this as the default no-op. For readers the engine may reload: unblock only, do not invalidate. + /// Controlled metadata probes also call this concurrently on cancellation/deadline, including during + /// open and `seek`. Implementations with blocking I/O must promptly interrupt those operations and + /// handle cancellation racing their start. A no-op implementation cannot provide an interruptible + /// deadline; the synchronous probe still waits for the reader to return before releasing its state. func cancel() /// Return an independent reader with its own cursor over the same source for concurrent access (side demuxer, scrub previews). Return nil for one-shot streams; the engine skips that feature. The returned reader is owned and closed by the engine. diff --git a/Sources/AetherEngine/IO/ProbeIOReader.swift b/Sources/AetherEngine/IO/ProbeIOReader.swift new file mode 100644 index 000000000..969dc48b5 --- /dev/null +++ b/Sources/AetherEngine/IO/ProbeIOReader.swift @@ -0,0 +1,66 @@ +import Foundation + +/// The accounting seam is below disc recognition/adaptation, so sparse reads and rereads count too. +final class ProbeIOReader: IOReader, @unchecked Sendable { + private let reader: IOReader + private let control: ProbeControl + + init(reader: IOReader, control: ProbeControl) { + self.reader = reader + self.control = control + control.interrupt { reader.cancel() } + } + + var discImageProbeEnabled: Bool { reader.discImageProbeEnabled } + + func read(_ buffer: UnsafeMutablePointer?, size: Int32) -> Int32 { + do { + let allowed = try control.inputAllowance(size) + let count = autoreleasepool { reader.read(buffer, size: allowed) } + try control.consumedInput(count, requested: allowed) + return count + } catch { + // The orchestrator rethrows the control's typed failure after the native call unwinds. + return -1 + } + } + + func seek(offset: Int64, whence: Int32) -> Int64 { + guard !control.isStopped else { return -1 } + let result = autoreleasepool { reader.seek(offset: offset, whence: whence) } + return control.isStopped ? -1 : result + } + + // A synchronous probe has no in-flight read at normal teardown, and never owns a host reader. + func cancel() {} + func close() {} +} + +/// Reuses the engine HTTP transport with speculation disabled; no playback transport policy changes. +/// Its AVIO allocation stays owned here, separate from the bridge FFmpeg uses for the counted input. +final class ProbeHTTPReader: IOReader, @unchecked Sendable { + private let reader: AVIOReader + let discImageProbeEnabled: Bool + + init(url: URL, headers: [String: String], control: ProbeControl) { + discImageProbeEnabled = Demuxer.isDiscImageURL(url) + reader = AVIOReader( + url: url, extraHeaders: headers, label: "probe", + chunkSize: 64 * 1024, prefetchEnabled: false, + chunkRequestTimeout: 2, chunkMaxRetries: 1, probeControl: control) + } + + func open() throws { try reader.open() } + func read(_ buffer: UnsafeMutablePointer?, size: Int32) -> Int32 { + guard let buffer else { return -1 } + let count = reader.read(into: buffer, size: size) + return count == FFmpegErr.eof ? 0 : count + } + func seek(offset: Int64, whence: Int32) -> Int64 { reader.seek(offset: offset, whence: whence) } + func cancel() { reader.markClosed() } + func close() { + reader.markClosed() + reader.finishProbeTransfers() + reader.close() + } +} diff --git a/Sources/AetherEngine/ProbeControl.swift b/Sources/AetherEngine/ProbeControl.swift new file mode 100644 index 000000000..afc8bec88 --- /dev/null +++ b/Sources/AetherEngine/ProbeControl.swift @@ -0,0 +1,259 @@ +import Foundation +import AetherLibavcodec + +/// Optional limits for an entire static metadata probe, including open, stream analysis and seeks. +/// Input counts bytes delivered by the reader, including rereads, not network traffic or prefetch. +public struct ProbeLimits: Sendable, Equatable { + public var maxInputBytes: Int64 + /// Packets returned by the demuxer for inspection, across all detail passes and streams. + /// FFmpeg's internal open/seek packets are bounded by input and time instead. + public var maxPackets: Int + /// Reject larger demuxed packets before inspection/decode. Not a native allocation ceiling. + public var maxPacketBytes: Int + /// Monotonic deadline from before the first input operation. Interrupts cooperative I/O. + public var timeBudget: TimeInterval + + public init( + maxInputBytes: Int64 = 8 * 1024 * 1024, + maxPackets: Int = 128, + maxPacketBytes: Int = 2 * 1024 * 1024, + timeBudget: TimeInterval = 5 + ) { + self.maxInputBytes = maxInputBytes + self.maxPackets = maxPackets + self.maxPacketBytes = maxPacketBytes + self.timeBudget = timeBudget + } +} + +/// A controlled probe throws rather than publishing a partial or late positive after a whole-probe stop. +public enum ProbeError: Error, Sendable, Equatable, LocalizedError { + case invalidLimits + case inputLimit + case packetLimit + case packetSizeLimit + case timedOut + case invalidReaderResult + case unsupportedURL + case sourceBusy + + public var errorDescription: String? { + switch self { + case .invalidLimits: "Probe limits must be nonnegative, with a finite time budget." + case .inputLimit: "Probe input byte limit reached." + case .packetLimit: "Probe packet limit reached." + case .packetSizeLimit: "Probe packet exceeds the inspection size limit." + case .timedOut: "Probe deadline reached." + case .invalidReaderResult: "Probe reader returned more bytes than requested." + case .unsupportedURL: "Controlled probes support file, HTTP and HTTPS URLs, or a custom IOReader." + case .sourceBusy: "No HTTP origin request slot is available for this probe." + } + } +} + +/// One-shot, thread-safe cancellation for synchronous probes. Cancellation requests interruption; +/// the probe has ended only when its call returns. Custom readers remain caller-owned. +public final class ProbeCancellation: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + private var handlers: [UUID: ProbeInterruption] = [:] + + public init() {} + + public var isCancelled: Bool { lock.withLock { cancelled } } + + public func cancel() { + let pending = lock.withLock { + cancelled = true + return Array(handlers.values) + } + for handler in pending { handler.fire() } + } + + fileprivate func register(_ handler: ProbeInterruption) -> UUID { + let id = UUID() + let fire = lock.withLock { + handlers[id] = handler + return cancelled + } + if fire { handler.fire() } + return id + } + + fileprivate func remove(_ id: UUID) { + let handler = lock.withLock { handlers.removeValue(forKey: id) } + handler?.invalidate() + } + + fileprivate func completing(_ body: () throws -> T) throws -> T { + try lock.withLock { + if cancelled { throw CancellationError() } + return try body() + } + } +} + +/// Invalidating joins an already-running callback, so it cannot touch a reused caller reader after return. +private final class ProbeInterruption: @unchecked Sendable { + private let lock = NSRecursiveLock() + private var action: (@Sendable () -> Void)? + + init(_ action: @escaping @Sendable () -> Void) { self.action = action } + + func fire() { + lock.lock() + defer { lock.unlock() } + let action = self.action + self.action = nil + action?() + } + + func invalidate() { + lock.lock() + action = nil + lock.unlock() + } +} + +/// Retained by the demuxer until every native call has returned; the watchdog only interrupts, never frees. +final class ProbeControl: @unchecked Sendable { + let limits: ProbeLimits? + private let cancellation: ProbeCancellation? + private let now: @Sendable () -> TimeInterval + private let deadline: TimeInterval? + private let lock = NSLock() + private var failure: (any Error)? + private var completed = false + private var inputBytes: Int64 = 0 + private var packets = 0 + private var interruption: ProbeInterruption? + private var cancellationID: UUID? + private var timer: DispatchSourceTimer? + + init( + limits: ProbeLimits?, + cancellation: ProbeCancellation?, + now: @escaping @Sendable () -> TimeInterval = { + Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 + }, + scheduleDeadline: Bool = true + ) throws { + if let limits { + guard limits.maxInputBytes >= 0, limits.maxPackets >= 0, limits.maxPacketBytes >= 0, + limits.timeBudget.isFinite, limits.timeBudget >= 0 else { throw ProbeError.invalidLimits } + } + self.limits = limits + self.cancellation = cancellation + self.now = now + deadline = limits.map { now() + $0.timeBudget } + if let cancellation { + cancellationID = cancellation.register(ProbeInterruption { [weak self] in + self?.stop(CancellationError()) + }) + } + if scheduleDeadline, let limits { + let timer = DispatchSource.makeTimerSource() + timer.setEventHandler { [weak self] in self?.stop(ProbeError.timedOut) } + let dispatchLimit = Double(Int.max / 1_000_000_000) + timer.schedule(deadline: limits.timeBudget >= dispatchLimit + ? .distantFuture : .now() + limits.timeBudget) + self.timer = timer + timer.resume() + } + } + + func interrupt(using action: @escaping @Sendable () -> Void) { + let handler = ProbeInterruption(action) + let stopped = lock.withLock { + interruption = handler + return failure != nil + } + if stopped { handler.fire() } + } + + func stop(_ error: any Error) { + let handler: ProbeInterruption? = lock.withLock { + guard !completed else { return nil } + if failure == nil { failure = error } + return interruption + } + handler?.fire() + } + + func check() throws { + if cancellation?.isCancelled == true { stop(CancellationError()) } + if let deadline, now() >= deadline { stop(ProbeError.timedOut) } + if let error = lock.withLock({ failure }) { throw error } + } + + var isStopped: Bool { + do { try check(); return false } + catch { return true } + } + + func inputAllowance(_ requested: Int32) throws -> Int32 { + try check() + guard let limits else { return requested } + let remaining = lock.withLock { limits.maxInputBytes - inputBytes } + guard remaining > 0 else { + stop(ProbeError.inputLimit) + throw ProbeError.inputLimit + } + return Int32(min(Int64(requested), remaining)) + } + + func consumedInput(_ count: Int32, requested: Int32) throws { + guard count <= requested else { + stop(ProbeError.invalidReaderResult) + throw ProbeError.invalidReaderResult + } + if count > 0 { lock.withLock { inputBytes += Int64(count) } } + try check() + } + + func willReadPacket() throws { + try check() + if let limits, lock.withLock({ packets >= limits.maxPackets }) { + stop(ProbeError.packetLimit) + throw ProbeError.packetLimit + } + } + + func receivedPacket(_ packet: UnsafePointer) throws { + try check() + if let limits, Int(packet.pointee.size) > limits.maxPacketBytes { + stop(ProbeError.packetSizeLimit) + throw ProbeError.packetSizeLimit + } + lock.withLock { packets += 1 } + } + + /// The result's linearization point: cancellation before this wins, cancellation afterwards is too late. + func complete() throws { + func commit() throws { + try lock.withLock { + if let deadline, now() >= deadline, failure == nil { failure = ProbeError.timedOut } + if let failure { throw failure } + completed = true + } + } + if let cancellation { try cancellation.completing(commit) } + else { try commit() } + } + + func finish() { + timer?.cancel() + timer = nil + if let cancellationID { + cancellation?.remove(cancellationID) + self.cancellationID = nil + } + let handler = lock.withLock { + completed = true + let handler = interruption + interruption = nil + return handler + } + handler?.invalidate() + } +} diff --git a/Sources/AetherEngine/Video/HDR10PlusDetectionProbe.swift b/Sources/AetherEngine/Video/HDR10PlusDetectionProbe.swift index 1b4361ff6..6cd685fd0 100644 --- a/Sources/AetherEngine/Video/HDR10PlusDetectionProbe.swift +++ b/Sources/AetherEngine/Video/HDR10PlusDetectionProbe.swift @@ -10,8 +10,8 @@ import AetherLibavutil /// `AtmosDetectionOptions`: a host opts into reading real video packets without the default probe changing /// behaviour or cost. /// -/// Unlike the Atmos pass this one opens no decoder. It reads demuxed video packets and looks for the T.35 -/// signature (see `HDR10PlusMetadataScan`), so its cost is I/O and a memory scan, not decode. +/// Unlike the Atmos pass this one opens no decoder. It reads demuxed video packets and validates codec +/// metadata (see `HDR10PlusMetadataScan`), so its cost is I/O and structural parsing, not decode. public struct HDR10PlusDetectionOptions: Sendable, Equatable { /// Stop after this many video packets have been scanned. Default 32. /// @@ -20,16 +20,15 @@ public struct HDR10PlusDetectionOptions: Sendable, Equatable { /// the adversarial one that never has it. public var maxPackets: Int - /// Stop after this many cumulative video-packet bytes. Default 16 MiB. + /// Inspect at most this many cumulative video-packet bytes. Default 16 MiB. /// /// This is the cap that actually binds on the content the feature targets: one UHD HEVC keyframe runs to - /// several MB, so a handful of packets can cross it long before `maxPackets` does. 16 MiB leaves room for - /// a keyframe plus the frames after it on a 4K remux while staying finite on a hostile source. + /// several MB, so a handful of packets can exhaust it long before `maxPackets` does. A packet larger + /// than the remaining budget stops the pass BEFORE inspection, even if it carries HDR10+. public var maxBytes: Int64 - /// Soft wall-clock budget, checked BETWEEN packet reads. NOT preemptive: one blocking `av_read_frame()` - /// on a stalled remote socket can still overrun it, the same AVIO-layer limitation `Demuxer.seekBounded` - /// documents. Default 2 seconds. + /// Soft wall-clock budget, checked before and after reads and after inspection. NOT preemptive: + /// one blocking read can still overrun it, but a late result never confirms HDR10+. Default 2 seconds. public var timeBudget: TimeInterval public init( @@ -49,13 +48,13 @@ struct HDR10PlusDetectionOutcome: Sendable, Equatable { enum StopReason: Sendable, Equatable { /// No video stream at the resolved index, or the source has no video at all. case noVideoTrack - /// The T.35 signature (or Matroska's decoded side data) was seen. The only positive answer. + /// Structural HDR10+ metadata was validated within the budget. The only positive answer. case found /// `maxPackets` video packets were scanned without a hit. case packetCap - /// `maxBytes` cumulative video-packet bytes were scanned without a hit. + /// The byte budget was exhausted, or the next packet would exceed it. case byteCap - /// `timeBudget` elapsed (checked between reads) without a hit. + /// `timeBudget` elapsed before a finding could be confirmed. case timeCap /// The demuxer reached EOF without a hit (a source short enough to scan whole). case demuxEOF @@ -137,7 +136,8 @@ extension AetherEngine { nonisolated static func detectHDR10Plus( demuxer: Demuxer, videoIndex: Int32, - options: HDR10PlusDetectionOptions + options: HDR10PlusDetectionOptions, + now: () -> UInt64 = { DispatchTime.now().uptimeNanoseconds } ) -> HDR10PlusDetectionOutcome { guard videoIndex >= 0, let stream = demuxer.stream(at: videoIndex), let codecpar = stream.pointee.codecpar, @@ -149,16 +149,18 @@ extension AetherEngine { // packets themselves either way; dropping the other streams keeps the byte budget spent on video. demuxer.discardAllStreamsExcept([videoIndex]) - let start = DispatchTime.now() + let start = now() + func elapsed() -> TimeInterval { + Double(now() - start) / 1_000_000_000 + } var packetsRead = 0 var bytesRead: Int64 = 0 var packetsSeen = 0 let fuse = Self.hdr10PlusForeignPacketFuse(maxPackets: options.maxPackets) while true { - let elapsed = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 if let cap = Self.hdr10PlusScanCapReached( - packetsRead: packetsRead, bytesRead: bytesRead, elapsed: elapsed, options: options + packetsRead: packetsRead, bytesRead: bytesRead, elapsed: elapsed(), options: options ) { return HDR10PlusDetectionOutcome(stopReason: cap, packetsRead: packetsRead, bytesRead: bytesRead) } @@ -170,6 +172,16 @@ extension AetherEngine { return HDR10PlusDetectionOutcome( stopReason: .demuxError, packetsRead: packetsRead, bytesRead: bytesRead) } + defer { + if let packet { + av_packet_unref(packet) + av_packet_free_safe(packet) + } + } + guard elapsed() < options.timeBudget else { + return HDR10PlusDetectionOutcome( + stopReason: .timeCap, packetsRead: packetsRead, bytesRead: bytesRead) + } guard let pkt = packet else { return HDR10PlusDetectionOutcome( stopReason: .demuxEOF, packetsRead: packetsRead, bytesRead: bytesRead) @@ -178,12 +190,23 @@ extension AetherEngine { var found = false packetsSeen += 1 if pkt.pointee.stream_index == videoIndex { + let packetBytes = Int64(pkt.pointee.size) + guard packetBytes >= 0 else { + return HDR10PlusDetectionOutcome( + stopReason: .demuxError, packetsRead: packetsRead, bytesRead: bytesRead) + } + guard packetBytes <= options.maxBytes - bytesRead else { + return HDR10PlusDetectionOutcome( + stopReason: .byteCap, packetsRead: packetsRead, bytesRead: bytesRead) + } packetsRead += 1 - bytesRead += Int64(pkt.pointee.size) - found = HDR10PlusMetadataScan.packetCarriesHDR10Plus(pkt) + bytesRead += packetBytes + found = HDR10PlusMetadataScan.packetCarriesHDR10Plus(pkt, codecParameters: codecpar) + } + guard elapsed() < options.timeBudget else { + return HDR10PlusDetectionOutcome( + stopReason: .timeCap, packetsRead: packetsRead, bytesRead: bytesRead) } - av_packet_unref(pkt) - av_packet_free_safe(pkt) if found { return HDR10PlusDetectionOutcome( diff --git a/Sources/AetherEngine/Video/HDR10PlusMetadataScan.swift b/Sources/AetherEngine/Video/HDR10PlusMetadataScan.swift index 4b9c9aa74..390ff8dc2 100644 --- a/Sources/AetherEngine/Video/HDR10PlusMetadataScan.swift +++ b/Sources/AetherEngine/Video/HDR10PlusMetadataScan.swift @@ -2,63 +2,350 @@ import Foundation import AetherLibavcodec import AetherLibavutil -/// Where HDR10+ (SMPTE ST 2094-40) can be seen in a demuxed packet, without decoding a frame. +/// Shared, decoder-free HDR10+ confirmation for playback and the detail probe. /// -/// One definition for both readers: the segment producer's per-packet scan during playback -/// (`HLSSegmentProducer.finalizeAndWriteVideo`) and the pre-playback pass behind -/// `AetherEngine.probeDetectingHDR10Plus`. They used to be one call site and a literal byte array; a probe -/// that answered differently from the session it precedes would be worse than no probe at all. -/// -/// Two carriages, because FFmpeg produces exactly two (checked against the pinned FFmpeg tree): -/// -/// - **In-band ITU-T T.35 SEI.** This is where HEVC HDR10+ lives, and NO demuxer parses it: `mov`, `mpegts` -/// and `matroska` hand the SEI through inside the video packet, and only `hevc/hevcdec.c` surfaces it, -/// post-decode, as `AV_FRAME_DATA_DYNAMIC_HDR_PLUS`. Hence the byte scan: it is the only way to see HDR10+ -/// without opening a decoder. -/// - **`AV_PKT_DATA_DYNAMIC_HDR10_PLUS` packet side data.** `matroskadec.c` alone attaches this, from a -/// BlockAdditional whose T.35 header it has already stripped (the VP9/AV1-in-Matroska carriage). The bytes -/// are a decoded `AVDynamicHDRPlus` by then, so the signature scan cannot see it and the type has to be -/// asked for separately. -/// -/// The byte scan is a signature match over the whole packet payload, not a NAL walk, and it can in principle -/// match those six bytes inside compressed slice data. That is the trade the producer has always made, and it -/// is kept here deliberately: one false positive mislabels a badge, while a NAL walk that disagrees with the -/// producer's scan would mislabel the session against its own probe. +/// Only registered T.35 messages in H.264/HEVC SEI or AV1 metadata OBUs are candidates. +/// FFmpeg validates their complete ST 2094-40 body. Matroska's already-decoded packet +/// side data is checked separately; neither a byte marker nor a side-data type is proof. enum HDR10PlusMetadataScan { + private static let t35Header: [UInt8] = [0xB5, 0x00, 0x3C, 0x00, 0x01, 0x04] - /// `country_code` 0xB5 (US), `provider_code` 0x003C (Samsung), `provider_oriented_code` 0x0001, - /// `application_identifier` 0x04: the four fields that open every ST 2094-40 T.35 payload, in bitstream - /// order. `matroskadec.c` gates its BlockAdditional conversion on the same four values. - /// - /// All four are needed. Dolby's RPU rides a T.35 payload too, under provider code 0x003B, so a scan for - /// the country code alone would call every Profile 5 / 8 source HDR10+. - static let t35Signature: [UInt8] = [0xB5, 0x00, 0x3C, 0x00, 0x01, 0x04] - - /// Whether the ST 2094-40 T.35 signature appears anywhere in `size` bytes at `data`. - /// - /// `nil` data, a non-positive size, or a payload shorter than the signature answer `false` without a read. - static func bytesCarrySignature(_ data: UnsafePointer?, size: Int) -> Bool { - guard let data, size >= t35Signature.count else { return false } - return t35Signature.withUnsafeBufferPointer { needle -> Bool in - memmem(data, size, needle.baseAddress, needle.count) != nil + static func packetCarriesHDR10Plus( + _ packet: UnsafePointer, + codecParameters: UnsafePointer, + framing: VideoNALFraming? = nil + ) -> Bool { + let parameters = codecParameters.pointee + let resolvedFraming = framing ?? NALUnitChain.lengthPrefixSize( + codecID: parameters.codec_id, + extradata: parameters.extradata, + extradataSize: Int(parameters.extradata_size) + ).map { .lengthPrefixed(size: $0) } ?? .annexB + return packetCarriesHDR10Plus(packet, codecID: parameters.codec_id, framing: resolvedFraming) + } + + static func packetCarriesHDR10Plus( + _ packet: UnsafePointer, codecID: AVCodecID, framing: VideoNALFraming = .annexB + ) -> Bool { + switch codecID { + case AV_CODEC_ID_H264, AV_CODEC_ID_HEVC, AV_CODEC_ID_AV1, AV_CODEC_ID_VP9: + break + default: + return false } + var sideDataSize = 0 + if let sideData = av_packet_get_side_data(packet, AV_PKT_DATA_DYNAMIC_HDR10_PLUS, &sideDataSize), + validSideData(sideData, size: sideDataSize) { + return true + } + return bytesCarryHDR10Plus( + packet.pointee.data, size: Int(packet.pointee.size), codecID: codecID, framing: framing) } - /// Array convenience for tests and call sites that already hold the bytes. - static func bytesCarrySignature(_ bytes: [UInt8]) -> Bool { - bytes.withUnsafeBufferPointer { bytesCarrySignature($0.baseAddress, size: $0.count) } + static func bytesCarryHDR10Plus( + _ data: UnsafePointer?, size: Int, codecID: AVCodecID, framing: VideoNALFraming = .annexB + ) -> Bool { + guard let data, size > 0 else { return false } + let bytes = UnsafeBufferPointer(start: data, count: size) + switch codecID { + case AV_CODEC_ID_H264, AV_CODEC_ID_HEVC: + return scanNALs(bytes, codecID: codecID, framing: framing) + case AV_CODEC_ID_AV1: + return scanOBUs(bytes) + default: + return false + } } - /// Whether this packet carries HDR10+ in either of the two carriages. - /// - /// Side data is asked first: it is a type lookup over a short list, while the signature scan walks the - /// whole payload, and on the one container that produces side data the payload no longer holds the T.35 - /// header at all. - static func packetCarriesHDR10Plus(_ packet: UnsafePointer) -> Bool { - var sideDataSize = 0 - if av_packet_get_side_data(packet, AV_PKT_DATA_DYNAMIC_HDR10_PLUS, &sideDataSize) != nil { + // MARK: - H.264 / HEVC + + private static func scanNALs( + _ bytes: UnsafeBufferPointer, codecID: AVCodecID, framing: VideoNALFraming + ) -> Bool { + var found = false + func visit(_ start: Int, _ end: Int) -> Bool { + let headerSize = codecID == AV_CODEC_ID_HEVC ? 2 : 1 + guard end - start >= headerSize, bytes[start] & 0x80 == 0 else { return false } + let isSEI: Bool + if codecID == AV_CODEC_ID_HEVC { + guard bytes[start + 1] & 7 != 0 else { return false } + let type = (bytes[start] >> 1) & 0x3F + isSEI = type == 39 || type == 40 + } else { + isSEI = bytes[start] & 0x1F == 6 + if isSEI, bytes[start] & 0x60 != 0 { return false } + } + guard isSEI else { return true } + guard let rbsp = unescape(bytes, start: start + headerSize, end: end), + let carriesMetadata = scanSEI(rbsp) else { return false } + found = found || carriesMetadata return true } - return bytesCarrySignature(packet.pointee.data, size: Int(packet.pointee.size)) + + switch framing { + case .lengthPrefixed(let width): + guard (1...4).contains(width) else { return false } + var offset = 0 + while offset < bytes.count { + guard bytes.count - offset >= width else { return false } + var count = 0 + for index in offset..<(offset + width) { count = (count << 8) | Int(bytes[index]) } + offset += width + guard count > 0, count <= bytes.count - offset, + visit(offset, offset + count) else { return false } + offset += count + } + case .annexB: + var nalStart: Int? + var zeros = 0 + for index in bytes.indices { + let byte = bytes[index] + if byte == 1, zeros >= 2 { + let end = index - zeros + if let start = nalStart { + guard visit(start, end) else { return false } + } else if end != 0 { + return false + } + nalStart = index + 1 + } + zeros = byte == 0 ? zeros + 1 : 0 + } + guard let start = nalStart, visit(start, bytes.count - zeros) else { return false } + } + return found + } + + /// Reject malformed escapes as well as unescaped start-code emulation in an SEI NAL. + private static func unescape( + _ bytes: UnsafeBufferPointer, start: Int, end: Int + ) -> [UInt8]? { + var rbsp: [UInt8] = [] + var zeros = 0 + for index in start..= 2 { + if byte == 3 { + guard index + 1 < end, bytes[index + 1] <= 3 else { return nil } + zeros = 0 + continue + } + if byte < 3 { return nil } + } + rbsp.append(byte) + zeros = byte == 0 ? zeros + 1 : 0 + } + return rbsp + } + + /// Nil denotes malformed SEI framing; false is a well-formed SEI without HDR10+. + private static func scanSEI(_ bytes: [UInt8]) -> Bool? { + var offset = 0 + var found = false + func extendedValue() -> Int? { + var value = 0 + while offset < bytes.count { + let byte = bytes[offset] + offset += 1 + let (sum, overflow) = value.addingReportingOverflow(Int(byte)) + guard !overflow else { return nil } + value = sum + if byte != 255 { return value } + } + return nil + } + while offset < bytes.count { + if offset == bytes.count - 1, bytes[offset] == 0x80 { return found } + guard let type = extendedValue(), let size = extendedValue(), + size <= bytes.count - offset else { return nil } + if type == 4 { + let valid = bytes.withUnsafeBufferPointer { + validT35($0.baseAddress! + offset, size: size) + } + found = found || valid + } + offset += size + } + return nil // rbsp_trailing_bits is mandatory. + } + + // MARK: - AV1 low-overhead OBU stream (demuxed packet framing) + + private static func scanOBUs(_ bytes: UnsafeBufferPointer) -> Bool { + var offset = 0 + var found = false + while offset < bytes.count { + let header = bytes[offset] + offset += 1 + guard header & 0x81 == 0 else { return false } + let type = (header >> 3) & 15 + if header & 4 != 0 { + guard offset < bytes.count, bytes[offset] & 7 == 0 else { return false } + offset += 1 + } + let size: Int + if header & 2 != 0 { + guard let declared = leb128(bytes, offset: &offset, end: bytes.count), + declared <= bytes.count - offset else { return false } + size = declared + } else { + size = bytes.count - offset + } + let end = offset + size + if type == 5 { + guard let metadataType = leb128(bytes, offset: &offset, end: end) else { return false } + if metadataType == 4 { + // AV1 permits arbitrarily many trailing zero bytes after the stop bit. + // They belong to the OBU, not to the byte-aligned T.35 payload. + var trailing = end + while trailing > offset, bytes[trailing - 1] == 0 { trailing -= 1 } + guard trailing > offset, bytes[trailing - 1] == 0x80 else { return false } + found = validT35(bytes.baseAddress! + offset, size: trailing - offset - 1) || found + } + } + offset = end + } + return found + } + + private static func leb128( + _ bytes: UnsafeBufferPointer, offset: inout Int, end: Int + ) -> Int? { + var value: UInt64 = 0 + for index in 0..<8 { + guard offset < end else { return nil } + let byte = bytes[offset] + offset += 1 + value |= UInt64(byte & 0x7F) << (index * 7) + if byte & 0x80 == 0 { + // AV1 restricts leb128 values to 32 bits, even with an eight-byte encoding. + return value <= UInt32.max ? Int(value) : nil + } + } + return nil + } + + // MARK: - Complete ST 2094-40 validation + + private static func validT35(_ bytes: UnsafePointer, size: Int) -> Bool { + guard size > t35Header.count, size - t35Header.count <= Int(AV_HDR_PLUS_MAX_PAYLOAD_SIZE), + t35Header.indices.allSatisfy({ bytes[$0] == t35Header[$0] }), + let metadata = av_dynamic_hdr_plus_alloc(nil) else { return false } + defer { av_free(metadata) } + let body = bytes + t35Header.count + let bodySize = size - t35Header.count + guard av_dynamic_hdr_plus_from_t35(metadata, body, bodySize) >= 0, + let bitCount = validatedBitCount(metadata.pointee), + (bitCount + 7) / 8 == bodySize else { return false } + let padding = bodySize * 8 - bitCount + return body[bodySize - 1] & UInt8((1 << padding) - 1) == 0 + } + + private static func validSideData(_ bytes: UnsafePointer, size: Int) -> Bool { + var allocationSize = 0 + guard let metadata = av_dynamic_hdr_plus_alloc(&allocationSize) else { return false } + defer { av_free(metadata) } + guard size == allocationSize else { return false } + // Packet side data is not required to have the alignment of AVDynamicHDRPlus. + memcpy(metadata, bytes, size) + // matroskadec validates and strips the T.35 header, leaving this field zero. + guard metadata.pointee.itu_t_t35_country_code == 0 || + metadata.pointee.itu_t_t35_country_code == 0xB5 else { return false } + return validatedBitCount(metadata.pointee) != nil + } + + /// Check every active field before trusting a decoded struct. In particular, FFmpeg's + /// serializer is not a validator: invalid counts can read out of bounds, and a zero + /// rational denominator can divide by zero. Do not feed untrusted side data to it. + private static func validatedBitCount(_ metadata: AVDynamicHDRPlus) -> Int? { + let windows = Int(metadata.num_windows) + guard (1...3).contains(windows), metadata.application_version <= 1, + rational(metadata.targeted_system_display_maximum_luminance, scale: 1, maximum: 0x7FFFFFF), + metadata.targeted_system_display_actual_peak_luminance_flag <= 1, + metadata.mastering_display_actual_peak_luminance_flag <= 1 else { return nil } + var bits = 8 + 2 + 153 * (windows - 1) + 27 + 1 + 1 + + func grid(_ flag: UInt8, _ rows: UInt8, _ columns: UInt8, _ values: T) -> Bool { + guard flag != 0 else { return true } + guard (2...25).contains(rows), (2...25).contains(columns) else { return false } + bits += 10 + Int(rows) * Int(columns) * 4 + return withUnsafeBytes(of: values) { storage in + let entries = storage.bindMemory(to: AVRational.self) + for row in 0.. 0 { + guard rational(window.window_upper_left_corner_x, scale: 1, maximum: 65535), + rational(window.window_upper_left_corner_y, scale: 1, maximum: 65535), + rational(window.window_lower_right_corner_x, scale: 1, maximum: 65535), + rational(window.window_lower_right_corner_y, scale: 1, maximum: 65535), + window.rotation_angle <= 180, + window.semimajor_axis_internal_ellipse > 0, + window.semimajor_axis_external_ellipse >= window.semimajor_axis_internal_ellipse, + window.semiminor_axis_external_ellipse > 0, + (window.overlap_process_option.rawValue == 0 || + window.overlap_process_option.rawValue == 1) else { return false } + } + let percentiles = Int(window.num_distribution_maxrgb_percentiles) + guard percentiles <= 15, + rational(window.maxscl.0, scale: 100000, maximum: 100000), + rational(window.maxscl.1, scale: 100000, maximum: 100000), + rational(window.maxscl.2, scale: 100000, maximum: 100000), + rational(window.average_maxrgb, scale: 100000, maximum: 100000), + rational(window.fraction_bright_pixels, scale: 1000, maximum: 1000), + window.tone_mapping_flag <= 1, + window.color_saturation_mapping_flag <= 1 else { return false } + let validPercentiles = withUnsafeBytes(of: window.distribution_maxrgb) { storage in + storage.bindMemory(to: AVHDRPlusPercentile.self).prefix(percentiles).allSatisfy { + $0.percentage <= 100 && rational($0.percentile, scale: 100000, maximum: 100000) + } + } + guard validPercentiles else { return false } + bits += 82 + percentiles * 24 + 1 + 1 + if window.tone_mapping_flag != 0 { + let anchors = Int(window.num_bezier_curve_anchors) + guard anchors <= 15, + rational(window.knee_point_x, scale: 4095, maximum: 4095), + rational(window.knee_point_y, scale: 4095, maximum: 4095) else { return false } + let validAnchors = withUnsafeBytes(of: window.bezier_curve_anchors) { storage in + storage.bindMemory(to: AVRational.self).prefix(anchors).allSatisfy { + rational($0, scale: 1023, maximum: 1023) + } + } + guard validAnchors else { return false } + bits += 28 + anchors * 10 + } + if window.color_saturation_mapping_flag != 0 { + guard rational(window.color_saturation_weight, scale: 8, maximum: 63) else { return false } + bits += 6 + } + } + return true + } + return valid ? bits : nil + } + + private static func rational(_ value: AVRational, scale: Int64, maximum: Int64) -> Bool { + guard value.den > 0, value.num >= 0 else { return false } + let scaled = Int64(value.num) * scale + return scaled % Int64(value.den) == 0 && scaled / Int64(value.den) <= maximum } } diff --git a/Sources/AetherEngine/Video/HLSSegmentProducer.swift b/Sources/AetherEngine/Video/HLSSegmentProducer.swift index fb836bd5a..6f4eebd90 100644 --- a/Sources/AetherEngine/Video/HLSSegmentProducer.swift +++ b/Sources/AetherEngine/Video/HLSSegmentProducer.swift @@ -4235,7 +4235,9 @@ final class HLSSegmentProducer: @unchecked Sendable { packet.pointee.stream_index = muxer.videoOutputStreamIndex - if !hdr10PlusDetected, HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet) { + if !hdr10PlusDetected, HDR10PlusMetadataScan.packetCarriesHDR10Plus( + packet, codecParameters: videoConfig.codecpar, framing: a53NALFraming + ) { hdr10PlusDetected = true onFirstHDR10PlusDetected?() } diff --git a/Tests/AetherEngineTests/AtmosDetectionProbeIntegrationTests.swift b/Tests/AetherEngineTests/AtmosDetectionProbeIntegrationTests.swift index 13b18a0b3..fecb4e4c9 100644 --- a/Tests/AetherEngineTests/AtmosDetectionProbeIntegrationTests.swift +++ b/Tests/AetherEngineTests/AtmosDetectionProbeIntegrationTests.swift @@ -162,7 +162,7 @@ struct AtmosDetectionProbeIntegrationTests { defer { demuxer.close() } try demuxer.open(reader: DataIOReader(data: Self.data(Self.eac3PlainBase64)), formatHint: "mp4") - let options = AtmosDetectionOptions(maxBytes: 1) + let options = AtmosDetectionOptions(maxBytes: 256) let targetIndex = AetherEngine.atmosDecodeTargetIndex(options: options, defaultAudioStreamIndex: demuxer.audioStreamIndex) let outcome = AetherEngine.detectAtmos(demuxer: demuxer, targetIndex: targetIndex, options: options) @@ -172,6 +172,20 @@ struct AtmosDetectionProbeIntegrationTests { #expect(outcome.confirmedAtmos == false) } + @Test("an oversized first audio packet is rejected before decode") + func oversizedPacketCannotConfirm() throws { + let demuxer = Demuxer() + defer { demuxer.close() } + try demuxer.open(reader: DataIOReader(data: Self.data(Self.eac3PlainBase64)), formatHint: "mp4") + let outcome = AetherEngine.detectAtmos( + demuxer: demuxer, targetIndex: demuxer.audioStreamIndex, + options: AtmosDetectionOptions(maxBytes: 1)) + #expect(outcome.stopReason == .byteCap) + #expect(outcome.packetsRead == 0) + #expect(outcome.bytesRead == 0) + #expect(outcome.decodedProfile == nil) + } + @Test("AAC audio never opens an EAC3 decoder (.notEAC3), never confirmed Atmos") func aacIsSkippedAsNotEAC3() throws { let demuxer = Demuxer() diff --git a/Tests/AetherEngineTests/DocumentedConstantsTests.swift b/Tests/AetherEngineTests/DocumentedConstantsTests.swift index 33c315b5c..04045558c 100644 --- a/Tests/AetherEngineTests/DocumentedConstantsTests.swift +++ b/Tests/AetherEngineTests/DocumentedConstantsTests.swift @@ -127,6 +127,17 @@ final class DocumentedConstantsTests: XCTestCase { // MARK: - Probe budgets + func testWholeProbeDefaultsMatchDocumentation() throws { + let docs = try documentation() + let limits = ProbeLimits() + XCTAssertEqual(limits.maxInputBytes, 8 * 1024 * 1024) + XCTAssertEqual(limits.maxPackets, 128) + XCTAssertEqual(limits.maxPacketBytes, 2 * 1024 * 1024) + XCTAssertEqual(limits.timeBudget, 5) + assertDocumented("`maxInputBytes` (8 MiB), `maxPackets` (128)", docs) + assertDocumented("`maxPacketBytes` (2 MiB), `timeBudget` (5 s)", docs) + } + /// docs/api.md states the defaults a host overrides with `probesize` / `maxAnalyzeDuration`. func testProbeBudgetDefaultsAreWhatTheDocsSay() throws { let docs = try documentation() diff --git a/Tests/AetherEngineTests/HDR10PlusDetectionOptionsTests.swift b/Tests/AetherEngineTests/HDR10PlusDetectionOptionsTests.swift index 882b71b9c..aa556394b 100644 --- a/Tests/AetherEngineTests/HDR10PlusDetectionOptionsTests.swift +++ b/Tests/AetherEngineTests/HDR10PlusDetectionOptionsTests.swift @@ -75,4 +75,36 @@ struct HDR10PlusDetectionOptionsTests { #expect(!ProbeDetail.atmos.contains(.hdr10Plus)) #expect(ProbeDetail().isEmpty) } + + @Test("HDR10+ enrichment preserves known Atmos and commutes with independent Atmos confirmation") + func knownAtmosIsPreserved() { + let tracks = [true, false].enumerated().map { index, knownAtmos in + TrackInfo( + id: index + 1, name: "Audio \(index + 1)", codec: "eac3", language: "eng", + channels: 6, bitrate: 768_000, isDefault: index == 0, + isForced: false, isHearingImpaired: false, isCommentary: false, + isAtmos: knownAtmos, assHeader: nil, isExternal: false) + } + let base = SourceProbe( + url: URL(fileURLWithPath: "/synthetic-hdr-atmos.mkv"), durationSeconds: 1, + videoFormat: .hdr10, videoCodecID: 173, videoCodecName: "hevc", + videoWidth: 64, videoHeight: 64, videoFrameRate: 24, isDolbyVision: false, + audioTracks: tracks, subtitleTracks: []) + + let hdr = AetherEngine.enrichHDR10Plus(base: base) + #expect(hdr.audioTracks == tracks) + #expect(hdr.videoFormat == .hdr10Plus) + #expect(hdr.carriesHDR10PlusMetadata) + + let hdrThenAtmos = AetherEngine.enrichAtmos(base: hdr, confirmedTrackID: 2) + let atmosThenHDR = AetherEngine.enrichHDR10Plus( + base: AetherEngine.enrichAtmos(base: base, confirmedTrackID: 2)) + #expect(hdrThenAtmos.audioTracks == atmosThenHDR.audioTracks) + #expect(hdrThenAtmos.audioTracks.map(\.isAtmos) == [true, true]) + #expect(hdrThenAtmos.carriesHDR10PlusMetadata && atmosThenHDR.carriesHDR10PlusMetadata) + #expect(hdrThenAtmos.videoFormat == atmosThenHDR.videoFormat) + #expect(AetherEngine.enrichAtmos(base: hdr, confirmedTrackID: 99).audioTracks == tracks) + #expect(!base.carriesHDR10PlusMetadata) + #expect(base.audioTracks.map(\.isAtmos) == [true, false]) + } } diff --git a/Tests/AetherEngineTests/HDR10PlusMetadataScanTests.swift b/Tests/AetherEngineTests/HDR10PlusMetadataScanTests.swift index 98e9cb663..6c0b9cd97 100644 --- a/Tests/AetherEngineTests/HDR10PlusMetadataScanTests.swift +++ b/Tests/AetherEngineTests/HDR10PlusMetadataScanTests.swift @@ -4,133 +4,419 @@ import AetherLibavcodec import AetherLibavutil @testable import AetherEngine -/// Pure byte-level tests for the HDR10+ (ST 2094-40) carriage scan that `AetherEngine.probeDetectingHDR10Plus` -/// and `HLSSegmentProducer` share. No demuxer, no decoder, no media. -/// -/// The two carriages tested here are the two FFmpeg actually produces, verified against the pinned -/// `~/Dev/FFmpegBuild/build/ffmpeg-src` tree: -/// -/// - in-band ITU-T T.35 SEI in the video bitstream, which is where HEVC HDR10+ lives and which no demuxer -/// parses (`hevc/hevcdec.c` surfaces it only post-decode as `AV_FRAME_DATA_DYNAMIC_HDR_PLUS`), and -/// - `AV_PKT_DATA_DYNAMIC_HDR10_PLUS` packet side data, which `matroskadec.c` alone attaches, from a -/// BlockAdditional carrying the same T.35 payload. -@Suite("HDR10PlusMetadataScan: T.35 signature and packet side data") +@Suite("HDR10PlusMetadataScan: structural metadata, not byte markers") struct HDR10PlusMetadataScanTests { + private static let header: [UInt8] = [0xB5, 0x00, 0x3C, 0x00, 0x01, 0x04] - /// country_code 0xB5 (US), provider_code 0x003C (Samsung), provider_oriented_code 0x0001, - /// application_identifier 0x04. The same four fields `matroskadec.c` gates its BlockAdditional - /// conversion on, in the same order. - static let signature: [UInt8] = [0xB5, 0x00, 0x3C, 0x00, 0x01, 0x04] + private struct BitWriter { + var bits: [UInt8] = [] + mutating func write(_ value: Int, width: Int) { + for shift in (0..> shift) & 1)) } + } + var bytes: [UInt8] { + stride(from: 0, to: bits.count, by: 8).map { start in + (0..<8).reduce(UInt8(0)) { byte, bit in + (byte << 1) | (start + bit < bits.count ? bits[start + bit] : 0) + } + } + } + } - @Test("The signature is found at the head of a packet payload") - func signatureAtHead() { - let payload = Self.signature + [0x01, 0x02, 0x03] - #expect(HDR10PlusMetadataScan.bytesCarrySignature(payload)) + /// A complete ST 2094-40 body, independently encoded rather than using the FFmpeg + /// writer (which normalizes application_version and some optional fields). + private static func t35(windows: Int = 1, grids: Bool = false, toneMapping: Bool = false) -> [UInt8] { + var writer = BitWriter() + writer.write(0, width: 8) + writer.write(windows, width: 2) + for _ in 1.. [UInt8] { + var bytes: [UInt8] = [] + var zeros = 0 + for byte in rbsp { + if zeros >= 2, byte <= 3 { bytes.append(3); zeros = 0 } + bytes.append(byte) + zeros = byte == 0 ? zeros + 1 : 0 + } + return bytes } - @Test("The signature is found at the very end of the payload") - func signatureAtTail() { - let payload: [UInt8] = [0xAA, 0xBB, 0xCC] + Self.signature - #expect(HDR10PlusMetadataScan.bytesCarrySignature(payload)) + private static func extended(_ value: Int) -> [UInt8] { + Array(repeating: 255, count: value / 255) + [UInt8(value % 255)] } - @Test("A payload without the signature does not match") - func absentSignature() { - let payload: [UInt8] = Array(repeating: 0xB5, count: 64) - #expect(!HDR10PlusMetadataScan.bytesCarrySignature(payload)) + private static func message(_ payload: [UInt8], type: Int = 4) -> [UInt8] { + extended(type) + extended(payload.count) + payload } - @Test("A prefix of the signature that runs off the end of the payload does not match") - func truncatedSignature() { - let payload: [UInt8] = [0x00, 0x00] + Self.signature.dropLast() - #expect(!HDR10PlusMetadataScan.bytesCarrySignature(payload)) + private static func sei(_ payload: [UInt8]? = nil, hevc: Bool = true, suffix: Bool = false) -> [UInt8] { + let header: [UInt8] = hevc ? [suffix ? 0x50 : 0x4E, 0x01] : [0x06] + return header + escaped(message(payload ?? t35()) + [0x80]) } - @Test("A payload shorter than the signature is rejected without reading past it") - func payloadShorterThanSignature() { - #expect(!HDR10PlusMetadataScan.bytesCarrySignature([])) - #expect(!HDR10PlusMetadataScan.bytesCarrySignature([0xB5, 0x00, 0x3C, 0x00, 0x01])) + private static func annexB(_ nal: [UInt8], fourBytes: Bool = true) -> [UInt8] { + (fourBytes ? [0, 0, 0, 1] : [0, 0, 1]) + nal } - @Test("A Dolby Vision T.35 payload (provider 0x003B) is not mistaken for HDR10+") - func dolbyProviderDoesNotMatch() { - // Dolby's provider_code is 0x003B, one below Samsung's. A scan that only checked the country code - // would report every P5/P8 RPU carried in a T.35 SEI as HDR10+. - let payload: [UInt8] = [0xB5, 0x00, 0x3B, 0x00, 0x01, 0x04, 0x11, 0x22] - #expect(!HDR10PlusMetadataScan.bytesCarrySignature(payload)) + private static func lengthPrefixed(_ nal: [UInt8], width: Int) -> [UInt8] { + (0..> ($0 * 8)) } + nal } - @Test("A packet carrying AV_PKT_DATA_DYNAMIC_HDR10_PLUS side data is detected without an in-band SEI") - func packetSideDataIsDetected() { - guard let packet = av_packet_alloc() else { - Issue.record("av_packet_alloc failed") - return + private static func leb128(_ value: Int) -> [UInt8] { + var value = value + var bytes: [UInt8] = [] + repeat { + let byte = UInt8(value & 0x7F) + value >>= 7 + bytes.append(byte | (value == 0 ? 0 : 0x80)) + } while value > 0 + return bytes + } + + private static func obu(_ payload: [UInt8], type: UInt8 = 5, sized: Bool = true, + extensionByte: UInt8? = nil) -> [UInt8] { + [type << 3 | (sized ? 2 : 0) | (extensionByte == nil ? 0 : 4)] + + (extensionByte.map { [$0] } ?? []) + + (sized ? leb128(payload.count) : []) + payload + } + + private func scan(_ bytes: [UInt8], codecID: AVCodecID = AV_CODEC_ID_HEVC, + framing: VideoNALFraming = .annexB) -> Bool { + bytes.withUnsafeBufferPointer { + HDR10PlusMetadataScan.bytesCarryHDR10Plus( + $0.baseAddress, size: $0.count, codecID: codecID, framing: framing) + } + } + + @Test("HEVC prefix/suffix and H.264 SEI work with either Annex B start-code width") + func annexBMetadata() { + for fourBytes in [false, true] { + #expect(scan(Self.annexB(Self.sei(), fourBytes: fourBytes))) + #expect(scan(Self.annexB(Self.sei(suffix: true), fourBytes: fourBytes))) + #expect(scan(Self.annexB(Self.sei(hevc: false), fourBytes: fourBytes), codecID: AV_CODEC_ID_H264)) + } + let aud: [UInt8] = [0x46, 0x01, 0x50] + #expect(scan(Self.annexB(aud) + Self.annexB(Self.sei()) + [0, 0, 0])) + } + + @Test("All supported length-prefix widths delimit metadata", arguments: [1, 2, 3, 4]) + func lengthPrefixedMetadata(width: Int) { + for hevc in [true, false] { + let payload = Self.lengthPrefixed(Self.sei(hevc: hevc), width: width) + #expect(scan(payload, codecID: hevc ? AV_CODEC_ID_HEVC : AV_CODEC_ID_H264, + framing: .lengthPrefixed(size: width))) + } + } + + @Test("EPB removal, extended SEI types/sizes, multiple messages and optional ST2094 fields") + func completeSEIWalk() { + let large = Self.t35(windows: 3, grids: true, toneMapping: true) + #expect(large.count > 255) + let rbsp = Self.message(Array(repeating: 0xAA, count: 260), type: 300) + + Self.message(large) + [0x80] + let escaped = Self.escaped(rbsp) + #expect(escaped.count > rbsp.count) + #expect(scan(Self.annexB([0x4E, 0x01] + escaped))) + } + + @Test("Raw markers, slices, parameter sets, and unregistered SEI never confirm") + func nonMetadataMarkers() { + #expect(!scan(Self.header)) + #expect(!scan(Self.t35())) + for header in [[0x26, 0x01], [0x42, 0x01], [0x7C, 0x01]] as [[UInt8]] { + #expect(!scan(Self.annexB(header + Self.escaped(Self.message(Self.t35()) + [0x80])))) + } + #expect(!scan(Self.annexB([0x65] + Self.escaped(Self.message(Self.t35()) + [0x80])), + codecID: AV_CODEC_ID_H264)) + #expect(!scan(Self.annexB([0x4E, 0x01] + Self.escaped(Self.message(Self.t35(), type: 5) + [0x80])))) + #expect(!scan(Self.annexB(Self.sei()), codecID: AV_CODEC_ID_VP9)) + #expect(!scan(Self.annexB(Self.sei()), codecID: AV_CODEC_ID_MPEG2VIDEO)) + } + + @Test("Every registered T35 identifier is checked at the start of the SEI payload") + func registeredIdentifiers() { + for index in Self.header.indices { + var payload = Self.t35() + payload[index] ^= 1 + #expect(!scan(Self.annexB(Self.sei(payload)))) + } + #expect(!scan(Self.annexB(Self.sei([0xAA] + Self.t35())))) + #expect(!scan(Self.annexB(Self.sei(Self.header + [0x01])))) + var wrongVersion = Self.t35() + wrongVersion[6] = 255 + #expect(!scan(Self.annexB(Self.sei(wrongVersion)))) + wrongVersion[6] = 1 + #expect(scan(Self.annexB(Self.sei(wrongVersion)))) + } + + @Test("The complete ST2094 body must be present without extra bytes or nonzero padding") + func bodyLengthAndPadding() { + for payload in [Self.t35(), Self.t35(windows: 3, grids: true, toneMapping: true)] { + for cut in 0..) throws -> Void + ) throws { + let packet = try #require(av_packet_alloc()) defer { - var p: UnsafeMutablePointer? = packet - av_packet_free(&p) + packet.pointee.data = nil + packet.pointee.size = 0 + var owned: UnsafeMutablePointer? = packet + av_packet_free(&owned) + } + try payload.withUnsafeBufferPointer { + packet.pointee.data = UnsafeMutablePointer(mutating: $0.baseAddress) + packet.pointee.size = Int32($0.count) + try body(packet) } - // Payload bytes are irrelevant to this carriage: matroskadec strips the T.35 header and stores a - // decoded AVDynamicHDRPlus, so the scan must key off the side-data TYPE, not the bytes. + } + + private func addSideData( + _ packet: UnsafeMutablePointer, payload: [UInt8] = Self.t35(), + mutate: (UnsafeMutablePointer) -> Void = { _ in } + ) throws { var size = 0 - guard let hdrplus = av_dynamic_hdr_plus_alloc(&size) else { - Issue.record("av_dynamic_hdr_plus_alloc failed") + let metadata = try #require(av_dynamic_hdr_plus_alloc(&size)) + let parsed = payload.withUnsafeBufferPointer { + av_dynamic_hdr_plus_from_t35(metadata, $0.baseAddress! + 6, $0.count - 6) + } + guard parsed >= 0 else { + av_free(metadata) + Issue.record("Independent ST2094 fixture did not parse: \(parsed)") return } + mutate(metadata) let added = av_packet_add_side_data( packet, AV_PKT_DATA_DYNAMIC_HDR10_PLUS, - UnsafeMutableRawPointer(hdrplus).assumingMemoryBound(to: UInt8.self), size) - guard added >= 0 else { - av_free(hdrplus) + UnsafeMutableRawPointer(metadata).assumingMemoryBound(to: UInt8.self), size) + if added < 0 { + av_free(metadata) Issue.record("av_packet_add_side_data failed: \(added)") - return } - #expect(HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet)) } - @Test("A packet with neither carriage is not detected") - func emptyPacketIsNotDetected() { - guard let packet = av_packet_alloc() else { - Issue.record("av_packet_alloc failed") - return + @Test("Real parsed Matroska side data is valid for VP9 and AV1 without a T35 header") + func parsedPacketSideData() throws { + for payload in [Self.t35(), Self.t35(windows: 3, grids: true, toneMapping: true)] { + try withPacket { packet in + try addSideData(packet, payload: payload) + #expect(HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet, codecID: AV_CODEC_ID_VP9)) + #expect(HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet, codecID: AV_CODEC_ID_AV1)) + #expect(!HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet, codecID: AV_CODEC_ID_AAC)) + } } - defer { - var p: UnsafeMutablePointer? = packet - av_packet_free(&p) - } - var payload: [UInt8] = Array(repeating: 0x00, count: 32) - payload.withUnsafeMutableBufferPointer { buf in - packet.pointee.data = buf.baseAddress - packet.pointee.size = Int32(buf.count) - #expect(!HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet)) - packet.pointee.data = nil - packet.pointee.size = 0 + } + + @Test("A side-data type alone, a raw T35 payload, and truncated or oversized structs are not proof") + func malformedSideDataStorage() throws { + var size = 0 + let metadata = try #require(av_dynamic_hdr_plus_alloc(&size)) + av_free(metadata) + for length in [1, Self.t35().count, size - 1, size, size + 1] { + try withPacket { packet in + let bytes = try #require(av_packet_new_side_data(packet, AV_PKT_DATA_DYNAMIC_HDR10_PLUS, length)) + memset(bytes, 0, length) + if length == Self.t35().count { + Self.t35().withUnsafeBufferPointer { _ = memcpy(bytes, $0.baseAddress!, $0.count) } + } + #expect(!HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet, codecID: AV_CODEC_ID_AV1)) + } } } - @Test("A packet with the in-band signature is detected") - func packetInBandIsDetected() { - guard let packet = av_packet_alloc() else { - Issue.record("av_packet_alloc failed") - return + @Test("Malformed active side-data fields are rejected without invoking FFmpeg's unsafe writer") + func malformedSideDataFields() throws { + let mutations: [(UnsafeMutablePointer) -> Void] = [ + { $0.pointee.num_windows = 0 }, + { $0.pointee.num_windows = 4 }, + { $0.pointee.application_version = 255 }, + { $0.pointee.itu_t_t35_country_code = 0xB4 }, + { $0.pointee.targeted_system_display_maximum_luminance.den = 0 }, + { $0.pointee.params.0.maxscl.0.den = 0 }, + { $0.pointee.params.0.average_maxrgb.num = -1 }, + { $0.pointee.params.0.num_distribution_maxrgb_percentiles = 16 }, + { $0.pointee.params.0.distribution_maxrgb.0.percentage = 101 }, + { $0.pointee.params.0.fraction_bright_pixels.den = 0 }, + { $0.pointee.params.0.tone_mapping_flag = 2 }, + { $0.pointee.params.0.tone_mapping_flag = 1 }, + { $0.pointee.params.0.color_saturation_mapping_flag = 1 }, + { $0.pointee.targeted_system_display_actual_peak_luminance_flag = 1 }, + { $0.pointee.mastering_display_actual_peak_luminance_flag = 2 }, + ] + for mutate in mutations { + try withPacket { packet in + try addSideData(packet, mutate: mutate) + #expect(!HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet, codecID: AV_CODEC_ID_AV1)) + } } - defer { - var p: UnsafeMutablePointer? = packet - av_packet_free(&p) - } - var payload: [UInt8] = [0x00, 0x00, 0x00, 0x01, 0x4E, 0x01] + Self.signature + [0x2A, 0x80] - payload.withUnsafeMutableBufferPointer { buf in - packet.pointee.data = buf.baseAddress - packet.pointee.size = Int32(buf.count) - #expect(HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet)) - packet.pointee.data = nil - packet.pointee.size = 0 + let optionalMutations: [(UnsafeMutablePointer) -> Void] = [ + { $0.pointee.params.1.window_upper_left_corner_x.den = 0 }, + { $0.pointee.params.1.rotation_angle = 181 }, + { $0.pointee.params.0.num_bezier_curve_anchors = 16 }, + { $0.pointee.params.0.bezier_curve_anchors.0.den = 0 }, + { $0.pointee.params.0.knee_point_x.den = 0 }, + { $0.pointee.num_rows_targeted_system_display_actual_peak_luminance = 26 }, + { $0.pointee.num_cols_mastering_display_actual_peak_luminance = 1 }, + { $0.pointee.targeted_system_display_actual_peak_luminance.0.0.den = 0 }, + { $0.pointee.mastering_display_actual_peak_luminance.0.0.num = 16 }, + ] + for mutate in optionalMutations { + try withPacket { packet in + try addSideData(packet, payload: Self.t35(windows: 3, grids: true, toneMapping: true), + mutate: mutate) + #expect(!HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet, codecID: AV_CODEC_ID_VP9)) + } + } + try withPacket(Self.annexB(Self.sei())) { packet in + try addSideData(packet) { $0.pointee.num_windows = 0 } + #expect(HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet, codecID: AV_CODEC_ID_HEVC)) + } + } + + @Test("Packet entry point derives avcC/hvcC framing and honors the playback override") + func packetCodecParameters() throws { + for hevc in [true, false] { + let codec = hevc ? AV_CODEC_ID_HEVC : AV_CODEC_ID_H264 + let parameters = try #require(avcodec_parameters_alloc()) + defer { + parameters.pointee.extradata = nil + parameters.pointee.extradata_size = 0 + var owned: UnsafeMutablePointer? = parameters + avcodec_parameters_free(&owned) + } + parameters.pointee.codec_id = codec + var extra = [UInt8](repeating: 0, count: hevc ? 23 : 7) + extra[0] = 1 + extra[hevc ? 21 : 4] = 1 // two-byte NAL lengths, not the four-byte default + try extra.withUnsafeMutableBufferPointer { buffer in + parameters.pointee.extradata = buffer.baseAddress + parameters.pointee.extradata_size = Int32(buffer.count) + try withPacket(Self.lengthPrefixed(Self.sei(hevc: hevc), width: 2)) { packet in + #expect(HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet, codecParameters: parameters)) + } + try withPacket(Self.annexB(Self.sei(hevc: hevc))) { packet in + #expect(!HDR10PlusMetadataScan.packetCarriesHDR10Plus(packet, codecParameters: parameters)) + #expect(HDR10PlusMetadataScan.packetCarriesHDR10Plus( + packet, codecParameters: parameters, framing: .annexB)) + } + } } } } diff --git a/Tests/AetherEngineTests/HDR10PlusProbeIntegrationTests.swift b/Tests/AetherEngineTests/HDR10PlusProbeIntegrationTests.swift index d310592a4..42e41bc37 100644 --- a/Tests/AetherEngineTests/HDR10PlusProbeIntegrationTests.swift +++ b/Tests/AetherEngineTests/HDR10PlusProbeIntegrationTests.swift @@ -153,9 +153,109 @@ struct HDR10PlusProbeIntegrationTests { try demuxer.open(url: url) defer { demuxer.close() } let outcome = AetherEngine.detectHDR10Plus( - demuxer: demuxer, videoIndex: demuxer.videoStreamIndex, options: HDR10PlusDetectionOptions()) + demuxer: demuxer, videoIndex: demuxer.videoStreamIndex, + options: HDR10PlusDetectionOptions(maxPackets: 1, maxBytes: 91)) #expect(outcome.stopReason == .found) #expect(outcome.packetsRead == 1) + #expect(outcome.bytesRead == 91) + } + + @Test("An oversized first video packet cannot confirm HDR10+", arguments: [Int64(1), 90]) + func oversizedPacketCannotConfirm(maxBytes: Int64) throws { + let url = try Self.writeFixture(Self.hdr10PlusBase64, name: "oversized") + defer { try? FileManager.default.removeItem(at: url) } + let demuxer = Demuxer() + try demuxer.open(url: url) + defer { demuxer.close() } + let outcome = AetherEngine.detectHDR10Plus( + demuxer: demuxer, videoIndex: demuxer.videoStreamIndex, + options: HDR10PlusDetectionOptions(maxBytes: maxBytes)) + #expect(outcome.stopReason == .byteCap) + #expect(!outcome.carriesHDR10Plus) + #expect(outcome.packetsRead == 0) + #expect(outcome.bytesRead == 0) + } + + @Test("A later carrying packet must fit the remaining byte budget", arguments: [Int64(183), 184]) + func remainingByteBudget(maxBytes: Int64) throws { + var fixture = try #require(Data(base64Encoded: Self.hdr10PlusBase64, options: .ignoreUnknownCharacters)) + let firstHeader = try #require(fixture.range(of: Data([0xB5, 0x00, 0x3C, 0x00, 0x01, 0x04]))) + fixture[firstHeader.lowerBound + 2] = 0x3B // First packet has a different registered provider. + let url = try Self.writeFixture(fixture.base64EncodedString(), name: "remaining") + defer { try? FileManager.default.removeItem(at: url) } + let demuxer = Demuxer() + try demuxer.open(url: url) + defer { demuxer.close() } + let outcome = AetherEngine.detectHDR10Plus( + demuxer: demuxer, videoIndex: demuxer.videoStreamIndex, + options: HDR10PlusDetectionOptions(maxBytes: maxBytes)) + #expect(outcome.stopReason == (maxBytes == 184 ? .found : .byteCap)) + #expect(outcome.packetsRead == (maxBytes == 184 ? 2 : 1)) + #expect(outcome.bytesRead == (maxBytes == 184 ? 184 : 91)) + } + + @Test("A read that returns at the deadline is rejected before metadata inspection") + func deadlineAfterRead() throws { + let url = try Self.writeFixture(Self.hdr10PlusBase64, name: "read-deadline") + defer { try? FileManager.default.removeItem(at: url) } + let demuxer = Demuxer() + try demuxer.open(url: url) + defer { demuxer.close() } + var clockReads = 0 + let outcome = AetherEngine.detectHDR10Plus( + demuxer: demuxer, videoIndex: demuxer.videoStreamIndex, + options: HDR10PlusDetectionOptions(timeBudget: 1), + now: { + defer { clockReads += 1 } + return clockReads < 2 ? 0 : 1_000_000_000 + }) + #expect(outcome.stopReason == .timeCap) + #expect(outcome.packetsRead == 0) + #expect(outcome.bytesRead == 0) + #expect(clockReads == 3) + } + + @Test("Metadata found at the deadline is not published as a positive") + func deadlineAfterScan() throws { + let url = try Self.writeFixture(Self.hdr10PlusBase64, name: "scan-deadline") + defer { try? FileManager.default.removeItem(at: url) } + let demuxer = Demuxer() + try demuxer.open(url: url) + defer { demuxer.close() } + var clockReads = 0 + let outcome = AetherEngine.detectHDR10Plus( + demuxer: demuxer, videoIndex: demuxer.videoStreamIndex, + options: HDR10PlusDetectionOptions(timeBudget: 1), + now: { + defer { clockReads += 1 } + return clockReads < 3 ? 0 : 1_000_000_000 + }) + #expect(outcome.stopReason == .timeCap) + #expect(!outcome.carriesHDR10Plus) + #expect(outcome.packetsRead == 1) + #expect(outcome.bytesRead == 91) + #expect(clockReads == 4) + } + + @Test("A read returning EOF after the deadline still reports the time cap") + func deadlineAtEOF() throws { + let url = try Self.writeFixture(Self.hdr10Base64, name: "eof-deadline") + defer { try? FileManager.default.removeItem(at: url) } + let demuxer = Demuxer() + try demuxer.open(url: url) + defer { demuxer.close() } + var clockReads = 0 + let outcome = AetherEngine.detectHDR10Plus( + demuxer: demuxer, videoIndex: demuxer.videoStreamIndex, + options: HDR10PlusDetectionOptions(timeBudget: 1), + now: { + defer { clockReads += 1 } + return clockReads < 8 ? 0 : 1_000_000_000 + }) + #expect(outcome.stopReason == .timeCap) + #expect(outcome.packetsRead == 2) + #expect(outcome.bytesRead == 38) + #expect(clockReads == 9) } @Test("A source scanned to its end without a hit reports EOF, not a cap") @@ -200,4 +300,30 @@ struct HDR10PlusProbeIntegrationTests { #expect(probe.carriesHDR10PlusMetadata) #expect(probe.videoFormat == .hdr10Plus) } + + @Test("Combined HDR/Atmos probing skips audio work for no-audio and non-EAC3 sources", + arguments: [0, 1, 2]) + func combinedDetailsPreserveHDRIndependence(fixtureIndex: Int) throws { + let fixtures = [Self.hdr10PlusBase64, Self.hdr10Base64, AtmosDetectionProbeIntegrationTests.aacBase64] + let formats: [VideoFormat] = [.hdr10Plus, .hdr10, .sdr] + let data = try #require(Data(base64Encoded: fixtures[fixtureIndex], options: .ignoreUnknownCharacters)) + let hdrReader = ProbeRecordingReader(data: data) + let combinedReader = ProbeRecordingReader(data: data) + let hdrOnly = try AetherEngine.probe( + source: .custom(hdrReader, formatHint: "mp4"), detecting: .hdr10Plus) + let combined = try AetherEngine.probe( + source: .custom(combinedReader, formatHint: "mp4"), detecting: [.hdr10Plus, .atmos]) + + #expect(combined.videoFormat == formats[fixtureIndex]) + #expect(combined.videoFormat == hdrOnly.videoFormat) + #expect(combined.carriesHDR10PlusMetadata == (fixtureIndex == 0)) + #expect(combined.carriesHDR10PlusMetadata == hdrOnly.carriesHDR10PlusMetadata) + #expect(combined.audioTracks == hdrOnly.audioTracks) + #expect(combined.audioTracks.count == (fixtureIndex == 2 ? 1 : 0)) + #expect(combined.audioTracks.allSatisfy { !$0.isAtmos }) + #expect(combinedReader.seeks.map(\.offset) == hdrReader.seeks.map(\.offset)) + #expect(combinedReader.seeks.map(\.whence) == hdrReader.seeks.map(\.whence)) + #expect(combinedReader.reads.map(\.offset) == hdrReader.reads.map(\.offset)) + #expect(combinedReader.bytesRead == hdrReader.bytesRead) + } } diff --git a/Tests/AetherEngineTests/ProbeControlTests.swift b/Tests/AetherEngineTests/ProbeControlTests.swift new file mode 100644 index 000000000..f1fe7f5d4 --- /dev/null +++ b/Tests/AetherEngineTests/ProbeControlTests.swift @@ -0,0 +1,1166 @@ +import Foundation +import Testing +import AetherLibavcodec +import AetherLibavformat +@testable import AetherEngine + +@Suite("Whole-probe limits, cancellation and caller ownership") +struct ProbeControlTests { + // Integration tests are not time-budget tests: a loaded runner must not change their answer. + private static let ample = ProbeLimits( + maxInputBytes: 8 * 1024 * 1024, maxPackets: 128, + maxPacketBytes: 2 * 1024 * 1024, timeBudget: 3600) + private static let atmos = AtmosDetectionOptions(timeBudget: 3600) + private static let hdr = HDR10PlusDetectionOptions(timeBudget: 3600) + + private static func control( + _ limits: ProbeLimits? = ample, + cancellation: ProbeCancellation? = nil + ) throws -> ProbeControl { + try ProbeControl(limits: limits, cancellation: cancellation, now: { 100 }, scheduleDeadline: false) + } + + private static func read(_ reader: IOReader, size: Int32) -> Int32 { + var buffer = [UInt8](repeating: 0, count: Int(size)) + return buffer.withUnsafeMutableBufferPointer { reader.read($0.baseAddress, size: size) } + } + + private static func expectCancellation(_ outcome: Result) { + switch outcome { + case .success: Issue.record("A cancelled probe published a result") + case .failure(let error): #expect(error is CancellationError) + } + } + + enum EntryPoint: CaseIterable, Equatable, Sendable { + case base, details, atmos + + func probe( + _ source: MediaSource, + limits: ProbeLimits? = nil, + cancellation: ProbeCancellation? = nil + ) throws -> SourceProbe { + switch self { + case .base: + return try AetherEngine.probe(source: source, limits: limits, cancellation: cancellation) + case .details: + return try AetherEngine.probe( + source: source, detecting: [.hdr10Plus, .atmos], + atmosDetection: ProbeControlTests.atmos, hdr10PlusDetection: ProbeControlTests.hdr, + limits: limits, cancellation: cancellation) + case .atmos: + return try AetherEngine.probeDetectingAtmos( + source: source, atmosDetection: ProbeControlTests.atmos, + limits: limits, cancellation: cancellation) + } + } + + func probe( + _ url: URL, + limits: ProbeLimits? = nil, + cancellation: ProbeCancellation? = nil + ) throws -> SourceProbe { + switch self { + case .base: + return try AetherEngine.probe(url: url, limits: limits, cancellation: cancellation) + case .details: + return try AetherEngine.probe( + url: url, detecting: [.hdr10Plus, .atmos], + atmosDetection: ProbeControlTests.atmos, hdr10PlusDetection: ProbeControlTests.hdr, + limits: limits, cancellation: cancellation) + case .atmos: + return try AetherEngine.probeDetectingAtmos( + url: url, atmosDetection: ProbeControlTests.atmos, + limits: limits, cancellation: cancellation) + } + } + } + + // MARK: Validation and monotonic deadlines + + @Test("Every invalid whole-probe limit fails before input", arguments: [ + ProbeLimits(maxInputBytes: -1), + ProbeLimits(maxPackets: -1), + ProbeLimits(maxPacketBytes: -1), + ProbeLimits(timeBudget: -1), + ProbeLimits(timeBudget: .infinity), + ProbeLimits(timeBudget: -.infinity), + ProbeLimits(timeBudget: .nan), + ]) + func invalidLimits(_ limits: ProbeLimits) throws { + #expect(throws: ProbeError.invalidLimits) { try Self.control(limits) } + let reader = ProbeRecordingReader(data: try ProbeTestFixtures.hdr10Plus()) + let token = ProbeCancellation() + #expect(throws: ProbeError.invalidLimits) { + try AetherEngine.probe(source: .custom(reader), limits: limits, cancellation: token) + } + token.cancel() + #expect(reader.reads.isEmpty) + #expect(reader.seeks.isEmpty) + #expect(reader.cancelCount == 0) + #expect(reader.closeCount == 0) + } + + @Test("Zero limits are valid, and the zero deadline is already expired") + func zeroLimitsAreNotInvalid() throws { + let control = try Self.control(.init( + maxInputBytes: 0, maxPackets: 0, maxPacketBytes: 0, timeBudget: 0)) + defer { control.finish() } + #expect(throws: ProbeError.timedOut) { try control.check() } + } + + @Test("A huge finite deadline schedules safely and still expires on the injected clock") + func hugeFiniteDeadlineDoesNotOverflowDispatchTime() throws { + let clock = ProbeTestBox(0) + let control = try ProbeControl( + limits: .init(timeBudget: Double.greatestFiniteMagnitude), cancellation: nil, + now: { clock.value }, scheduleDeadline: true) + defer { control.finish() } + // Keep scheduling enabled: disabling the watchdog would bypass the conversion regression. + try control.check() + #expect(!control.isStopped) + clock.update { $0 = Double.greatestFiniteMagnitude } + #expect(throws: ProbeError.timedOut) { try control.check() } + #expect(throws: ProbeError.timedOut) { try control.complete() } + } + + @Test("Without limits there is no implicit deadline, byte cap or packet cap") + func absentLimitsAreUnbounded() throws { + let clock = ProbeTestBox(0) + let control = try ProbeControl( + limits: nil, cancellation: nil, now: { clock.value }, scheduleDeadline: false) + defer { control.finish() } + #expect(try control.inputAllowance(Int32.max) == Int32.max) + try control.consumedInput(Int32.max, requested: Int32.max) + clock.update { $0 = 1_000_000 } + #expect(try control.inputAllowance(Int32.max) == Int32.max) + var packet = AVPacket() + packet.size = Int32.max + for _ in 0..<129 { + try control.willReadPacket() + try control.receivedPacket(&packet) + } + try control.complete() + } + + @Test("A monotonic deadline fires exactly at the boundary and only interrupts once") + func deadlineBoundary() throws { + let clock = ProbeTestBox(10) + let interrupts = ProbeTestBox(0) + let control = try ProbeControl( + limits: .init(timeBudget: 5), cancellation: nil, + now: { clock.value }, scheduleDeadline: false) + defer { control.finish() } + control.interrupt { interrupts.update { $0 += 1 } } + clock.update { $0 = 14.999 } + try control.check() + #expect(!control.isStopped) + #expect(interrupts.value == 0) + clock.update { $0 = 15 } + #expect(throws: ProbeError.timedOut) { try control.check() } + #expect(throws: ProbeError.timedOut) { try control.complete() } + #expect(control.isStopped) + #expect(interrupts.value == 1) + } + + @Test("Completion checks time even without another read or watchdog tick") + func completionChecksDeadline() throws { + let clock = ProbeTestBox(10) + let control = try ProbeControl( + limits: .init(timeBudget: 5), cancellation: nil, + now: { clock.value }, scheduleDeadline: false) + defer { control.finish() } + try control.check() + clock.update { $0 = 15 } + #expect(throws: ProbeError.timedOut) { try control.complete() } + } + + @Test("A real positive HDR10+ finding cannot commit after the whole-probe deadline") + func positiveDetailCannotCompletePastDeadline() throws { + let clock = ProbeTestBox(0) + let control = try ProbeControl( + limits: .init(timeBudget: 1), cancellation: nil, + now: { clock.value }, scheduleDeadline: false) + let reader = ProbeRecordingReader(data: try ProbeTestFixtures.hdr10Plus()) + let demuxer = Demuxer() + demuxer.probeControl = control + defer { control.finish(); demuxer.close() } + try demuxer.open( + reader: ProbeIOReader(reader: reader, control: control), + formatHint: "mp4", profile: .stillExtraction) + let finding = AetherEngine.detectHDR10Plus( + demuxer: demuxer, videoIndex: demuxer.videoStreamIndex, options: Self.hdr) + try #require(finding.carriesHDR10Plus) + let reads = reader.reads.count + clock.update { $0 = 1 } + #expect(throws: ProbeError.timedOut) { try control.complete() } + #expect(reader.reads.count == reads) + #expect(reader.closeCount == 0) + } + + @Test("A read or seek finishing after the deadline cannot deliver a late success", + arguments: ProbeParkedReader.Operation.allCases) + func lateIOIsRejected(_ operation: ProbeParkedReader.Operation) throws { + let clock = ProbeTestBox(0) + let control = try ProbeControl( + limits: .init(timeBudget: 1), cancellation: nil, + now: { clock.value }, scheduleDeadline: false) + defer { control.finish() } + let expire: @Sendable () -> Void = { clock.update { $0 = 1 } } + let reader = ProbeRecordingReader( + data: Data([1, 2, 3]), afterRead: operation == .read ? expire : nil, + afterSeek: operation == .seek ? expire : nil) + let counted = ProbeIOReader(reader: reader, control: control) + if operation == .read { + #expect(Self.read(counted, size: 3) == -1) + #expect(reader.bytesRead == 3) + } else { + #expect(counted.seek(offset: 0, whence: SEEK_SET) == -1) + #expect(reader.seeks.count == 1) + } + #expect(throws: ProbeError.timedOut) { try control.complete() } + #expect(reader.cancelCount == 1) + #expect(reader.closeCount == 0) + } + + @Test("The first whole-probe failure remains the typed cause") + func firstFailureWins() throws { + let control = try Self.control() + defer { control.finish() } + control.stop(ProbeError.inputLimit) + control.stop(ProbeError.timedOut) + #expect(throws: ProbeError.inputLimit) { try control.check() } + #expect(throws: ProbeError.inputLimit) { try control.complete() } + } + + // MARK: Byte and packet accounting seams + + @Test("Allowance clamps the request, charges actual bytes, and does not charge EOF or errors") + func inputAccounting() throws { + let control = try Self.control(.init(maxInputBytes: 10)) + defer { control.finish() } + #expect(try control.inputAllowance(Int32.max) == 10) + try control.consumedInput(3, requested: 10) + #expect(try control.inputAllowance(100) == 7) + try control.consumedInput(0, requested: 7) + try control.consumedInput(-1, requested: 7) + #expect(try control.inputAllowance(100) == 7) + try control.consumedInput(7, requested: 7) + try control.check() + #expect(throws: ProbeError.inputLimit) { try control.inputAllowance(1) } + #expect(throws: ProbeError.inputLimit) { try control.complete() } + } + + @Test("The exact byte boundary can complete if no further input is needed") + func exactInputBoundaryCanComplete() throws { + let control = try Self.control(.init(maxInputBytes: 3)) + defer { control.finish() } + #expect(try control.inputAllowance(20) == 3) + try control.consumedInput(3, requested: 3) + try control.complete() + } + + @Test("Large valid byte limits do not narrow or overflow the reader's Int32 request") + func largeInputLimit() throws { + let control = try Self.control(.init(maxInputBytes: Int64.max)) + defer { control.finish() } + #expect(try control.inputAllowance(Int32.max) == Int32.max) + try control.consumedInput(Int32.max, requested: Int32.max) + #expect(try control.inputAllowance(Int32.max) == Int32.max) + } + + @Test("Seek rereads consume the same input budget, and a refused read never reaches the host") + func rereadsAreCounted() throws { + let control = try Self.control(.init(maxInputBytes: 7)) + defer { control.finish() } + let reader = ProbeRecordingReader(data: Data([1, 2, 3, 4])) + let counted = ProbeIOReader(reader: reader, control: control) + #expect(Self.read(counted, size: 4) == 4) + #expect(counted.seek(offset: 0, whence: SEEK_SET) == 0) + #expect(counted.seek(offset: 0, whence: 0x10000) == 4) + #expect(Self.read(counted, size: 4) == 3) + #expect(Self.read(counted, size: 4) == -1) + #expect(reader.reads.map(\.requested) == [4, 3]) + #expect(reader.reads.map(\.offset) == [0, 0]) + #expect(reader.bytesRead == 7) + #expect(throws: ProbeError.inputLimit) { try control.complete() } + let seekCount = reader.seeks.count + #expect(counted.seek(offset: 0, whence: SEEK_SET) == -1) + #expect(reader.seeks.count == seekCount) + #expect(reader.cancelCount == 1) + } + + @Test("A reader claiming more than its clamped request fails closed") + func oversizedReaderResult() throws { + final class LyingReader: IOReader, @unchecked Sendable { + let discImageProbeEnabled = false + func read(_ buffer: UnsafeMutablePointer?, size: Int32) -> Int32 { size + 1 } + func seek(offset: Int64, whence: Int32) -> Int64 { + whence == 0x10000 ? -1 : offset + } + func close() {} + } + let control = try Self.control(.init(maxInputBytes: 3)) + defer { control.finish() } + let counted = ProbeIOReader(reader: LyingReader(), control: control) + #expect(Self.read(counted, size: 10) == -1) + #expect(throws: ProbeError.invalidReaderResult) { try control.check() } + #expect(throws: ProbeError.invalidReaderResult) { try control.complete() } + #expect(throws: ProbeError.invalidReaderResult) { + try AetherEngine.probe( + source: .custom(LyingReader(), formatHint: "mp4"), + limits: .init(maxInputBytes: 3, timeBudget: 3600)) + } + } + + @Test("One packet budget covers every stream and later pass") + func packetBudgetIsShared() throws { + let control = try Self.control(.init(maxPackets: 2, maxPacketBytes: 4)) + defer { control.finish() } + var packet = AVPacket() + packet.size = 4 + for stream in [Int32(0), Int32(7)] { + packet.stream_index = stream + try control.willReadPacket() + try control.receivedPacket(&packet) + } + try control.check() + #expect(throws: ProbeError.packetLimit) { try control.willReadPacket() } + #expect(throws: ProbeError.packetLimit) { try control.complete() } + } + + @Test("A single oversize packet is rejected independently of packet count") + func packetSizeBoundary() throws { + let control = try Self.control(.init(maxPackets: 10, maxPacketBytes: 4)) + defer { control.finish() } + var packet = AVPacket() + packet.size = 4 + try control.willReadPacket() + try control.receivedPacket(&packet) + packet.size = 5 + try control.willReadPacket() + #expect(throws: ProbeError.packetSizeLimit) { try control.receivedPacket(&packet) } + #expect(throws: ProbeError.packetSizeLimit) { try control.complete() } + } + + @Test("Zero packet and packet-size budgets are enforced separately") + func zeroPacketBudgets() throws { + let none = try Self.control(.init(maxPackets: 0)) + defer { none.finish() } + #expect(throws: ProbeError.packetLimit) { try none.willReadPacket() } + + let emptyOnly = try Self.control(.init(maxPacketBytes: 0)) + defer { emptyOnly.finish() } + var packet = AVPacket() + try emptyOnly.willReadPacket() + try emptyOnly.receivedPacket(&packet) + packet.size = 1 + #expect(throws: ProbeError.packetSizeLimit) { try emptyOnly.receivedPacket(&packet) } + } + + // MARK: Token lifecycle and races + + @Test("A precancelled token interrupts even when the reader is attached later") + func cancellationBeforeRegistration() throws { + let token = ProbeCancellation() + token.cancel() + let control = try Self.control(nil, cancellation: token) + defer { control.finish() } + let calls = ProbeTestBox(0) + control.interrupt { calls.update { $0 += 1 } } + token.cancel() + #expect(token.isCancelled) + #expect(calls.value == 1) + #expect(throws: CancellationError.self) { try control.check() } + #expect(throws: CancellationError.self) { try control.complete() } + } + + @Test("A shared token interrupts active registrations but not finished ones") + func tokenFanoutAndRemoval() throws { + let token = ProbeCancellation() + let first = try Self.control(nil, cancellation: token) + let second = try Self.control(nil, cancellation: token) + let third = try Self.control(nil, cancellation: token) + defer { first.finish(); second.finish(); third.finish() } + let calls = ProbeTestBox([0, 0, 0]) + first.interrupt { calls.update { $0[0] += 1 } } + second.interrupt { calls.update { $0[1] += 1 } } + third.interrupt { calls.update { $0[2] += 1 } } + second.finish() + second.finish() + token.cancel() + token.cancel() + #expect(calls.value == [1, 0, 1]) + #expect(throws: CancellationError.self) { try first.complete() } + #expect(throws: CancellationError.self) { try third.complete() } + } + + @Test("Finishing drops callback captures before the caller reuses its reader") + func finishReleasesCallback() throws { + let token = ProbeCancellation() + let control = try Self.control(nil, cancellation: token) + defer { control.finish() } + weak var released: ProbeTestBox? + do { + let capture = ProbeTestBox(0) + released = capture + control.interrupt { capture.update { $0 += 1 } } + } + #expect(released != nil) + control.finish() + #expect(released == nil) + token.cancel() + #expect(released == nil) + } + + @Test("Cancellation after the result's commit cannot touch the caller's reader") + func completionWinsAgainstLaterCancellation() throws { + let token = ProbeCancellation() + let calls = ProbeTestBox(0) + let control = try Self.control(nil, cancellation: token) + defer { control.finish() } + control.interrupt { calls.update { $0 += 1 } } + try control.complete() + token.cancel() + #expect(calls.value == 0) + try control.check() + } + + @Test("Cancellation callbacks may safely cancel the token reentrantly", .timeLimit(.minutes(1))) + func reentrantCancellation() async throws { + let token = ProbeCancellation() + let calls = ProbeTestBox(0) + let control = try Self.control(nil, cancellation: token) + defer { control.finish() } + control.interrupt { + calls.update { $0 += 1 } + token.cancel() + } + let job = ProbeTestJob { token.cancel() } + _ = try await job.outcome().get() + #expect(calls.value == 1) + } + + @Test("Concurrent cancels deliver each callback once", .timeLimit(.minutes(1))) + func racingCancellationIsOneShot() async throws { + let token = ProbeCancellation() + let gate = ProbeTestGate() + let calls = ProbeTestBox(0) + let control = try Self.control(nil, cancellation: token) + defer { gate.open(); control.finish() } + control.interrupt { calls.update { $0 += 1 } } + let jobs = (0..<8).map { _ in + ProbeTestJob { gate.wait(); token.cancel() } + } + gate.open() + for job in jobs { _ = try await job.outcome().get() } + #expect(calls.value == 1) + #expect(throws: CancellationError.self) { try control.complete() } + } + + @Test("Cancellation and completion have one winner", .timeLimit(.minutes(1))) + func cancellationCompletionRace() async throws { + for _ in 0..<16 { + let token = ProbeCancellation() + let gate = ProbeTestGate() + let calls = ProbeTestBox(0) + let control = try Self.control(nil, cancellation: token) + defer { gate.open(); control.finish() } + control.interrupt { calls.update { $0 += 1 } } + let cancelling = ProbeTestJob { gate.wait(); token.cancel() } + let completing = ProbeTestJob { gate.wait(); try control.complete() } + gate.open() + let outcome = try await completing.outcome() + _ = try await cancelling.outcome().get() + switch outcome { + case .success: #expect(calls.value == 0) + case .failure(let error): + #expect(error is CancellationError) + #expect(calls.value == 1) + } + control.finish() + token.cancel() + #expect(calls.value <= 1) + } + } + + @Test("Finish joins a callback already running on the cancelling thread", .timeLimit(.minutes(1))) + func finishJoinsRunningCallback() async throws { + let token = ProbeCancellation() + let callbackGate = ProbeTestGate() + let callbackExited = ProbeTestBox(false) + let finishEntered = ProbeTestBox(false) + let control = try Self.control(nil, cancellation: token) + defer { callbackGate.open(); control.finish() } + control.interrupt { + callbackGate.wait() + callbackExited.update { $0 = true } + } + let cancelling = ProbeTestJob { token.cancel() } + try await waitFor { callbackGate.entered } + let finishing = ProbeTestJob { + finishEntered.update { $0 = true } + control.finish() + return callbackExited.value + } + try await waitFor { finishEntered.value } + #expect(!finishing.isFinished) + callbackGate.open() + #expect(try await finishing.outcome().get()) + _ = try await cancelling.outcome().get() + control.finish() + } + + // MARK: Real custom and file probes + + @Test("All custom overloads reject precancellation without reading, seeking or closing", + arguments: EntryPoint.allCases) + func customPrecancelled(_ entry: EntryPoint) throws { + let token = ProbeCancellation() + token.cancel() + let reader = ProbeRecordingReader(data: try ProbeTestFixtures.hdr10Plus(), discImageProbeEnabled: true) + #expect(throws: CancellationError.self) { + try entry.probe(.custom(reader, formatHint: "mp4"), cancellation: token) + } + #expect(reader.reads.isEmpty) + #expect(reader.seeks.isEmpty) + #expect(reader.cancelCount == 0) + #expect(reader.closeCount == 0) + } + + @Test("All URL overloads reject precancellation before trying to open a missing file", + arguments: EntryPoint.allCases) + func filePrecancelled(_ entry: EntryPoint) { + let token = ProbeCancellation() + token.cancel() + let missing = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent(".missing-probe-\(UUID().uuidString).mp4") + #expect(throws: CancellationError.self) { try entry.probe(missing, cancellation: token) } + } + + @Test("A zero whole-probe deadline does no input") + func zeroDeadlineBeforeInput() throws { + let reader = ProbeRecordingReader(data: try ProbeTestFixtures.hdr10Plus(), discImageProbeEnabled: true) + #expect(throws: ProbeError.timedOut) { + try AetherEngine.probe(source: .custom(reader), limits: .init(timeBudget: 0)) + } + #expect(reader.reads.isEmpty) + #expect(reader.seeks.isEmpty) + #expect(reader.closeCount == 0) + } + + @Test("Opening and stream analysis obey the input cap even with short host reads") + func openInputLimit() throws { + let reader = ProbeRecordingReader(data: try ProbeTestFixtures.hdr10Plus(), chunkSize: 7) + var limits = Self.ample + limits.maxInputBytes = 31 + #expect(throws: ProbeError.inputLimit) { + try AetherEngine.probe(source: .custom(reader, formatHint: "mp4"), limits: limits) + } + #expect(reader.bytesRead == 31) + #expect(reader.reads.last?.requested == 3) + var remaining: Int64 = 31 + for read in reader.reads { + #expect(Int64(read.requested) <= remaining) + remaining -= Int64(read.returned) + } + #expect(reader.cancelCount == 1) + #expect(reader.closeCount == 0) + } + + @Test("Sparse ISO and UDF signature reads share the opening input cap", arguments: [3, 6, 8]) + func discSniffCountsTowardInput(_ cap: Int) throws { + let reader = ProbeRecordingReader( + data: Data(repeating: 0, count: 256 * 2048 + 2), discImageProbeEnabled: true) + var limits = Self.ample + limits.maxInputBytes = Int64(cap) + #expect(throws: ProbeError.inputLimit) { + try AetherEngine.probe(source: .custom(reader, formatHint: "mp4"), limits: limits) + } + #expect(reader.bytesRead == Int64(cap)) + #expect(reader.reads.first?.offset == 0x8001) + #expect(reader.reads.first?.requested == Int32(min(cap, 5))) + if cap > 5 { + try #require(reader.reads.count >= 2) + #expect(reader.reads[1].offset == 256 * 2048) + #expect(reader.reads[1].requested == Int32(min(cap - 5, 2))) + } + if cap > 7 { + #expect(reader.reads.last?.offset == 0) + #expect(reader.reads.last?.requested == 1) + } + #expect(reader.closeCount == 0) + } + + @Test("Disc parsing after a positive signature is charged below the adapter") + func isoMetadataCountsTowardInput() { + let reader = ProbeRecordingReader( + data: ISO9660Fixture.make(files: [.init(name: "VTS_01_1.VOB", length: 2048)]), + discImageProbeEnabled: true) + var limits = Self.ample + limits.maxInputBytes = 37 + #expect(throws: ProbeError.inputLimit) { + try AetherEngine.probe(source: .custom(reader), limits: limits) + } + #expect(reader.reads.first?.returned == 5) + #expect(reader.bytesRead == 37) + #expect(reader.reads.dropFirst().contains { $0.offset == 16 * 2048 }) + #expect(reader.closeCount == 0) + } + + @Test("Base probing needs no inspection packets and preserves ordinary metadata") + func baseProbeWithZeroPacketBudgets() throws { + let bytes = try ProbeTestFixtures.hdr10Plus() + let baseline = try AetherEngine.probe(source: .custom(DataIOReader(data: bytes), formatHint: "mp4")) + let reader = ProbeRecordingReader(data: bytes) + var limits = Self.ample + limits.maxPackets = 0 + limits.maxPacketBytes = 0 + let actual = try AetherEngine.probe(source: .custom(reader, formatHint: "mp4"), limits: limits) + #expect(actual.videoCodecID == baseline.videoCodecID) + #expect(actual.videoWidth == baseline.videoWidth) + #expect(actual.videoHeight == baseline.videoHeight) + #expect(actual.durationSeconds == baseline.durationSeconds) + #expect(actual.videoFormat == baseline.videoFormat) + #expect(!actual.carriesHDR10PlusMetadata) + #expect(reader.bytesRead > 0) + #expect(reader.cancelCount == 0) + #expect(reader.closeCount == 0) + } + + @Test("Bounded file and custom detail probes still find real HDR10+") + func controlledPositiveResults() throws { + let bytes = try ProbeTestFixtures.hdr10Plus() + let custom = try AetherEngine.probe( + source: .custom(ProbeRecordingReader(data: bytes), formatHint: "mp4"), detecting: .hdr10Plus, + hdr10PlusDetection: Self.hdr, limits: Self.ample, cancellation: ProbeCancellation()) + let file = try ProbeTestFixtures.withFile(bytes) { url in + try AetherEngine.probe( + url: url, detecting: .hdr10Plus, hdr10PlusDetection: Self.hdr, + limits: Self.ample, cancellation: ProbeCancellation()) + } + #expect(custom.carriesHDR10PlusMetadata) + #expect(file.carriesHDR10PlusMetadata) + #expect(custom.videoFormat == .hdr10Plus) + #expect(file.videoFormat == .hdr10Plus) + #expect(file.videoWidth == custom.videoWidth) + } + + @Test("Per-detail zero budgets remain soft stops, unlike whole-probe limits") + func detailCapsRemainSoft() throws { + var limits = Self.ample + limits.maxPackets = 0 + let probe = try AetherEngine.probe( + source: .custom(ProbeRecordingReader(data: try ProbeTestFixtures.combined()), formatHint: "mp4"), + detecting: [.hdr10Plus, .atmos], + atmosDetection: .init(maxPackets: 0, timeBudget: 3600), + hdr10PlusDetection: .init(maxPackets: 0, timeBudget: 3600), + limits: limits) + #expect(!probe.carriesHDR10PlusMetadata) + #expect(!probe.audioTracks.isEmpty) + #expect(probe.audioTracks.allSatisfy { !$0.isAtmos }) + } + + @Test("Whole packet limits are thrown, never folded into an inconclusive detail", + arguments: [false, true]) + func realPacketLimit(_ atmos: Bool) throws { + let bytes = try (atmos ? ProbeTestFixtures.eac3() : ProbeTestFixtures.hdr10Plus()) + let reader = ProbeRecordingReader(data: bytes) + var limits = Self.ample + limits.maxPackets = 0 + #expect(throws: ProbeError.packetLimit) { + try AetherEngine.probe( + source: .custom(reader, formatHint: "mp4"), detecting: atmos ? .atmos : .hdr10Plus, + atmosDetection: Self.atmos, hdr10PlusDetection: Self.hdr, limits: limits) + } + #expect(reader.cancelCount == 1) + #expect(reader.closeCount == 0) + } + + @Test("Whole individual-packet limits reject both scan and decode payloads", + arguments: [false, true]) + func realPacketSizeLimit(_ atmos: Bool) throws { + let bytes = try (atmos ? ProbeTestFixtures.eac3() : ProbeTestFixtures.hdr10Plus()) + var limits = Self.ample + limits.maxPacketBytes = 0 + #expect(throws: ProbeError.packetSizeLimit) { + try AetherEngine.probe( + source: .custom(ProbeRecordingReader(data: bytes), formatHint: "mp4"), + detecting: atmos ? .atmos : .hdr10Plus, + atmosDetection: Self.atmos, hdr10PlusDetection: Self.hdr, limits: limits) + } + } + + @Test("A real HDR10+ packet fits at its size boundary but not one byte below it") + func realPacketSizeBoundary() throws { + let bytes = try ProbeTestFixtures.hdr10Plus() + let budget = try ProbeTestFixtures.firstVideoBudget(bytes) + var limits = Self.ample + limits.maxPackets = budget.packets + limits.maxPacketBytes = budget.bytes + let probe = try AetherEngine.probe( + source: .custom(ProbeRecordingReader(data: bytes), formatHint: "mp4"), + detecting: .hdr10Plus, hdr10PlusDetection: Self.hdr, limits: limits) + #expect(probe.carriesHDR10PlusMetadata) + limits.maxPacketBytes -= 1 + #expect(throws: ProbeError.packetSizeLimit) { + try AetherEngine.probe( + source: .custom(ProbeRecordingReader(data: bytes), formatHint: "mp4"), + detecting: .hdr10Plus, hdr10PlusDetection: Self.hdr, limits: limits) + } + } + + @Test("HDR then Atmos share a packet budget across the flushing seek; a partial positive is not returned") + func sharedDetailBudgetRejectsPartialPositive() throws { + let bytes = try ProbeTestFixtures.combined() + let budget = try ProbeTestFixtures.firstVideoBudget(bytes) + var limits = Self.ample + limits.maxPackets = budget.packets + let hdrOnly = try AetherEngine.probe( + source: .custom(ProbeRecordingReader(data: bytes), formatHint: "mp4"), + detecting: .hdr10Plus, hdr10PlusDetection: Self.hdr, limits: limits) + #expect(hdrOnly.carriesHDR10PlusMetadata) + #expect(!hdrOnly.audioTracks.isEmpty) + #expect(hdrOnly.audioTracks.allSatisfy { !$0.isAtmos }) + #expect(throws: ProbeError.packetLimit) { + try AetherEngine.probe( + source: .custom(ProbeRecordingReader(data: bytes), formatHint: "mp4"), + detecting: [.hdr10Plus, .atmos], atmosDetection: Self.atmos, + hdr10PlusDetection: Self.hdr, limits: limits) + } + let sufficient = try AetherEngine.probe( + source: .custom(ProbeRecordingReader(data: bytes), formatHint: "mp4"), + detecting: [.hdr10Plus, .atmos], atmosDetection: Self.atmos, + hdr10PlusDetection: Self.hdr, limits: Self.ample) + #expect(sufficient.carriesHDR10PlusMetadata) + #expect(sufficient.audioTracks.allSatisfy { !$0.isAtmos }) + } + + @Test("File overloads forward both input and packet limits", arguments: EntryPoint.allCases) + func fileLimits(_ entry: EntryPoint) throws { + try ProbeTestFixtures.withFile(ProbeTestFixtures.eac3()) { url in + var limits = Self.ample + limits.maxInputBytes = 1 + #expect(throws: ProbeError.inputLimit) { try entry.probe(url, limits: limits) } + if entry != .base { + limits = Self.ample + limits.maxPackets = 0 + #expect(throws: ProbeError.packetLimit) { try entry.probe(url, limits: limits) } + } + } + } + + @Test("A reader cancelling during read cannot publish the bytes it nevertheless returns", + arguments: EntryPoint.allCases) + func cancellationDuringSuccessfulRead(_ entry: EntryPoint) throws { + let token = ProbeCancellation() + let reader = ProbeRecordingReader( + data: try ProbeTestFixtures.hdr10Plus(), afterRead: { token.cancel() }) + #expect(throws: CancellationError.self) { + try entry.probe(.custom(reader, formatHint: "mp4"), cancellation: token) + } + #expect(reader.bytesRead > 0) + #expect(reader.reads.count == 1) + #expect(reader.cancelCount == 1) + #expect(reader.closeCount == 0) + } + + @Test("A reader cancelling from a successful seek cannot proceed to any read") + func cancellationDuringSuccessfulSeek() throws { + let token = ProbeCancellation() + let reader = ProbeRecordingReader( + data: try ProbeTestFixtures.hdr10Plus(), afterSeek: { token.cancel() }) + #expect(throws: CancellationError.self) { + try AetherEngine.probe(source: .custom(reader, formatHint: "mp4"), cancellation: token) + } + #expect(reader.seeks.count == 1) + #expect(reader.reads.isEmpty) + #expect(reader.cancelCount == 1) + #expect(reader.closeCount == 0) + } + + @Test("Cancellation wakes a parked read or seek, but cannot complete before the host returns", + .timeLimit(.minutes(1)), arguments: ProbeParkedReader.Operation.allCases) + func cancellationInterruptsParkedIO(_ operation: ProbeParkedReader.Operation) async throws { + let token = ProbeCancellation() + let reader = ProbeParkedReader(operation: operation) + defer { reader.release(); token.cancel() } + let job = ProbeTestJob { + try AetherEngine.probe(source: .custom(reader, formatHint: "mp4"), cancellation: token) + } + try await waitFor { reader.interrupted.entered } + #expect(!job.isFinished) + token.cancel() + try await waitFor { reader.mayReturn.entered } + #expect(reader.cancelCount == 1) + #expect(!job.isFinished, "Cancellation requests interruption, not an early synthetic result") + reader.mayReturn.open() + Self.expectCancellation(try await job.outcome()) + token.cancel() + #expect(reader.cancelCount == 1) + #expect(reader.closeCount == 0) + } + + @Test("A cancellation racing the reader's start remains latched before it parks", + .timeLimit(.minutes(1)), arguments: ProbeParkedReader.Operation.allCases) + func cancellationBeforeReaderParks(_ operation: ProbeParkedReader.Operation) async throws { + let token = ProbeCancellation() + let reader = ProbeParkedReader(operation: operation, pauseBeforeParking: true) + let beforePark = try #require(reader.beforePark) + defer { reader.release(); token.cancel() } + let job = ProbeTestJob { + try AetherEngine.probe(source: .custom(reader, formatHint: "mp4"), cancellation: token) + } + try await waitFor { beforePark.entered || job.isFinished } + try #require(beforePark.entered) + token.cancel() + #expect(reader.cancelCount == 1) + #expect(!reader.interrupted.entered) + #expect(!job.isFinished) + beforePark.open() + try await waitFor { reader.mayReturn.entered || job.isFinished } + try #require(reader.mayReturn.entered, "The reader must not lose cancellation before its wait starts") + #expect(!job.isFinished) + reader.mayReturn.open() + Self.expectCancellation(try await job.outcome()) + #expect(reader.cancelCount == 1) + #expect(reader.closeCount == 0) + } + + @Test("A noninterrupting reader still owns the native call after cancel has been notified", + .timeLimit(.minutes(1)), arguments: ProbeParkedReader.Operation.allCases) + func cancellationCannotCompleteUninterruptibleIO(_ operation: ProbeParkedReader.Operation) async throws { + let token = ProbeCancellation() + let reader = ProbeParkedReader(operation: operation, interruptOnCancel: false) + defer { reader.release(); token.cancel() } + let job = ProbeTestJob { + try AetherEngine.probe(source: .custom(reader, formatHint: "mp4"), cancellation: token) + } + try await waitFor { reader.interrupted.entered || job.isFinished } + try #require(reader.interrupted.entered) + token.cancel() + #expect(reader.cancelCount == 1) + #expect(!reader.interrupted.isOpen) + #expect(!reader.mayReturn.entered) + #expect(!job.isFinished, "Notification alone cannot free native state or return a result") + reader.interrupted.open() + try await waitFor { reader.mayReturn.entered || job.isFinished } + try #require(reader.mayReturn.entered) + #expect(!job.isFinished) + reader.mayReturn.open() + Self.expectCancellation(try await job.outcome()) + #expect(reader.closeCount == 0) + } + + @Test("An injected deadline interrupts parked I/O without relying on a wall-clock timer", + .timeLimit(.minutes(1)), arguments: ProbeParkedReader.Operation.allCases) + func deadlineInterruptsParkedIO(_ operation: ProbeParkedReader.Operation) async throws { + let clock = ProbeTestBox(0) + let control = try ProbeControl( + limits: .init(timeBudget: 1), cancellation: nil, + now: { clock.value }, scheduleDeadline: false) + let reader = ProbeParkedReader(operation: operation) + let counted = ProbeIOReader(reader: reader, control: control) + defer { reader.release(); control.finish() } + let job = ProbeTestJob { + operation == .read + ? Int64(Self.read(counted, size: 8)) + : counted.seek(offset: 0, whence: SEEK_SET) + } + try await waitFor { reader.interrupted.entered } + clock.update { $0 = 1 } + #expect(throws: ProbeError.timedOut) { try control.check() } + try await waitFor { reader.mayReturn.entered } + #expect(!job.isFinished) + reader.mayReturn.open() + #expect(try await job.outcome().get() == -1) + #expect(throws: ProbeError.timedOut) { try control.complete() } + #expect(reader.cancelCount == 1) + #expect(reader.closeCount == 0) + } + + @Test("A native AVIO seek waits for the interrupted host callback to unwind", + .timeLimit(.minutes(1)), arguments: [false, true]) + func nativeSeekInterruption(_ expireDeadline: Bool) async throws { + let token = ProbeCancellation() + let clock = ProbeTestBox(0) + let control = try ProbeControl( + limits: .init(timeBudget: 1), cancellation: token, + now: { clock.value }, scheduleDeadline: false) + let armed = ProbeTestBox(false) + let interrupted = ProbeTestGate() + let mayReturn = ProbeTestGate() + defer { interrupted.open(); mayReturn.open() } + let reader = ProbeRecordingReader( + data: Data([1, 2, 3]), + afterSeek: { + if armed.value { + interrupted.wait() + mayReturn.wait() + } + }, + afterCancel: { interrupted.open() }) + let job = ProbeTestJob { + let counted = ProbeIOReader(reader: reader, control: control) + let bridge = CustomIOReaderBridge(reader: counted) + defer { control.finish(); bridge.close() } + try bridge.open() + let context = try #require(bridge.context) + // Bypass AVIO's in-buffer seek shortcut: this test needs a real host callback. + context.pointee.direct = 1 + armed.update { $0 = true } + return avio_seek(context, 1024 * 1024, SEEK_SET) + } + try await waitFor { interrupted.entered || job.isFinished } + try #require(interrupted.entered) + if expireDeadline { + clock.update { $0 = 1 } + #expect(throws: ProbeError.timedOut) { try control.check() } + } else { + token.cancel() + } + try await waitFor { mayReturn.entered || job.isFinished } + try #require(mayReturn.entered) + #expect(!job.isFinished, "The native avio_seek call still owns the parked callback") + mayReturn.open() + #expect(try await job.outcome().get() < 0) + if expireDeadline { + #expect(throws: ProbeError.timedOut) { try control.check() } + } else { + #expect(throws: CancellationError.self) { try control.check() } + } + let requestedOffset = reader.seeks.last?.offset + let expectedOffset: Int64 = 1024 * 1024 + #expect(requestedOffset == expectedOffset) + #expect(reader.reads.isEmpty) + #expect(reader.cancelCount == 1) + #expect(reader.closeCount == 0) + } + + @Test("A real demuxer refuses a flushing seek once its injected deadline expires") + func demuxerSeekAfterDeadline() throws { + let clock = ProbeTestBox(0) + let control = try ProbeControl( + limits: .init(timeBudget: 1), cancellation: nil, + now: { clock.value }, scheduleDeadline: false) + let reader = ProbeRecordingReader(data: try ProbeTestFixtures.hdr10Plus()) + let demuxer = Demuxer() + demuxer.probeControl = control + defer { control.finish(); demuxer.close() } + try demuxer.open( + reader: ProbeIOReader(reader: reader, control: control), + formatHint: "mp4", profile: .stillExtraction) + let reads = reader.reads.count + let seeks = reader.seeks.count + clock.update { $0 = 1 } + #expect(!demuxer.seekBounded(to: 0, timeout: 3600)) + #expect(throws: ProbeError.timedOut) { try control.check() } + #expect(reader.reads.count == reads) + #expect(reader.seeks.count == seeks) + #expect(reader.cancelCount == 1) + #expect(reader.closeCount == 0) + } + + @Test("Normal completion unregisters cancellation and leaves the same reader reusable") + func successfulReaderReuse() throws { + let oldToken = ProbeCancellation() + let reader = ProbeRecordingReader(data: try ProbeTestFixtures.hdr10Plus()) + let first = try AetherEngine.probe( + source: .custom(reader, formatHint: "mp4"), limits: Self.ample, cancellation: oldToken) + oldToken.cancel() + #expect(reader.cancelCount == 0) + #expect(reader.closeCount == 0) + let secondToken = ProbeCancellation() + let second = try AetherEngine.probe( + source: .custom(reader, formatHint: "mp4"), limits: Self.ample, cancellation: secondToken) + secondToken.cancel() + #expect(first.videoCodecID == second.videoCodecID) + #expect(first.videoWidth == second.videoWidth) + #expect(reader.cancelCount == 0) + #expect(reader.closeCount == 0) + } + + @Test("A stopped probe unregisters its token and does not poison a new probe on the caller's reader") + func failedReaderReuse() throws { + let token = ProbeCancellation() + let reader = ProbeRecordingReader(data: try ProbeTestFixtures.hdr10Plus()) + var limits = Self.ample + limits.maxInputBytes = 1 + #expect(throws: ProbeError.inputLimit) { + try AetherEngine.probe(source: .custom(reader, formatHint: "mp4"), limits: limits, cancellation: token) + } + #expect(reader.cancelCount == 1) + token.cancel() + #expect(reader.cancelCount == 1) + let recovered = try AetherEngine.probe( + source: .custom(reader, formatHint: "mp4"), limits: Self.ample) + #expect(recovered.videoWidth == 64) + #expect(reader.closeCount == 0) + } + + @Test("Native open errors also unregister cancellation and preserve caller ownership") + func nativeErrorCleanup() { + let token = ProbeCancellation() + let reader = ProbeRecordingReader(data: Data([0, 0, 0, 0])) + #expect(throws: (any Error).self) { + try AetherEngine.probe(source: .custom(reader, formatHint: "mp4"), cancellation: token) + } + token.cancel() + #expect(reader.cancelCount == 0) + #expect(reader.closeCount == 0) + } + + @Test("The counted wrapper's ordinary close and cancel never close or cancel the host") + func wrapperDoesNotOwnReader() throws { + let control = try Self.control() + defer { control.finish() } + let reader = ProbeRecordingReader(data: Data([42])) + let counted = ProbeIOReader(reader: reader, control: control) + counted.cancel() + counted.close() + #expect(reader.cancelCount == 0) + #expect(reader.closeCount == 0) + #expect(Self.read(counted, size: 1) == 1) + } + + @Test("Controlled probes reject unsupported URL schemes without native protocol fallback") + func unsupportedURL() throws { + let url = try #require(URL(string: "ftp://127.0.0.1/probe.mp4")) + #expect(throws: ProbeError.unsupportedURL) { try AetherEngine.probe(url: url, limits: Self.ample) } + #expect(throws: ProbeError.unsupportedURL) { + try AetherEngine.probe(url: url, cancellation: ProbeCancellation()) + } + } + + // MARK: HTTP opening, with an isolated loopback origin and no URLProtocol global hooks + + @Test("HTTP cancellation ends a blocked header or body request without launching a fallback", + .timeLimit(.minutes(1)), arguments: ProbeHTTPTestOrigin.Stage.allCases) + func cancellationDuringHTTPOpen(_ stage: ProbeHTTPTestOrigin.Stage) async throws { + let token = ProbeCancellation() + let returned = ProbeTestBox(false) + // Stop at the observed request on the origin thread, not after a test-executor hop + // that could outlast the HTTP transport's own timeout on an overloaded runner. + let origin = try ProbeHTTPTestOrigin( + data: ProbeTestFixtures.hdr10Plus(), stage: stage, onBlocked: { token.cancel() }) + defer { origin.stop(); token.cancel() } + let url = try #require(URL(string: "http://127.0.0.1:\(origin.port)/\(UUID().uuidString).mp4")) + let job = ProbeTestJob { + defer { returned.update { $0 = true } } + return try AetherEngine.probe(url: url, cancellation: token) + } + try await waitFor { origin.blocked.entered || job.isFinished || origin.failure != nil } + try #require(origin.blocked.entered) + Self.expectCancellation(try await job.outcome()) + #expect(returned.value, "Observe the synchronous probe returning, not just the cancelled token") + #expect(!origin.blocked.isOpen, "Neither origin headers/body nor an EOF released the probe") + #expect(origin.failure == nil) + let ranges: [String?] = stage == .headers ? ["bytes=0-"] : ["bytes=0-", "bytes=0-65535"] + #expect(origin.requests.map(\.range) == ranges, "No HEAD, bounded fallback or retry after cancellation") + #expect(origin.requests.allSatisfy { $0.method == "GET" }) + #expect(OriginRequestBudget.shared.snapshot(for: url)?.inflight == 0) + origin.stop() + try await waitFor { origin.isStopped } + } + + @Test("An injected deadline interrupts blocked HTTP headers or body before any fallback", + .timeLimit(.minutes(1)), arguments: ProbeHTTPTestOrigin.Stage.allCases) + func deadlineDuringHTTPOpen(_ stage: ProbeHTTPTestOrigin.Stage) async throws { + let clock = ProbeTestBox(0) + let control = try ProbeControl( + limits: .init(timeBudget: 1), cancellation: nil, + now: { clock.value }, scheduleDeadline: false) + let stopped = ProbeTestBox?>(nil) + let returned = ProbeTestBox(false) + let origin = try ProbeHTTPTestOrigin(data: ProbeTestFixtures.hdr10Plus(), stage: stage) { + clock.update { $0 = 1 } + let result = Result { try control.check() } + stopped.update { $0 = result } + } + defer { origin.stop() } + let url = try #require(URL(string: "http://127.0.0.1:\(origin.port)/\(UUID().uuidString).mp4")) + let job = ProbeTestJob { + let reader = ProbeHTTPReader(url: url, headers: [:], control: control) + let counted = ProbeIOReader(reader: reader, control: control) + let demuxer = Demuxer() + demuxer.probeControl = control + defer { + demuxer.close() + reader.close() + control.finish() + returned.update { $0 = true } + } + do { + try reader.open() + try control.check() + try demuxer.open(reader: counted, profile: .stillExtraction) + demuxer.close() + reader.close() + try control.complete() + } catch { + try control.check() + throw error + } + } + try await waitFor { origin.blocked.entered || job.isFinished || origin.failure != nil } + try #require(origin.blocked.entered) + let outcome = try await job.outcome() + #expect(throws: ProbeError.timedOut) { try outcome.get() } + try await waitFor { stopped.value != nil } + let expired = try #require(stopped.value) + #expect(throws: ProbeError.timedOut) { try expired.get() } + #expect(returned.value) + #expect(!origin.blocked.isOpen) + #expect(origin.failure == nil) + let ranges: [String?] = stage == .headers ? ["bytes=0-"] : ["bytes=0-", "bytes=0-65535"] + #expect(origin.requests.map(\.range) == ranges) + #expect(origin.requests.allSatisfy { $0.method == "GET" }) + #expect(OriginRequestBudget.shared.snapshot(for: url)?.inflight == 0) + origin.stop() + try await waitFor { origin.isStopped } + } + + @Test("Controlled HTTP fixture probes preserve detection and enforce delivered-byte limits", + .timeLimit(.minutes(1)), arguments: [false, true]) + func controlledHTTPFixture(_ exhaustInput: Bool) async throws { + let origin = try ProbeHTTPTestOrigin(data: ProbeTestFixtures.hdr10Plus()) + defer { origin.stop() } + let url = try #require(URL(string: "http://127.0.0.1:\(origin.port)/\(UUID().uuidString).mp4")) + var limits = Self.ample + if exhaustInput { limits.maxInputBytes = 1 } + let requestedLimits = limits + let job = ProbeTestJob { + try AetherEngine.probe( + url: url, detecting: .hdr10Plus, hdr10PlusDetection: Self.hdr, + limits: requestedLimits) + } + let outcome = try await job.outcome() + if exhaustInput { + #expect(throws: ProbeError.inputLimit) { try outcome.get() } + } else { + let probe = try outcome.get() + #expect(probe.carriesHDR10PlusMetadata) + #expect(probe.videoFormat == .hdr10Plus) + } + #expect(origin.failure == nil) + #expect(origin.requests.map(\.range) == ["bytes=0-", "bytes=0-65535"], + "The tiny fixture needs one size probe and one finite chunk, no speculative tail") + #expect(OriginRequestBudget.shared.snapshot(for: url)?.inflight == 0) + origin.stop() + try await waitFor { origin.isStopped } + } + + @Test("Precancelled HTTP probes issue no opening request", .timeLimit(.minutes(1))) + func precancelledHTTPDoesNotOpen() async throws { + let origin = try ProbeHTTPTestOrigin(data: ProbeTestFixtures.hdr10Plus()) + defer { origin.stop() } + let token = ProbeCancellation() + token.cancel() + let url = try #require(URL(string: "http://127.0.0.1:\(origin.port)/\(UUID().uuidString).mp4")) + let job = ProbeTestJob { try AetherEngine.probe(url: url, cancellation: token) } + Self.expectCancellation(try await job.outcome()) + #expect(origin.requests.isEmpty) + origin.stop() + try await waitFor { origin.isStopped } + } +} diff --git a/Tests/AetherEngineTests/PublicAPIDocumentationTests.swift b/Tests/AetherEngineTests/PublicAPIDocumentationTests.swift index c73a6c809..62fe3bf12 100644 --- a/Tests/AetherEngineTests/PublicAPIDocumentationTests.swift +++ b/Tests/AetherEngineTests/PublicAPIDocumentationTests.swift @@ -32,6 +32,7 @@ final class PublicAPIDocumentationTests: XCTestCase { private static let hostFacingTypeFiles = [ "PlayerState.swift", "PlaybackErrorInfo.swift", + "ProbeControl.swift", "PlaybackClock.swift", "SeekEvent.swift", "StartupProgress.swift", diff --git a/Tests/AetherEngineTests/Support/ProbeControlTestSupport.swift b/Tests/AetherEngineTests/Support/ProbeControlTestSupport.swift new file mode 100644 index 000000000..0a0351aaf --- /dev/null +++ b/Tests/AetherEngineTests/Support/ProbeControlTestSupport.swift @@ -0,0 +1,306 @@ +import Foundation +import Testing +import AetherLibavcodec +import AetherLibavformat +import AetherLibavutil +@testable import AetherEngine + +final class ProbeTestBox: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + + init(_ value: Value) { stored = value } + var value: Value { lock.withLock { stored } } + func update(_ body: (inout Value) -> Void) { lock.withLock { body(&stored) } } +} + +/// Only detached test workers park here; the test executor observes arrivals with `waitFor`. +final class ProbeTestGate: @unchecked Sendable { + private let condition = NSCondition() + private var released = false + private var arrivals = 0 + + var entered: Bool { condition.withLock { arrivals > 0 } } + var isOpen: Bool { condition.withLock { released } } + + func wait(onArrival: @Sendable () -> Void = {}) { + condition.lock() + arrivals += 1 + condition.unlock() + onArrival() + condition.lock() + while !released { condition.wait() } + condition.unlock() + } + + func open() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } +} + +final class ProbeTestJob: @unchecked Sendable { + private let result = ProbeTestBox?>(nil) + + init(_ body: @escaping @Sendable () throws -> Value) { + let result = self.result + Thread.detachNewThread { + let outcome = autoreleasepool { Result(catching: body) } + result.update { $0 = outcome } + } + } + + var isFinished: Bool { result.value != nil } + + func outcome() async throws -> Result { + try await waitFor { self.isFinished } + return try #require(result.value) + } +} + +final class ProbeRecordingReader: IOReader, @unchecked Sendable { + struct Read: Sendable { + let offset: Int64 + let requested: Int32 + let returned: Int32 + } + + struct Seek: Sendable { + let offset: Int64 + let whence: Int32 + } + + private struct State { + var position: Int64 = 0 + var reads: [Read] = [] + var seeks: [Seek] = [] + var cancellations = 0 + var closes = 0 + } + + private let state = ProbeTestBox(State()) + private let data: Data + private let chunkSize: Int + private let afterRead: (@Sendable () -> Void)? + private let afterSeek: (@Sendable () -> Void)? + private let afterCancel: (@Sendable () -> Void)? + let discImageProbeEnabled: Bool + + init( + data: Data, + discImageProbeEnabled: Bool = false, + chunkSize: Int = Int.max, + afterRead: (@Sendable () -> Void)? = nil, + afterSeek: (@Sendable () -> Void)? = nil, + afterCancel: (@Sendable () -> Void)? = nil + ) { + self.data = data + self.discImageProbeEnabled = discImageProbeEnabled + self.chunkSize = chunkSize + self.afterRead = afterRead + self.afterSeek = afterSeek + self.afterCancel = afterCancel + } + + var reads: [Read] { state.value.reads } + var seeks: [Seek] { state.value.seeks } + var bytesRead: Int64 { reads.reduce(0) { $0 + Int64(max(0, $1.returned)) } } + var cancelCount: Int { state.value.cancellations } + var closeCount: Int { state.value.closes } + + func read(_ buffer: UnsafeMutablePointer?, size: Int32) -> Int32 { + guard let buffer, size > 0 else { return 0 } + var result: Int32 = 0 + state.update { state in + let start = Int(min(state.position, Int64(data.count))) + let count = min(Int(size), chunkSize, data.count - start) + if count > 0 { + data.copyBytes( + to: UnsafeMutableBufferPointer(start: buffer, count: count), + from: start..<(start + count)) + } + result = Int32(count) + state.reads.append(Read(offset: state.position, requested: size, returned: result)) + state.position += Int64(count) + } + // Deliberately outside the cursor lock: cancellation is allowed to reenter this reader. + afterRead?() + return result + } + + func seek(offset: Int64, whence: Int32) -> Int64 { + var result: Int64 = -1 + state.update { state in + state.seeks.append(Seek(offset: offset, whence: whence)) + if whence & 0x10000 != 0 { + result = Int64(data.count) + return + } + let target: Int64 + switch whence & ~0x20000 { + case SEEK_SET: target = offset + case SEEK_CUR: target = state.position + offset + case SEEK_END: target = Int64(data.count) + offset + default: return + } + guard target >= 0 else { return } + state.position = target + result = target + } + afterSeek?() + return result + } + + func cancel() { + state.update { $0.cancellations += 1 } + afterCancel?() + } + func close() { state.update { $0.closes += 1 } } +} + +/// `cancel` wakes the operation without closing it. A second gate proves the synchronous probe +/// does not return until the host's callback has actually unwound. +final class ProbeParkedReader: IOReader, @unchecked Sendable { + enum Operation: Sendable, CaseIterable, Equatable { case read, seek } + + let interrupted = ProbeTestGate() + let mayReturn = ProbeTestGate() + let beforePark: ProbeTestGate? + let discImageProbeEnabled = false + private let operation: Operation + private let interruptOnCancel: Bool + private let counts = ProbeTestBox((cancels: 0, closes: 0)) + + init(operation: Operation, pauseBeforeParking: Bool = false, interruptOnCancel: Bool = true) { + self.operation = operation + self.interruptOnCancel = interruptOnCancel + beforePark = pauseBeforeParking ? ProbeTestGate() : nil + } + var cancelCount: Int { counts.value.cancels } + var closeCount: Int { counts.value.closes } + + func read(_ buffer: UnsafeMutablePointer?, size: Int32) -> Int32 { + guard operation == .read else { return -1 } + beforePark?.wait() + interrupted.wait() + mayReturn.wait() + return -1 + } + + func seek(offset: Int64, whence: Int32) -> Int64 { + guard operation == .seek else { return whence == 0x10000 ? 1024 : 0 } + beforePark?.wait() + interrupted.wait() + mayReturn.wait() + return -1 + } + + func cancel() { + counts.update { $0.cancels += 1 } + if interruptOnCancel { interrupted.open() } + } + + func close() { counts.update { $0.closes += 1 } } + + func release() { + beforePark?.open() + interrupted.open() + mayReturn.open() + } +} + +enum ProbeTestFixtures { + static func decode(_ base64: String) throws -> Data { + try #require(Data(base64Encoded: base64, options: .ignoreUnknownCharacters)) + } + + static func hdr10Plus() throws -> Data { + try decode(HDR10PlusProbeIntegrationTests.hdr10PlusBase64) + } + + static func eac3() throws -> Data { + try decode(AtmosDetectionProbeIntegrationTests.eac3PlainBase64) + } + + /// Scratch fixtures stay under the checkout, never the system temporary directory. + static func withFile(_ data: Data, _ body: (URL) throws -> T) throws -> T { + let url = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + .appendingPathComponent(".probe-control-\(UUID().uuidString).mp4") + defer { try? FileManager.default.removeItem(at: url) } + try data.write(to: url) + return try body(url) + } + + /// Remux the existing synthetic fixtures in memory; no CLI, downloaded media or global hooks. + static func combined() throws -> Data { + let video = Demuxer() + let audio = Demuxer() + defer { video.close(); audio.close() } + try video.open(reader: DataIOReader(data: hdr10Plus()), formatHint: "mp4") + try audio.open(reader: DataIOReader(data: eac3()), formatHint: "mp4") + let videoStream = try #require(video.stream(at: video.videoStreamIndex)) + let audioStream = try #require(audio.stream(at: audio.audioStreamIndex)) + + var output: UnsafeMutablePointer? + try #require(avformat_alloc_output_context2(&output, nil, "mp4", nil) >= 0) + let context = try #require(output) + defer { avformat_free_context(context) } + + var io: UnsafeMutablePointer? + try #require(avio_open_dyn_buf(&io) >= 0) + let buffer = try #require(io) + context.pointee.pb = buffer + var bytes: UnsafeMutablePointer? + var bufferClosed = false + defer { + if !bufferClosed { _ = avio_close_dyn_buf(buffer, &bytes) } + av_free(bytes) + } + + let streams = [(video, videoStream), (audio, audioStream)] + for (_, source) in streams { + let target = try #require(avformat_new_stream(context, nil)) + try #require(avcodec_parameters_copy(target.pointee.codecpar, source.pointee.codecpar) >= 0) + target.pointee.time_base = source.pointee.time_base + } + try #require(avformat_write_header(context, nil) >= 0) + for (index, pair) in streams.enumerated() { + let (demuxer, source) = pair + let target = try #require(context.pointee.streams[index]) + while let packet = try demuxer.readPacket() { + var owned: UnsafeMutablePointer? = packet + defer { trackedPacketFree(&owned) } + av_packet_rescale_ts(packet, source.pointee.time_base, target.pointee.time_base) + packet.pointee.stream_index = Int32(index) + packet.pointee.pos = -1 + try #require(av_interleaved_write_frame(context, packet) >= 0) + } + } + try #require(av_write_trailer(context) >= 0) + let count = avio_close_dyn_buf(buffer, &bytes) + bufferClosed = true + context.pointee.pb = nil + try #require(count > 0) + return Data(bytes: try #require(bytes), count: Int(count)) + } + + /// Include queued foreign packets, rather than assuming a particular MP4 interleave order. + static func firstVideoBudget(_ data: Data) throws -> (packets: Int, bytes: Int) { + let demuxer = Demuxer() + defer { demuxer.close() } + try demuxer.open(reader: DataIOReader(data: data), formatHint: "mp4", profile: .stillExtraction) + demuxer.discardAllStreamsExcept([demuxer.videoStreamIndex]) + var count = 0 + while let packet = try demuxer.readPacket() { + var owned: UnsafeMutablePointer? = packet + defer { trackedPacketFree(&owned) } + count += 1 + if packet.pointee.stream_index == demuxer.videoStreamIndex { + return (count, Int(packet.pointee.size)) + } + } + throw CocoaError(.fileReadCorruptFile) + } +} diff --git a/Tests/AetherEngineTests/Support/ProbeHTTPTestOrigin.swift b/Tests/AetherEngineTests/Support/ProbeHTTPTestOrigin.swift new file mode 100644 index 000000000..affc5300a --- /dev/null +++ b/Tests/AetherEngineTests/Support/ProbeHTTPTestOrigin.swift @@ -0,0 +1,250 @@ +import Darwin +import Foundation + +/// Per-test, loopback-only origin. Socket waits and response gates live on owned detached threads. +final class ProbeHTTPTestOrigin: @unchecked Sendable { + enum Stage: CaseIterable, Equatable, Sendable { case headers, body } + + struct Request: Sendable { + let method: String + let range: String? + } + + private struct State { + var stopping = false + var acceptExited = false + var connections: Set = [] + var requests: [Request] = [] + var failure: String? + var stalled = false + } + + let port: UInt16 + let blocked = ProbeTestGate() + private let listener: Int32 + private let wakeRead: Int32 + private let wakeWrite: Int32 + private let data: Data + private let stage: Stage? + private let onBlocked: @Sendable () -> Void + private let state = ProbeTestBox(State()) + + init(data: Data, stage: Stage? = nil, onBlocked: @escaping @Sendable () -> Void = {}) throws { + self.data = data + self.stage = stage + self.onBlocked = onBlocked + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } + var wake: [Int32] = [-1, -1] + var initialized = false + defer { + if !initialized { + Darwin.close(fd) + for pipeFD in wake where pipeFD >= 0 { Darwin.close(pipeFD) } + } + } + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + address.sin_port = 0 + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, listen(fd, 16) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &length) } + } + guard named == 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } + guard pipe(&wake) == 0, + fcntl(fd, F_SETFL, O_NONBLOCK) == 0, + fcntl(wake[1], F_SETFL, O_NONBLOCK) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + listener = fd + wakeRead = wake[0] + wakeWrite = wake[1] + port = UInt16(bigEndian: address.sin_port) + initialized = true + Thread.detachNewThread { self.acceptLoop() } + } + + var requests: [Request] { state.value.requests } + var failure: String? { state.value.failure } + var isStopped: Bool { + let snapshot = state.value + return snapshot.acceptExited && snapshot.connections.isEmpty + } + + func stop() { + state.update { state in + guard !state.stopping else { return } + state.stopping = true + // The accept thread owns its listener and wake pipe until poll returns. Closing a + // listener from another thread can race descriptor reuse or fail to wake accept. + if !state.acceptExited { + var byte: UInt8 = 1 + var sent: Int + repeat { sent = Darwin.write(wakeWrite, &byte, 1) } while sent < 0 && errno == EINTR + if sent != 1 { state.failure = "Could not wake the test origin: \(errno)" } + } + // Connection workers likewise retain descriptor ownership until their syscalls unwind. + for fd in state.connections { shutdown(fd, SHUT_RDWR) } + } + blocked.open() + } + + private func acceptLoop() { + defer { + state.update { + Darwin.close(listener) + Darwin.close(wakeRead) + Darwin.close(wakeWrite) + $0.acceptExited = true + } + } + while !state.value.stopping { + var events = [ + pollfd(fd: listener, events: Int16(POLLIN), revents: 0), + pollfd(fd: wakeRead, events: Int16(POLLIN), revents: 0), + ] + let ready = events.withUnsafeMutableBufferPointer { + poll($0.baseAddress, nfds_t($0.count), -1) + } + guard ready >= 0 else { + if errno == EINTR { continue } + state.update { $0.failure = "poll failed: \(errno)" } + return + } + if events[1].revents != 0 || state.value.stopping { return } + guard events[0].revents & Int16(POLLIN) != 0 else { + state.update { $0.failure = "Unexpected test listener event" } + return + } + let fd = accept(listener, nil, nil) + guard fd >= 0 else { + if errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK { continue } + if !state.value.stopping { + state.update { $0.failure = "accept failed: \(errno)" } + } + return + } + var noSigPipe: Int32 = 1 + let flags = fcntl(fd, F_GETFL, 0) + guard flags >= 0, fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) == 0, + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size)) == 0 else { + state.update { $0.failure = "Connection setup failed: \(errno)" } + Darwin.close(fd) + return + } + var accepted = false + state.update { + if !$0.stopping { + $0.connections.insert(fd) + accepted = true + } + } + guard accepted else { Darwin.close(fd); return } + Thread.detachNewThread { self.serve(fd) } + } + } + + private func serve(_ fd: Int32) { + defer { + state.update { + Darwin.close(fd) + $0.connections.remove(fd) + } + } + guard let request = readRequest(fd) else { return } + var index = 0 + state.update { + index = $0.requests.count + $0.requests.append(request) + } + if stage == .headers, index == 0 { park() } + guard !state.value.stopping else { return } + + var start = 0 + var end = data.count - 1 + if let range = request.range { + let parts = range.dropFirst("bytes=".count).split(separator: "-", omittingEmptySubsequences: false) + guard range.hasPrefix("bytes="), parts.count == 2, + let first = Int(parts[0]), first >= 0, first < data.count, + parts[1].isEmpty || Int(parts[1]) != nil else { + state.update { $0.failure = "Unexpected test request range: \(range)" } + return + } + start = first + if let last = Int(parts[1]) { end = min(end, last) } + } + guard end >= start else { + state.update { $0.failure = "Inverted test request range" } + return + } + let ranged = request.range != nil + let header = "HTTP/1.1 \(ranged ? "206 Partial Content" : "200 OK")\r\n" + + "Content-Length: \(end - start + 1)\r\n" + + (ranged ? "Content-Range: bytes \(start)-\(end)/\(data.count)\r\n" : "") + + "Accept-Ranges: bytes\r\nConnection: close\r\n\r\n" + guard writeFully(fd, data: Data(header.utf8)) else { return } + guard request.method != "HEAD" else { return } + // The open-ended GET is the response-header-only size probe. Park the subsequent + // finite chunk request only, after its headers but before its first payload byte. + if stage == .body, request.range != nil, request.range != "bytes=0-" { park() } + guard !state.value.stopping else { return } + _ = writeFully(fd, data: data.subdata(in: start..<(end + 1))) + } + + private func park() { + var shouldPark = false + state.update { + if !$0.stalled { + $0.stalled = true + shouldPark = true + } + } + if shouldPark { blocked.wait(onArrival: onBlocked) } + } + + private func readRequest(_ fd: Int32) -> Request? { + var header = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while header.range(of: Data("\r\n\r\n".utf8)) == nil { + let count = recv(fd, &buffer, buffer.count, 0) + guard count > 0 else { return nil } + header.append(contentsOf: buffer.prefix(count)) + guard header.count <= 64 * 1024 else { + state.update { $0.failure = "Oversize test request header" } + return nil + } + } + let lines = String(decoding: header, as: UTF8.self).components(separatedBy: "\r\n") + let method = lines[0].split(separator: " ").first.map(String.init) + guard let method, method == "GET" || method == "HEAD" else { + state.update { $0.failure = "Unexpected test request method" } + return nil + } + let range = lines.first { $0.lowercased().hasPrefix("range:") } + .map { String($0.dropFirst("range:".count)).trimmingCharacters(in: .whitespaces) } + return Request(method: method, range: range) + } + + private func writeFully(_ fd: Int32, data: Data) -> Bool { + data.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress else { return true } + var offset = 0 + while offset < bytes.count { + let count = Darwin.write(fd, base.advanced(by: offset), bytes.count - offset) + if count < 0, errno == EINTR { continue } + guard count > 0 else { return false } + offset += count + } + return true + } + } +} diff --git a/docs/api.md b/docs/api.md index 673897172..7f08cef18 100644 --- a/docs/api.md +++ b/docs/api.md @@ -410,13 +410,67 @@ try await player.reloadAtCurrentPosition() | `reloadAtCurrentPosition()` | `async throws`. Background reopen at the current position, preserving options. Session-preserving: it finishes an installed audio tap and keeps the native host where it can. It also preserves the session's TRANSPORT rather than replaying `autoplay`, so a session that was playing comes back playing and one that was paused comes back paused, whatever the mount was given (AE#464 round 2). The one exception is the resume after a background teardown, which has no transport left to read and is the host's call, so there the mount flag still decides. | | `prepareForItemReplacement()` | One-shot. Keeps the current native `AVPlayerItem` attached until the next `load()` replaces it atomically, for a host that mounts the engine's own player layer and would otherwise show a black layer across the nil-item gap of a foreground episode or playlist change. Consumed by the next `load()`, cancelled by `stop()`, no effect when the outgoing session is not native. PiP hosts do not need it: an active PiP window already forces the handover (AE#158). | | `stop(resetDisplayCriteria:finalTeardown:)` | Ends the session, `state` becomes `.idle`, `startupProgress` becomes nil. `resetDisplayCriteria: false` keeps the panel in its current mode across an item handoff. | -| `AetherEngine.probe(url:options:)` / `probe(source:options:)` | `nonisolated static throws -> SourceProbe`. Demux-only metadata read, no decoders, no session. `options` is read for `httpHeaders` only. For a custom reader the caller keeps ownership, `close()` is not called, and the cursor is left unspecified. | +| `AetherEngine.probe(url:options:)` / `probe(source:options:)` | `nonisolated static throws -> SourceProbe`. Container/stream metadata read, no detail-pass decoder or session. `options` is read for `httpHeaders` only. Optional trailing `limits` and `cancellation` control the whole operation (below). For a custom reader the caller keeps ownership, `close()` is not called, and the cursor is left unspecified. | | `AetherEngine.probeDetectingAtmos(url:options:atmosDetection:)` | `probe` plus a bounded decode pass that authoritatively resolves E-AC-3 JOC for an Atmos badge. Strictly more expensive; never on the playback-start path. Decode-side failures degrade to "not confirmed" rather than throwing. Same thing as `probe(url:detecting: .atmos)`. | -| `AetherEngine.probe(url:options:detecting:atmosDetection:hdr10PlusDetection:)` / `probe(source:...)` | `probe` plus the opt-in passes named in `ProbeDetail`, over ONE open handle: `.atmos` (the bounded JOC decode above) and `.hdr10Plus` (a bounded packet scan for ST 2094-40 carriage). Asking for both runs both on one connection. Empty set is exactly `probe(url:)`. Both passes only ever SET `isAtmos` / `carriesHDR10PlusMetadata`; a pass that hits a cap leaves the base answer untouched, and pass-side failures are never thrown. | +| `AetherEngine.probe(url:options:detecting:atmosDetection:hdr10PlusDetection:)` / `probe(source:...)` | `probe` plus the opt-in passes named in `ProbeDetail`, over one demuxer: `.atmos` (the bounded JOC decode above) and `.hdr10Plus` (structurally validated ST 2094-40 carriage). Both passes share the optional trailing `limits` and `cancellation`. Empty set is the header probe with the same controls. Both passes only ever SET `isAtmos` / `carriesHDR10PlusMetadata`; ordinary pass failures and per-pass caps leave the detail unconfirmed. A whole-probe stop throws, with no partial result. | | `ProbeDetail` | `OptionSet`: `.atmos`, `.hdr10Plus`. | | `HDR10PlusDetectionOptions` | Bounds for the HDR10+ scan: `maxPackets` (32), `maxBytes` (16 MiB), `timeBudget` (2 s). The byte cap is the one that binds on UHD remuxes, where a single keyframe runs to several MB. | +| `ProbeLimits` | Optional whole-probe controls: `maxInputBytes` (8 MiB), `maxPackets` (128), `maxPacketBytes` (2 MiB), `timeBudget` (5 s). Nonnegative values required; the time budget must be finite. | +| `ProbeCancellation` | Thread-safe, one-shot token: `init()`, `isCancelled`, `cancel()`. Available on every URL/custom header, detail and `probeDetectingAtmos` overload. Cancellation is a request, not a completion notification. | +| `ProbeError` | `invalidLimits`, `inputLimit`, `packetLimit`, `packetSizeLimit`, `timedOut`, `invalidReaderResult`, `unsupportedURL`, `sourceBusy`; `errorDescription` describes the stop. Explicit caller cancellation throws `CancellationError` instead. | | `AetherEngine.externalSubtitleTrackIDBase` | `100_000`. Synthetic ids of external subtitle tracks start here. | +### Whole-probe limits and cancellation + +Existing calls retain their open policy: `limits: nil, cancellation: nil`. Passing `limits: .init()` +starts a monotonic deadline before opening the source, shares the input budget across disc recognition, +container open, `avformat_find_stream_info`, the HDR scan, the Atmos rewind and decode, and disables +speculative HTTP prefetch. Passing only `cancellation` enables interruption without installing numeric +limits. The bounded open uses a smaller FFmpeg analysis profile; sparse sources may therefore report less +metadata or need larger limits. Controlled URL probes support files, HTTP and HTTPS; other transports can +use an independent `.custom` reader. They do not open a second URL reader for the optional recordless +Dolby Vision audit. Container-declared Dolby Vision remains primary even when HDR10+ is also confirmed. + +`maxInputBytes` counts cumulative bytes the underlying reader delivers to the probe, including bytes +read again after a seek. Reads are clipped to the remaining allowance **before** calling the reader. +It is **not a network/wire cap**: HTTP headers, transport buffers, requests used to resolve length, and +reader-internal prefetch can consume more. `maxPackets` counts all packets returned for inspection across +both passes, including foreign streams; FFmpeg-internal packets during open/seek are bounded by input +and time, not that counter. `maxPacketBytes` rejects an oversized packet payload before parsing/decode, +after FFmpeg has allocated it. None of these is a hard native-memory ceiling. The separate per-pass byte +caps also reject a packet that would exceed the remaining allowance, rather than accepting an overshoot. + +The deadline and token interrupt HTTP requests and call a custom `IOReader.cancel()` concurrently, +including during open/seek. A blocking custom reader must implement thread-safe cancellation, unblock +promptly, and handle cancellation racing an operation's start; the default no-op cannot do that. +FFmpeg also gets an interrupt callback. This is cooperative interruption, **not a hard real-time return +guarantee**: native computation and a noncooperating reader cannot be forcibly terminated. The call waits +for native work to return before freeing its state, drains interruption callbacks before returning the +reader, and never publishes a positive obtained after a stop. A controlled HTTP probe declines with +`sourceBusy` rather than queueing behind playback's origin request budget. Normal playback's redirect, +cookie, authentication and response policies are unchanged. + +```swift +let cancellation = ProbeCancellation() +let worker = Task.detached { + try AetherEngine.probe( + url: mediaURL, options: .init(httpHeaders: headers), + detecting: [.hdr10Plus, .atmos], + limits: .init(), cancellation: cancellation) +} +let result = try await withTaskCancellationHandler { + try await worker.value // completion means the synchronous native call actually returned +} onCancel: { + cancellation.cancel() +} +``` + +Cancelling an awaiting Swift task alone does not cancel synchronous probing; wire the handler as above. +Do not share a custom reader's cursor with playback. The caller still owns and closes it. A successful +probe with `carriesHDR10PlusMetadata == false` or an unconfirmed Atmos track means **not confirmed within +the requested passes/budgets**, never proof of absence. Atmos detection means E-AC-3 JOC, not TrueHD Atmos. +These are source-metadata answers, not evidence of HDMI output or display mode. + ### Warming a source before it is loaded ```swift diff --git a/docs/architecture.md b/docs/architecture.md index f00965830..6c6ee1d7c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -282,7 +282,8 @@ batch's advanced cursor so re-selection collects it again rather than skipping u ``` Sources/AetherEngine/ ├── AetherEngine.swift Engine core: stored state, load dispatch, transport, stop/seek, track selection -├── AetherEngine+Probe.swift Static probe machinery: probe(url:/source:), probeDetectingAtmos(url:/source:), swDecodeProbe, format / frame-rate / codec-label detection +├── AetherEngine+Probe.swift Static probe machinery: probe(url:/source:), shared HDR10+/Atmos detail orchestration, swDecodeProbe, format / frame-rate / codec-label detection +├── ProbeControl.swift Opt-in whole-probe input/packet/deadline accounting and caller cancellation; native callbacks retain their control until synchronous work returns ├── AetherEngine+Loading.swift The per-backend loaders (remote-HLS, native, software, audio, audio-native) + reload ├── AetherEngine+Subtitles.swift Embedded + external subtitle pipeline (packet-store drainer, cue apply / prune, `closeOpenEndedCues` taking a placeholder end from the next stored packet, or retiring it at the reconstruction window when the store has none, external track registry + unified selection routing, #88). Every embedded stream is tapped off the session demuxer into `SubtitlePacketStore`; a playhead-paced drainer decodes the selected stream (both channels, text and bitmap, VOD and live) into the overlay, riding producer seeks/restarts by construction, which replaced the side-demuxer reader and its recovery machinery outright (#112 rework); a VOD-only forward prefetcher extends store coverage past the producer park to the 60 s drain lead for host advance sync offsets (#151), plus a margin so the harvest leads the decode and the set at the window's edge can take its authored end from the store (#362); side readers yield the source link to the video path while it is fetching or seeking (#240, `SideReaderLinkPolicy`); the drain tick states how far display-state determination has advanced on the absolute source axis, fenced by load + seek generation, so a host or conformance harness can tell "determined, and empty" from "not read yet" (#250, `AetherEngine+SubtitleResolution.swift`) ├── AetherEngine+ClosedCaptions.swift In-band CEA-608 closed captions + A53/SEI extraction: ClosedCaptionTap (read-only producer observer) + cue mirroring (#77, #131) diff --git a/docs/formats.md b/docs/formats.md index 6ef099356..0e7478936 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -204,7 +204,15 @@ AV1+DV emits a bare `dav1.10.` primary for Profile 10.0 (DV-only, no ba ST 2094-40 metadata stays attached to the HEVC bitstream as user-data-registered ITU-T T.35 SEI NALs. The HLS-fMP4 stream-copy preserves the SEI through to `AVPlayer`, which forwards it to the system compositor. HDR10+-capable TVs apply the per-scene tone-mapping curves; HDR10-only TVs fall back to the static HDR10 base. -The published `videoFormat` starts at `.hdr10` for any BT.2020 / PQ source and flips to `.hdr10Plus` the first time a packet's T.35 SEI signature is seen in the producer's scan. That includes a Dolby Vision source carrying an HDR10+ layer next to its RPU (Blu-ray Profile 7 does, and so does a Profile 8.1 remuxed from one) whenever the label resolved to its HDR10 base, while `sourceVideoFormat` keeps saying `.dolbyVision`; the evidence is latched per session, so a label republished later by the panel proof (AE#459) keeps it. Debounced across producer restarts so a scrub doesn't re-fire. Hosts can drive an HDR10+ badge or analytics hook off the `$videoFormat` transition. +The published `videoFormat` starts at `.hdr10` for any BT.2020 / PQ source and flips to `.hdr10Plus` the first time the producer validates a packet's HDR10+ metadata. That includes a Dolby Vision source carrying an HDR10+ layer next to its RPU (Blu-ray Profile 7 does, and so does a Profile 8.1 remuxed from one) whenever the label resolved to its HDR10 base, while `sourceVideoFormat` keeps saying `.dolbyVision`; the evidence is latched per session, so a label republished later by the panel proof (AE#459) keeps it. Debounced across producer restarts so a scrub doesn't re-fire. Hosts can drive an HDR10+ badge or analytics hook off the `$videoFormat` transition. + +The same structural validator serves the opt-in metadata probe: codec metadata framing, registered T.35 +identifiers and the complete ST 2094-40 payload must be valid. A matching byte sequence inside compressed +picture data is not metadata. FFmpeg's parsed HDR10+ packet side data remains another supported carriage, +including VP9/AV1 Matroska. No video decoder is opened just to inspect HEVC metadata. Probe confirmation +sets `SourceProbe.carriesHDR10PlusMetadata` independently of the primary format, preserving `.dolbyVision`. +See [whole-probe limits and cancellation](api.md#whole-probe-limits-and-cancellation) for bounded source +reads; absence of confirmation is not proof of absence or a statement about the connected display. The label can also be taken back from the item itself, where the platform has no capability table to clamp it against (AE#515). A Dolby Vision source on macOS resolves to `.hdr10`, because `supportsDolbyVision` is unclaimable there without a host assertion, while AVFoundation goes on playing the `dvh1` sample entry the engine served. Measured with the assertion off on a 16" XDR, a Profile 5 and a Profile 8.1 grade of Dolby's reference content both strobe, so the RPU reaches the pixels with no claim set anywhere and the clamp was moving nothing but the label. When the item's sample entry reads `dvh1` / `dvhe` and the probe agrees the source is Dolby Vision, the label is upgraded from `.hdr10` to `.dolbyVision` at `readyToPlay`. It is an upgrade and not a mirror of what AVFoundation parsed, for two reasons that both matter: an `.sdr` label is the clamp being right about a display presenting no HDR at all, and on tvOS and iOS the per-mode table answers the capability question, so the label follows it rather than a sample entry that a Profile 5 master carries on every panel. Profile 8.1 keeps `.hdr10` on macOS: it reports `hvc1` with the DV configuration alongside it, it composes on that display all the same, and nothing in the stack reports that. From 0a3af6b9a04e4235f93273d4f0a1325c26353220 Mon Sep 17 00:00:00 2001 From: Brandon Moore <16313090+thatcube@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:26:23 -0400 Subject: [PATCH 2/4] test(probe): tighten reader contract and native seek assertions Exercise oversized read results through real media and assert the exact clamped read and caller ownership. Snapshot completed native seek results outside testing macros. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AetherEngineTests/ProbeControlTests.swift | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/Tests/AetherEngineTests/ProbeControlTests.swift b/Tests/AetherEngineTests/ProbeControlTests.swift index f1fe7f5d4..3568566a5 100644 --- a/Tests/AetherEngineTests/ProbeControlTests.swift +++ b/Tests/AetherEngineTests/ProbeControlTests.swift @@ -294,23 +294,37 @@ struct ProbeControlTests { func oversizedReaderResult() throws { final class LyingReader: IOReader, @unchecked Sendable { let discImageProbeEnabled = false - func read(_ buffer: UnsafeMutablePointer?, size: Int32) -> Int32 { size + 1 } + let reader: ProbeRecordingReader + + init(reader: ProbeRecordingReader) { self.reader = reader } + func read(_ buffer: UnsafeMutablePointer?, size: Int32) -> Int32 { + let count = reader.read(buffer, size: size) + return count > 0 ? size + 1 : count + } func seek(offset: Int64, whence: Int32) -> Int64 { - whence == 0x10000 ? -1 : offset + reader.seek(offset: offset, whence: whence) } - func close() {} + func cancel() { reader.cancel() } + func close() { reader.close() } } + let data = try ProbeTestFixtures.hdr10Plus() let control = try Self.control(.init(maxInputBytes: 3)) defer { control.finish() } - let counted = ProbeIOReader(reader: LyingReader(), control: control) + let counted = ProbeIOReader( + reader: LyingReader(reader: ProbeRecordingReader(data: data)), control: control) #expect(Self.read(counted, size: 10) == -1) #expect(throws: ProbeError.invalidReaderResult) { try control.check() } #expect(throws: ProbeError.invalidReaderResult) { try control.complete() } + let nativeReader = ProbeRecordingReader(data: data) #expect(throws: ProbeError.invalidReaderResult) { try AetherEngine.probe( - source: .custom(LyingReader(), formatHint: "mp4"), + source: .custom(LyingReader(reader: nativeReader), formatHint: "mp4"), limits: .init(maxInputBytes: 3, timeBudget: 3600)) } + #expect(nativeReader.reads.map(\.requested) == [3]) + #expect(nativeReader.bytesRead == 3) + #expect(nativeReader.cancelCount == 1) + #expect(nativeReader.closeCount == 0) } @Test("One packet budget covers every stream and later pass") @@ -923,14 +937,16 @@ struct ProbeControlTests { try #require(mayReturn.entered) #expect(!job.isFinished, "The native avio_seek call still owns the parked callback") mayReturn.open() - #expect(try await job.outcome().get() < 0) + let nativeResult = try await job.outcome().get() + #expect(nativeResult < 0) if expireDeadline { #expect(throws: ProbeError.timedOut) { try control.check() } } else { #expect(throws: CancellationError.self) { try control.check() } } - let requestedOffset = reader.seeks.last?.offset - let expectedOffset: Int64 = 1024 * 1024 + let recordedSeeks = reader.seeks + let requestedOffset = try #require(recordedSeeks.last?.offset) + let expectedOffset: Int64 = 1_048_576 #expect(requestedOffset == expectedOffset) #expect(reader.reads.isEmpty) #expect(reader.cancelCount == 1) From d32c261872b26a93dfd12e72789e11dcac4b5379 Mon Sep 17 00:00:00 2001 From: Brandon Moore <16313090+thatcube@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:19:15 -0400 Subject: [PATCH 3/4] test(probe): simplify HDR10+ fixtures for Xcode 26.3 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../HDR10PlusMetadataScanTests.swift | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Tests/AetherEngineTests/HDR10PlusMetadataScanTests.swift b/Tests/AetherEngineTests/HDR10PlusMetadataScanTests.swift index 6c0b9cd97..ecab32b25 100644 --- a/Tests/AetherEngineTests/HDR10PlusMetadataScanTests.swift +++ b/Tests/AetherEngineTests/HDR10PlusMetadataScanTests.swift @@ -14,9 +14,12 @@ struct HDR10PlusMetadataScanTests { for shift in (0..> shift) & 1)) } } var bytes: [UInt8] { - stride(from: 0, to: bits.count, by: 8).map { start in - (0..<8).reduce(UInt8(0)) { byte, bit in - (byte << 1) | (start + bit < bits.count ? bits[start + bit] : 0) + stride(from: 0, to: bits.count, by: 8).map { (start: Int) -> UInt8 in + (0..<8).reduce(UInt8(0)) { (byte: UInt8, bit: Int) -> UInt8 in + let index: Int = start + bit + let nextBit: UInt8 = index < bits.count ? bits[index] : 0 + let shiftedByte: UInt8 = byte << 1 + return shiftedByte | nextBit } } } @@ -111,9 +114,14 @@ struct HDR10PlusMetadataScanTests { private static func obu(_ payload: [UInt8], type: UInt8 = 5, sized: Bool = true, extensionByte: UInt8? = nil) -> [UInt8] { - [type << 3 | (sized ? 2 : 0) | (extensionByte == nil ? 0 : 4)] - + (extensionByte.map { [$0] } ?? []) - + (sized ? leb128(payload.count) : []) + payload + let sizeFlag: UInt8 = sized ? 2 : 0 + let extensionFlag: UInt8 = extensionByte == nil ? 0 : 4 + let header: UInt8 = (type << 3) | sizeFlag | extensionFlag + var bytes: [UInt8] = [header] + if let extensionByte { bytes.append(extensionByte) } + if sized { bytes.append(contentsOf: leb128(payload.count)) } + bytes.append(contentsOf: payload) + return bytes } private func scan(_ bytes: [UInt8], codecID: AVCodecID = AV_CODEC_ID_HEVC, From dd6eaa668dbbdae9804796ecbd7b325cbe5890da Mon Sep 17 00:00:00 2001 From: Brandon Moore <16313090+thatcube@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:01:41 -0400 Subject: [PATCH 4/4] fix(probe): drain cancelled HTTP requests before returning Wait for size and finite-range request callbacks before releasing probe origin slots. Cover cancellation and deadlines with isolated callback-queue gates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 +- Sources/AetherEngine/Demuxer/AVIOReader.swift | 24 ++++++- .../AetherEngineTests/ProbeControlTests.swift | 63 +++++++++++++++++++ docs/api.md | 3 +- 4 files changed, 88 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c51b8b917..aad265f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ the public-API contract. - Optional `ProbeLimits` and `ProbeCancellation` on URL/custom metadata and HDR10+/Atmos detail probes. Input and monotonic time limits cover opening, stream analysis, seeks and both passes; - cancellation reaches HTTP reads and cooperating custom readers. A whole-probe stop throws without + cancellation reaches HTTP reads and cooperating custom readers, and waits for cancelled HTTP request + callbacks before releasing origin slots. A whole-probe stop throws without a partial result, and caller-owned readers are never closed. Existing calls keep their open policy. ### Fixed diff --git a/Sources/AetherEngine/Demuxer/AVIOReader.swift b/Sources/AetherEngine/Demuxer/AVIOReader.swift index 3d0c16db6..e53d69a6d 100644 --- a/Sources/AetherEngine/Demuxer/AVIOReader.swift +++ b/Sources/AetherEngine/Demuxer/AVIOReader.swift @@ -1014,9 +1014,13 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { private let label: String /// Only static controlled probes use this; playback retains its existing transport policy. private let probeControl: ProbeControl? + private let probeRequestSession: 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, heldConnection: Bool = false, probeControl: ProbeControl? = nil) { + 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, heldConnection: Bool = false, probeControl: ProbeControl? = nil, probeRequestSession: URLSession? = nil) { self.probeControl = probeControl + self.probeRequestSession = probeControl == nil ? nil : probeRequestSession self.url = url self.label = label self.extraHeaders = extraHeaders @@ -3731,7 +3735,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { defer { OriginRequestBudget.shared.release(ticket) } let delegate = ProbeDelegate(extraHeaders: extraHeaders) - let task = Self.probeSession.dataTask(with: request) + let task = (probeRequestSession ?? Self.probeSession).dataTask(with: request) task.delegate = delegate let semaphore = DispatchSemaphore(value: 0) @@ -3750,6 +3754,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { self?.isClosed == true }) != .signaled { task.cancel() + finishCancelledProbeRequest(semaphore) EngineLog.emit("[AVIOReader] Range probe (\(range)) timed out", category: .demux, level: .verbose) return nil } @@ -3980,6 +3985,18 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { prefetchQueue.sync {} } + var isDrainingProbeRequestForTesting: Bool { + probeDrainLock.withLock { drainingProbeRequest } + } + + private func finishCancelledProbeRequest(_ completion: DispatchSemaphore) { + guard probeControl != nil else { return } + probeDrainLock.withLock { drainingProbeRequest = true } + defer { probeDrainLock.withLock { drainingProbeRequest = false } } + // Keep the origin ticket and callback state until this task acknowledges cancellation. + completion.wait() + } + private func syncRequest(_ request: URLRequest, budget: TimeInterval = 35) throws -> (Data, URLResponse) { // #377: every short fetch the reader makes (detour blocks, size probes, HEAD) funnels // through here, so this is the one place that has to take an origin slot for all of them. @@ -3991,7 +4008,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { let delegate = ChunkFetchDelegate(extraHeaders: extraHeaders, bodyLimit: Self.expectedBodyBytes(for: request)) - let task = Self.chunkSession.dataTask(with: request) + let task = (probeRequestSession ?? Self.chunkSession).dataTask(with: request) task.delegate = delegate let semaphore = DispatchSemaphore(value: 0) @@ -4009,6 +4026,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable { ) guard outcome == .signaled else { task.cancel() + finishCancelledProbeRequest(semaphore) throw AVIOReaderError.requestTimeout } diff --git a/Tests/AetherEngineTests/ProbeControlTests.swift b/Tests/AetherEngineTests/ProbeControlTests.swift index 3568566a5..3cc9d7d3f 100644 --- a/Tests/AetherEngineTests/ProbeControlTests.swift +++ b/Tests/AetherEngineTests/ProbeControlTests.swift @@ -1050,6 +1050,69 @@ struct ProbeControlTests { // MARK: HTTP opening, with an isolated loopback origin and no URLProtocol global hooks + @Test("Stopped HTTP probes retain their origin slot until the request callback finishes", + .timeLimit(.minutes(1)), arguments: ProbeHTTPTestOrigin.Stage.allCases, [false, true]) + func stoppedHTTPWaitsForCompletion(_ stage: ProbeHTTPTestOrigin.Stage, deadline: Bool) async throws { + let token = ProbeCancellation() + let clock = ProbeTestBox(0) + let control = try ProbeControl( + limits: deadline ? .init(timeBudget: 1) : nil, cancellation: token, + now: { clock.value }, scheduleDeadline: false) + let callbacks = OperationQueue() + callbacks.maxConcurrentOperationCount = 1 + let session = URLSession(configuration: .ephemeral, delegate: nil, delegateQueue: callbacks) + let origin = try ProbeHTTPTestOrigin(data: ProbeTestFixtures.hdr10Plus(), stage: stage) { + // Delay only this reader's callbacks; never park a shared URLSession delegate queue. + callbacks.isSuspended = true + if deadline { + clock.update { $0 = 1 } + control.stop(ProbeError.timedOut) + } else { + token.cancel() + } + } + defer { + callbacks.isSuspended = false + origin.stop() + token.cancel() + session.invalidateAndCancel() + } + let url = try #require(URL(string: "http://127.0.0.1:\(origin.port)/\(UUID().uuidString).mp4")) + let reader = AVIOReader( + url: url, chunkSize: 64 * 1024, prefetchEnabled: false, + chunkRequestTimeout: 3600, chunkMaxRetries: 1, + probeControl: control, probeRequestSession: session) + control.interrupt { reader.markClosed() } + let job = ProbeTestJob { + defer { + reader.markClosed() + reader.finishProbeTransfers() + reader.close() + control.finish() + } + try reader.open() + try control.check() + } + try await waitFor { reader.isDrainingProbeRequestForTesting || job.isFinished || origin.failure != nil } + try #require(origin.blocked.entered) + #expect(reader.isDrainingProbeRequestForTesting) + #expect(!job.isFinished, "Cancellation is not completion while the task callback is still queued") + #expect(OriginRequestBudget.shared.snapshot(for: url)?.inflight == 1) + + callbacks.isSuspended = false + let outcome = try await job.outcome() + if deadline { + #expect(throws: ProbeError.timedOut) { try outcome.get() } + } else { + Self.expectCancellation(outcome) + } + #expect(!reader.isDrainingProbeRequestForTesting) + #expect(OriginRequestBudget.shared.snapshot(for: url)?.inflight == 0) + #expect(origin.failure == nil) + origin.stop() + try await waitFor { origin.isStopped } + } + @Test("HTTP cancellation ends a blocked header or body request without launching a fallback", .timeLimit(.minutes(1)), arguments: ProbeHTTPTestOrigin.Stage.allCases) func cancellationDuringHTTPOpen(_ stage: ProbeHTTPTestOrigin.Stage) async throws { diff --git a/docs/api.md b/docs/api.md index 7f08cef18..6747f5d49 100644 --- a/docs/api.md +++ b/docs/api.md @@ -446,7 +446,8 @@ promptly, and handle cancellation racing an operation's start; the default no-op FFmpeg also gets an interrupt callback. This is cooperative interruption, **not a hard real-time return guarantee**: native computation and a noncooperating reader cannot be forcibly terminated. The call waits for native work to return before freeing its state, drains interruption callbacks before returning the -reader, and never publishes a positive obtained after a stop. A controlled HTTP probe declines with +reader, and never publishes a positive obtained after a stop. Cancelled HTTP requests finish their task +callbacks before the probe releases their origin slots or returns. A controlled HTTP probe declines with `sourceBusy` rather than queueing behind playback's origin request budget. Normal playback's redirect, cookie, authentication and response policies are unchanged.