From b2a5a267729670c3c1428a4750e004104fa1ed9f Mon Sep 17 00:00:00 2001 From: orut34iop Date: Sun, 18 Oct 2026 12:21:13 +0800 Subject: [PATCH 1/2] test(subtitles): cover independent secondary bitmap decoding --- CHANGELOG.md | 2 + .../AetherEngine/AetherEngine+Subtitles.swift | 6 +- Sources/AetherEngine/AetherEngine.swift | 2 +- .../SecondarySubtitleChannelTests.swift | 98 ++++++++++++++++++- docs/formats.md | 2 +- 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f69ff13..e42d430a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ the public-API contract. ### Changed +- Correct the secondary subtitle contract: embedded bitmap tracks already use independent channel decoders. Add real dual-PGS drain regression coverage for reused palette/object IDs, clear/off, and cached seek reconstruction. No playback behavior change. + - **The documentation no longer says TrueHD Atmos objects are always lost.** The Known limitations entry in `docs/formats.md`, the `AudioDelivery.bridged` reference and the channel bridge's notes predate `objectAudioRendering` (#731) and said objects and heights never survive. They now say the diff --git a/Sources/AetherEngine/AetherEngine+Subtitles.swift b/Sources/AetherEngine/AetherEngine+Subtitles.swift index 02ab63cbf..fef3ab12f 100644 --- a/Sources/AetherEngine/AetherEngine+Subtitles.swift +++ b/Sources/AetherEngine/AetherEngine+Subtitles.swift @@ -167,7 +167,9 @@ extension AetherEngine { selectSubtitleTrack(index: Int(index), startAt: anchor > 0 ? anchor : sourceTime) } - /// Activate an embedded subtitle stream as the secondary companion track (issue #47). Text-only; bitmap codecs are rejected. Runs a second side demuxer concurrently. External ids (#88) route onto the secondary sidecar decode. + /// Activate an embedded subtitle stream as the secondary companion track (issue #47). + /// Text and supported bitmap codecs use an independent decoder and cursor over the shared + /// packet store. External ids (#88) route onto the secondary sidecar decode. public func selectSecondarySubtitleTrack(index: Int) { selectSecondarySubtitleTrack(index: index, startAt: sourceTime) } @@ -1704,7 +1706,7 @@ extension AetherEngine { } /// Turn the secondary subtitle off and clear its cues. Tears down - /// the secondary sidecar decode task and the secondary side reader. + /// the secondary sidecar decode task and its packet-store drain target. public func clearSecondarySubtitle() { hostExplicitSubtitleAction = true cancelSidecarTask(channel: .secondary) diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index bdbf1a191..c86ce3e41 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -1415,7 +1415,7 @@ public final class AetherEngine: ObservableObject { /// Nil for SRT/VTT and when markup preservation is off. @Published public internal(set) var sidecarASSHeader: String? = nil - /// Cues for the secondary subtitle track (#47). Text-only (bitmap rejected); independent of primary. + /// Cues for the secondary subtitle track (#47). Text and supported bitmap codecs; independent of primary. @Published public internal(set) var secondarySubtitleCues: [SubtitleCue] = [] @Published public internal(set) var isLoadingSecondarySubtitles: Bool = false @Published public internal(set) var isSecondarySubtitleActive: Bool = false diff --git a/Tests/AetherEngineTests/SecondarySubtitleChannelTests.swift b/Tests/AetherEngineTests/SecondarySubtitleChannelTests.swift index d2679f31c..c003041fc 100644 --- a/Tests/AetherEngineTests/SecondarySubtitleChannelTests.swift +++ b/Tests/AetherEngineTests/SecondarySubtitleChannelTests.swift @@ -1,11 +1,14 @@ import XCTest +import Testing +import AetherLibavformat +import AetherLibavutil import AetherLibavcodec @testable import AetherEngine final class SecondarySubtitleChannelTests: XCTestCase { - // Documents the basis of the secondary text-only guard: the four - // bitmap subtitle codecs must classify as bitmap, text codecs must not. + // Bitmap classification is shared by both channels. Decoder availability still depends + // on the shipped FFmpeg build (in particular, XSUB is not built). func testBitmapCodecClassification() { XCTAssertTrue(EmbeddedSubtitleDecoder.isBitmapCodec(AV_CODEC_ID_HDMV_PGS_SUBTITLE)) XCTAssertTrue(EmbeddedSubtitleDecoder.isBitmapCodec(AV_CODEC_ID_DVB_SUBTITLE)) @@ -29,3 +32,94 @@ final class SecondarySubtitleChannelTests: XCTestCase { XCTAssertFalse(engine.isSecondarySubtitleActive) } } + +/// Generated PGS packets deliberately reuse palette/object id zero on two streams. +/// Drive the engine's actual packet-store drain with real shipped FFmpeg decoders. +@MainActor +struct SecondaryBitmapSubtitleTests { + static func segment(_ type: UInt8, _ body: [UInt8]) -> Data { + Data([type, UInt8(body.count >> 8), UInt8(body.count & 255)] + body) + } + static func pcs(x: Int, state: UInt8, clear: Bool = false) -> Data { + var body: [UInt8] = [7, 128, 4, 56, 16, 0, 1, state, 0, 0, clear ? 0 : 1] + if !clear { body += [0, 0, 0, 0, UInt8(x >> 8), UInt8(x & 255), 3, 132] } + return segment(0x16, body) + } + static func displaySet(x: Int, luminance: UInt8) -> Data { + let palette = segment(0x14, [0, 0, 0, 16, 128, 128, 0, 1, luminance, 128, 128, 255]) + let rle = Array(repeating: [0, 0x88, 1, 0, 0] as [UInt8], count: 8).flatMap { $0 } + let object = segment(0x15, [0, 0, 0, 0xc0, 0, 0, 44, 0, 8, 0, 8] + rle) + return pcs(x: x, state: 0x80) + palette + object + segment(0x80, []) + } + + @Test("PGS channels isolate palettes, clear, off and cached reconstruction", .timeLimit(.minutes(1))) + func independentChannels() async throws { + let format = try #require(avformat_alloc_context()) + defer { avformat_free_context(format) } + for _ in 0..<2 { + let stream = try #require(avformat_new_stream(format, nil)) + stream.pointee.time_base = AVRational(num: 1, den: 90_000) + stream.pointee.codecpar.pointee.codec_type = AVMEDIA_TYPE_SUBTITLE + stream.pointee.codecpar.pointee.codec_id = AV_CODEC_ID_HDMV_PGS_SUBTITLE + } + let engine = try AetherEngine() + defer { engine.clearSubtitle(); engine.clearSecondarySubtitle() } + engine.loadedURL = URL(string: "https://example.invalid/movie.mkv")! + func drain() async { + engine.requestSubtitleDrainTick() + while let pending = engine.subtitleDrainTickInFlight { await pending.value } + } + let store = SubtitlePacketStore() + engine.softwareSubtitlePacketStore = store + engine.subtitleDrainDecoderFactoryForTesting = { index in + guard (0..<2).contains(index), let stream = format.pointee.streams[Int(index)] else { return nil } + return EmbeddedSubtitleDecoder(stream: stream, sourceVideoWidth: 1920, sourceVideoHeight: 1080) + } + func harvest(_ stream: Int32, _ time: Double, _ data: Data) { + store.harvestChunk(streamIndex: stream, ptsSeconds: time, durationSeconds: 0, + flags: 0, payload: data, assembleSplitDisplaySets: true) + } + func image(_ cues: [SubtitleCue], at time: Double) throws -> SubtitleImage { + let cue = try #require(cues.first { $0.startTime <= time && $0.endTime > time }, "no bitmap at \(time); intervals \(cues.map { [$0.startTime, $0.endTime] })") + guard case .image(let image) = cue.body else { + Issue.record("expected a decoded bitmap"); throw NSError(domain: "fixture", code: 1) + } + return image + } + func pixels(_ image: SubtitleImage) -> Data { image.cgImage.dataProvider!.data! as Data } + harvest(0, 10, Self.displaySet(x: 100, luminance: 235)) + harvest(1, 10, Self.displaySet(x: 300, luminance: 100)) + // Composition-only packets require the decoder's previously cached palette/object. + harvest(0, 12, Self.pcs(x: 100, state: 0) + Self.segment(0x80, [])) + harvest(1, 11, Self.pcs(x: 300, state: 0, clear: true) + Self.segment(0x80, [])) + engine.clock.sourceTime = 10 + engine.selectSubtitleTrack(index: 0) + engine.selectSecondarySubtitleTrack(index: 1) + await drain() + let primary = try image(engine.subtitleCues, at: 10) + let secondary = try image(engine.secondarySubtitleCues, at: 10) + #expect(abs(primary.position.origin.x * 1920 - 100) < 0.01) + #expect(abs(secondary.position.origin.x * 1920 - 300) < 0.01) + #expect(pixels(primary) != pixels(secondary)) + #expect(!engine.secondarySubtitleCues.contains { $0.startTime <= 11.5 && $0.endTime > 11.5 }) + #expect(pixels(try image(engine.subtitleCues, at: 12)) == pixels(primary)) + let primaryDecoder = try #require(engine.subtitleDrainDecoders[.primary]) + let secondaryDecoder = try #require(engine.subtitleDrainDecoders[.secondary]) + #expect(primaryDecoder !== secondaryDecoder) + engine.clearSecondarySubtitle() + #expect(engine.subtitleDrainDecoders[.primary] === primaryDecoder) + #expect(engine.subtitleDrainDecoders[.secondary] == nil) + #expect(engine.secondarySubtitleCues.isEmpty) + #expect(pixels(try image(engine.subtitleCues, at: 12)) == pixels(primary)) + engine.selectSecondarySubtitleTrack(index: 1) + await drain() + #expect(pixels(try image(engine.secondarySubtitleCues, at: 10)) == pixels(secondary)) + // A seek resets both channel cursors and reconstructs from retained packets. + engine.clock.sourceTime = 200 + await drain() + engine.clock.sourceTime = 10 + await drain() + #expect(pixels(try image(engine.subtitleCues, at: 10)) == pixels(primary)) + #expect(pixels(try image(engine.secondarySubtitleCues, at: 10)) == pixels(secondary)) + } +} diff --git a/docs/formats.md b/docs/formats.md index 7257a04a9..8254e620a 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -416,7 +416,7 @@ The same resolution picks the `DEFAULT=YES` native rendition from `nativeSubtitl ### Second simultaneous subtitle track (bilingual) -A second subtitle channel can run alongside the primary for bilingual playback / language learning: `selectSecondarySubtitleTrack(index:)` for an embedded track and `selectSecondarySidecarSubtitle(url:httpHeaders:)` for a sidecar file, mirroring the primary API. Its cues land in a separate `@Published secondarySubtitleCues` list (so the host can render the two channels independently, e.g. top vs bottom), with `isSecondarySubtitleActive` and `isLoadingSecondarySubtitles` for UI state; `clearSecondarySubtitle()` tears it down. The secondary channel decodes through the same demux loop and PTS rules as the primary. +A second subtitle channel can run alongside the primary for bilingual playback / language learning: `selectSecondarySubtitleTrack(index:)` for an embedded track and `selectSecondarySidecarSubtitle(url:httpHeaders:)` for a sidecar file, mirroring the primary API. Its cues land in a separate `@Published secondarySubtitleCues` list (so the host can render the two channels independently, e.g. top vs bottom), with `isSecondarySubtitleActive` and `isLoadingSecondarySubtitles` for UI state; `clearSecondarySubtitle()` tears it down. Embedded text and supported bitmap tracks (PGS, DVB and DVD) use the shared packet store with independent per-channel decoders, cursors and PGS admission state. XSUB classification alone does not imply decoder availability in the shipped FFmpeg build. Clearing or rebuilding the secondary channel does not clear the primary. The host owns rendering both cue lists, including separate bitmap layers and any placement or delay policy. A single packet that carries multiple rects (PGS often emits signs/songs at the top alongside dialogue at the bottom) becomes multiple cues at the same time range, and the host renders all of them. Cues are inserted in sorted order; re-emitted events after a seek dedupe by time range plus content (so two simultaneous speaker lines with identical timing both survive) and the list doesn't grow on rewind. From 1e5353a8debf52e0177a569c24fe8d2f87d5ed3b Mon Sep 17 00:00:00 2001 From: orut34iop Date: Sun, 18 Oct 2026 14:15:12 +0800 Subject: [PATCH 2/2] fix(hls): gate each segment request on FIFO headroom --- CHANGELOG.md | 4 + .../IO/HLSIngest/HLSVODIngestReader.swift | 15 +++ .../Issue732PersistentHTTP1Tests.swift | 99 ++++++++++++++++++- docs/api.md | 2 +- 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f69ff13..3d948266e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ the public-API contract. ## [Unreleased] +### Fixed + +- Persistent HTTP/1.1 HLS ingest checks FIFO headroom before every segment request, including when the previous body’s final chunk filled the buffer with no pending remainder. Seek, close and cancellation retire the waiting producer. + ### Changed - **The documentation no longer says TrueHD Atmos objects are always lost.** The Known limitations diff --git a/Sources/AetherEngine/IO/HLSIngest/HLSVODIngestReader.swift b/Sources/AetherEngine/IO/HLSIngest/HLSVODIngestReader.swift index 989560402..6631f1d1b 100644 --- a/Sources/AetherEngine/IO/HLSIngest/HLSVODIngestReader.swift +++ b/Sources/AetherEngine/IO/HLSIngest/HLSVODIngestReader.swift @@ -475,6 +475,9 @@ final class HLSVODIngestReader: TimeSeekableIOReader, @unchecked Sendable { defer { primed?.body.abandon() } for (offset, (segment, url)) in segments.enumerated() { try Task.checkCancellation() + // Runs on the producer's BlockingWork executor. The last offered chunk can fill the + // FIFO with no pending remainder, so admission must not depend on append() parking. + guard waitForSegmentAdmission(generation: generation) else { return } if let crypt = segment.crypt { let bytes = try await decrypt( try await fetch(url, limit: BoundedFetch.segmentLimit(forDuration: segment.duration)), @@ -574,6 +577,18 @@ final class HLSVODIngestReader: TimeSeekableIOReader, @unchecked Sendable { return body } + /// Wait between requests, after the previous body has completed. Seek/close wake the producer + /// and invalidate its generation; a superseded producer must never open the next segment. + private func waitForSegmentAdmission(generation: UInt64) -> Bool { + condition.lock() + defer { condition.unlock() } + while self.generation == generation, !closed, !failed, + bufferedBytes >= Self.maximumBufferedBytes { + condition.wait() + } + return self.generation == generation && !closed && !failed + } + private func append(_ data: Data, generation: UInt64) -> Bool { condition.lock() defer { condition.unlock() } diff --git a/Tests/AetherEngineTests/Issue732PersistentHTTP1Tests.swift b/Tests/AetherEngineTests/Issue732PersistentHTTP1Tests.swift index 799ba0218..7c06efb34 100644 --- a/Tests/AetherEngineTests/Issue732PersistentHTTP1Tests.swift +++ b/Tests/AetherEngineTests/Issue732PersistentHTTP1Tests.swift @@ -1,6 +1,7 @@ import CommonCrypto import Foundation import XCTest +import Testing @testable import AetherEngine /// AE#732: under `heldSourceConnection` the HLS VOD ingest carries its playlist, probe and segment @@ -426,14 +427,14 @@ final class Issue732PersistentHTTP1Tests: XCTestCase { static let markerOffset = 100 - private static func mediaPlaylist(_ segments: [String]) -> Data { + fileprivate static func mediaPlaylist(_ segments: [String]) -> Data { var text = "#EXTM3U\n#EXT-X-TARGETDURATION:6\n" for segment in segments { text += "#EXTINF:6,\n\(segment)\n" } return Data((text + "#EXT-X-ENDLIST").utf8) } /// TS packets whose first PMT declares HEVC (0x24); the marker sits in the PMT packet's filler. - private static func transportStream(marker: UInt8, packets: Int = 3) -> Data { + fileprivate static func transportStream(marker: UInt8, packets: Int = 3) -> Data { var bytes = [UInt8](repeating: 0xFF, count: 188 * packets) for packet in 0.. Data { + fileprivate static func aes128CBC(_ data: Data, key: Data) -> Data { var out = Data(count: data.count + kCCBlockSizeAES128) var written = 0 let iv = Data(count: kCCBlockSizeAES128) @@ -684,3 +685,95 @@ final class KeepAliveOrigin: @unchecked Sendable { } } } + +@Suite("Persistent HLS segment-boundary admission", .serialized, .timeLimit(.minutes(1))) +struct PersistentHLSSegmentAdmissionTests { + enum Exit: String, CaseIterable, Sendable { case consume, seek, close, cancel } + + @Test("a final chunk filling the FIFO holds the next request until consumption or retirement", + arguments: Exit.allCases, [false, true]) + func finalChunkAdmission(exit: Exit, encryptedNext: Bool) async throws { + let server = try #require(KeepAliveOrigin()) + defer { server.stop() } + let segments = (0..<4).map { "seg\($0).ts" } + // After the one 188-byte read below, these two segments leave 16,777,308 bytes: + // just above the 16 MiB ceiling. The final offered chunk can empty `pending` while + // filling the FIFO, which used to skip append() and start the next request. + let packetCounts = [64_000, 25_242, 3, 3] + let key = Data(repeating: 0x43, count: 16) + var playlist = String(decoding: Issue732PersistentHTTP1Tests.mediaPlaylist(segments), as: UTF8.self) + if encryptedNext { + playlist = playlist.replacingOccurrences(of: "#EXTINF:6,\nseg2.ts", + with: "#EXT-X-KEY:METHOD=AES-128,URI=\"/key\",IV=0x00000000000000000000000000000000\n#EXTINF:6,\nseg2.ts") + } + let playlistBody = Data(playlist.utf8) + server.route = { path in + if path == "/vod.m3u8" { + return .init(body: playlistBody) + } + if path == "/key" { return .init(body: key) } + guard let index = segments.firstIndex(where: { "/" + $0 == path }) else { + return .init(status: 404) + } + let clear = Issue732PersistentHTTP1Tests.transportStream( + marker: UInt8(index), packets: packetCounts[index]) + return .init(body: encryptedNext && index >= 2 + ? Issue732PersistentHTTP1Tests.aes128CBC(clear, key: key) : clear) + } + let reader = try #require(try await HLSVODIngestReader.makeIfHEVCMPEGTS( + playlistURL: server.url("/vod.m3u8"), httpHeaders: [:], persistentHTTP1: true)) + defer { reader.close() } + let firstRead = ProbeTestJob { () -> Int32 in + var bytes = [UInt8](repeating: 0, count: 188) + return bytes.withUnsafeMutableBufferPointer { reader.read($0.baseAddress, size: 188) } + } + #expect(try await firstRead.outcome().get() == 188) + try await waitFor { server.completed.contains("/seg1.ts") } + let escaped = try await waitFor(upTo: .milliseconds(500)) { + server.requests.contains { $0.path == "/seg2.ts" || $0.path == "/key" } + } + #expect(!escaped, "the final chunk must not bypass the next request's admission") + + switch exit { + case .consume: break + case .seek: #expect(reader.seek(to: 20)) // starts at segment 2, before the containing segment 3 + case .close: reader.close() + case .cancel: reader.cancel() + } + let drain = ProbeTestJob { () -> (bytes: Int, marker: UInt8?, terminal: Int32) in + var bytes = [UInt8](repeating: 0, count: 4096) + var total = 0 + var marker: UInt8? + while true { + let n = bytes.withUnsafeMutableBufferPointer { reader.read($0.baseAddress, size: 4096) } + if n <= 0 { return (total, marker, n) } + if marker == nil, n > Issue732PersistentHTTP1Tests.markerOffset { + marker = bytes[Issue732PersistentHTTP1Tests.markerOffset] + } + total += Int(n) + } + } + let result = try await drain.outcome().get() + switch exit { + case .consume: + #expect(result.bytes + 188 == packetCounts.reduce(0, +) * 188) + #expect(result.terminal == 0) + case .seek: + #expect(result.bytes == 6 * 188) + #expect(result.marker == 2) + #expect(result.terminal == 0) + case .close, .cancel: + #expect(result.bytes == 0) + #expect(result.terminal == -1) + let lateRequest = try await waitFor(upTo: .milliseconds(500)) { + server.requests.contains { $0.path == "/seg2.ts" || $0.path == "/key" } + } + #expect(!lateRequest, "a retired producer must not issue the next request") + } + if exit == .consume || exit == .seek { + #expect(server.requests.filter { $0.path == "/seg2.ts" }.count == 1) + #expect(server.connectionCount == 1) + #expect(server.maxInFlight == 1) + } + } +} diff --git a/docs/api.md b/docs/api.md index 16362779c..06fb61fcd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1334,7 +1334,7 @@ All flags default to safe values; the table is the full set. Depth for the media | `declaredDurationSeconds` | nil | Trusted duration, overriding the container's. Required alongside `sequentialOrigin` on VOD, where the tail read is gone. | | `maxConcurrentSourceRequests` | nil | Most requests the reader may have open against this origin at once, across every path it fetches on (pump ranges, detour blocks, size probes, tail prefetch, subtitle side reader, and the HLS live ingest's playlist, segment and key fetches, whose prefetch window narrows to it, AE#678). nil counts without capping and lowers the ceiling on its own after a 429/503/509. Set it when the provider states a limit; `1` also switches off the speculative parallel paths, which exist only to overlap with the pump. Counts **requests**, not TCP connections, because over HTTP/2 a session multiplexes every request onto one connection while the origin still counts each one (AE#377). It is also the only ceiling: several engines playing from one origin are bounded by this value and by what the origin refuses, not by a transport pool underneath it (AE#450). | | `sourceOpenPolicy` | `SourceOpenPolicy()` | HTTP VOD opening budgets for the playback reader and its reopens: `firstByteTimeout` (15 s) bounds the first data wait, `sizeProbeTimeout` (25 s) bounds the one retry and the whole size discovery, slot waits included. Nonpositive or nonfinite values take the defaults, anything above 120 s is capped. A request that gets no response is retried once with an open-ended Range and that connection stays the playback connection; two unanswered requests fail as `.sourceOpenFailed` instead of demoting the source to forward-only. A real length-less or Range-ignoring response keeps the ordinary sequential handling. Single-request origins run the size probes serially, others race them and join the losers before playback starts. Live, `sequentialOrigin`, remote HLS and disposable probes keep their own budgets. | -| `heldSourceConnection` | false | Ask the source **once** and pull it, instead of ending the connection at the reader's window high water and asking again every 8 to 16 MB of drain (AE#377). For an origin that punishes repeated requests rather than concurrency: some CDNs refuse new requests for minutes at a stretch while serving an already open connection at full rate, and against one of those the request cadence is the defect, at any range size (measured at the reporting origin: 32 MB ranges raised to 256 MB, eight times fewer requests, the refusals unchanged). Where `maxConcurrentSourceRequests` bounds how many requests are in flight, this removes the second request. The read path is the engine's own HTTP/1.1 over a demand-driven stream task, so: **HTTP/1.1 only** (no ALPN, an HTTP/2-only origin is out of scope), the **system proxy configuration does not apply**, and TLS is the OS's through the same host trust decision as every other engine session but has not been exercised against a self-signed origin. A viewer who pauses ends the connection after five seconds and resuming costs one request at the frontier, because a held flow nobody reads is the process-wide Network.framework starvation of AE#310. Applies to the playback reader; the subtitle and enrichment side readers keep the default transport, since they park for minutes at a time. The open-time detection of an origin that ignores `Range` (see `sequentialOrigin`) does not cover it, because its open is open-ended. A finite HLS VOD on the HEVC-in-MPEG-TS ingest (AE#268) is covered too (AE#732): playlist, key and segment requests run over one persistent HTTP/1.1 connection per origin, reused across segments and fetched one at a time, for origins that refuse h2 or new connections but serve consecutive requests on one keep-alive connection. Each body is streamed into the ingest as it arrives, and only the wait for a response head has a wall-clock ceiling (45 s); a body ends on its size limit or on 30 s without a byte, never on its duration. A new connection opens only on a server close, an origin change, or a seek that abandons a body with more than 2 MB left or too slow to arrive within 3 s (otherwise the remainder is drained and the connection kept, with the next request waiting for it), and the log names each connection's protocol and request count. **Names the session**: the transport is chosen at open, so a reload that changes it is refused. | +| `heldSourceConnection` | false | Ask the source **once** and pull it, instead of ending the connection at the reader's window high water and asking again every 8 to 16 MB of drain (AE#377). For an origin that punishes repeated requests rather than concurrency: some CDNs refuse new requests for minutes at a stretch while serving an already open connection at full rate, and against one of those the request cadence is the defect, at any range size (measured at the reporting origin: 32 MB ranges raised to 256 MB, eight times fewer requests, the refusals unchanged). Where `maxConcurrentSourceRequests` bounds how many requests are in flight, this removes the second request. The read path is the engine's own HTTP/1.1 over a demand-driven stream task, so: **HTTP/1.1 only** (no ALPN, an HTTP/2-only origin is out of scope), the **system proxy configuration does not apply**, and TLS is the OS's through the same host trust decision as every other engine session but has not been exercised against a self-signed origin. A viewer who pauses ends the connection after five seconds and resuming costs one request at the frontier, because a held flow nobody reads is the process-wide Network.framework starvation of AE#310. Applies to the playback reader; the subtitle and enrichment side readers keep the default transport, since they park for minutes at a time. The open-time detection of an origin that ignores `Range` (see `sequentialOrigin`) does not cover it, because its open is open-ended. A finite HLS VOD on the HEVC-in-MPEG-TS ingest (AE#268) is covered too (AE#732): playlist, key and segment requests run over one persistent HTTP/1.1 connection per origin, reused across segments and fetched one at a time, for origins that refuse h2 or new connections but serve consecutive requests on one keep-alive connection. Each body is streamed into the ingest as it arrives. A full FIFO lets the current body finish but admits no next segment or key request until consumption makes room; seek, close or cancellation retires that wait. Only the wait for a response head has a wall-clock ceiling (45 s); a body ends on its size limit or on 30 s without a byte, never on its duration. A new connection opens only on a server close, an origin change, or a seek that abandons a body with more than 2 MB left or too slow to arrive within 3 s (otherwise the remainder is drained and the connection kept, with the next request waiting for it), and the log names each connection's protocol and request count. **Names the session**: the transport is chosen at open, so a reload that changes it is refused. | | `autoplay` | true | False mounts paused: the load skips the terminal `play()` and settles at `.paused` for a host that resumes later. It describes THIS MOUNT and nothing after it: the rebuilds a session makes on its own (`reloadAtCurrentPosition`, an option correction, the AirPlay LAN swap, an audio-delay nudge) come back in the transport state the session is in, not in this one. Correcting it through `reloadAtCurrentPosition(applying:)` therefore does nothing (the log names it as not applied rather than as applied, AE#464 round 3, and the call returns it as `sessionOwned` with `rebuilt` false rather than spending a teardown on it, AE#464 round 4); call `play()` or `pause()` instead. | ## Value types