Releases: superuser404notfound/AetherEngine
Release list
6.28.0 - One origin request budget, and a 429 that is a not-yet
Drop-in from 6.27.1. Two additive API surfaces, and one behaviour change that only a source being rate limited can reach. Both come out of #377, where a reporter's origin meters requests and the engine kept reading its refusals as a death.
Half of what the reader knew never left the reader
AVIOReader.isRateLimitStatus has existed for a while and is used in seven places, every one of them inside AVIOReader.swift. Sources/AetherEngine/Video/ contained no reference to rate limiting at all. The classification died at the give-up arm, which returns a bare -1: FFmpeg renders that as "Operation not permitted", and the session's revive arm saw exactly what a source that is genuinely gone produces.
So a metered source spent both attempts of a two-attempt budget inside a minute, each one reopening from byte 0 against an origin refusing precisely that, and then declared the source "not readable in this session". The counter-evidence was in the same report: back out, press play, it starts immediately. The verdict was not merely unhelpful, it was false.
A 429 is a not-yet. It now takes its own larger budget with a growing backoff (3 s, 8 s, 20 s, 45 s) rather than an immediate reopen, and the terminal surface is a new kind:
if info.kind == .sourceRateLimited {
// the source is being metered, not lost. The same request works later,
// and a handoff to a second player meets the same refusal.
}That last clause is the reason this is its own kind rather than a message: the reporter's host is dual-engine, and its fallback engine asked the same origin and was refused identically.
Four connection caps that never composed into one
httpMaximumConnectionsPerHost is a per-URLSession cap, and the reader fetches over four pools: the pump's ranges (2), detour blocks (2), size probes on URLSessionConfiguration.default (6), plus a per-call streaming session. The subtitle side reader shares the same static pools. Against one signed CDN URL a pump range, a detour block and a probe could all be open at once, and nothing anywhere held the sum.
The cap also measures the wrong thing, which is the part that cannot be established from outside the engine: over HTTP/2 a URLSession multiplexes every request of a session onto a single connection, so a cap of 1 there bounds nothing while the origin still counts every request. URLSessionTaskMetrics.networkProtocolName was read nowhere in the tree. It now logs one line per origin saying which case that origin is.
OriginRequestBudget counts requests per origin, keyed on scheme+host+port so a rotating signed token shares one ceiling rather than starting a fresh one per refresh. Counting is unconditional; capping is not. With no limit set nothing waits and a healthy origin behaves exactly as before. A limit arrives either from the host or from the origin itself, halving from the concurrency actually reached on each refusal:
try await engine.load(url: url, options: LoadOptions(
maxConcurrentSourceRequests: 1 // provider documents one connection per link
))Deadlock is excluded structurally rather than managed. At one slot the speculative parallel paths (detour blocks, the tail prefetch) switch off instead of queueing, and each already had a serial fallback: the detour's is repositioning the persistent connection, the probe fan's is running in order. Nothing ever blocks on a slot its own caller holds, and no acquire can park a read indefinitely.
Measured against an origin that meters
A test origin with real Range support that answers 429 above one in-flight request, playing one file:
| run | peak concurrency the origin saw | 429s | requests served |
|---|---|---|---|
| budget learns from the refusals | 2 | 2 | 3, session survives |
maxConcurrentSourceRequests: 1 |
1 | 0 | 1 |
The measurement also named the request nobody suspected. The tail prefetch leaves microseconds before the first data connection, so a metered origin sees two requests on the very first open, and it is the pump that gets refused. That is the "429 not long after first opening a file, long before any real number of requests" from the report. It is speculative and nobody waits on it, so it now takes a slot only if one is free, and it counts when it does; while it did not, the budget reported a peak of 1 for an origin that had just seen 2.
A refusal is also recorded against the source URL, not only the URL that produced it. A metered source is routinely a proxy that 302s to a CDN: the CDN refuses, the engine's revive arm only ever knows the URL the host loaded, and keying the verdict solely on the refusing host would have built a classification that is never once reached.
Also in this release
The slow read: summary carries origin=<n>inflight/<peak>peak limit=<n>, so the concurrency a metered origin was reacting to is in the line a field report already sends. aetherctl play --max-concurrent-requests N reproduces a connection-capped origin.
Not claimed
The budget throttles what this process asks of an origin; it cannot know what that origin actually permits, and no automatic increase ever raises a learned limit back. Whether request count, concurrency or bandwidth is what a given provider meters is still the provider's business: this makes the concurrency observable and bounded, and names the transport, so the next trace can answer it rather than repeat the question.
Reported by Rasmusmart57 (#377).
6.27.1 - The live join gate names what it held
One diagnostic change on the loopback live path. No API change, no change to playback itself, drop-in from 6.27.0.
A startup nobody could see from outside
A loopback live session's entire join latency is one withheld /media.m3u8 response. The first serve holds until the window carries the live-edge holdback (3 x TARGETDURATION, the RFC 8216bis floor the served playlist advertises) of content behind the edge, because serving earlier puts AVPlayer's opening seek inside its own stall-danger zone, where it restarts in a loop instead of playing (#189).
Everything else the engine does for that session is finished before the gate is entered. Measured with aetherctl live against a paced raw-MPEG-TS origin, same machine and fixture, varying only the backlog the origin holds at join:
| backlog at join | first readyToPlay |
|---|---|
| 0 s (strict realtime) | 18.66 s |
| 6 s | 12.70 s |
| 12 s | 6.75 s |
| 30 s | 0.43 s |
The startup ladder reached sessionConstructed at +0.19 s in every one of them. The wait is the runway being filled at whatever rate the origin hands it over, and nothing else.
Only a FAILED gate used to log. A successful eighteen-second hold left no trace at all, so a host measuring a slow live start had no way to tell it apart from a slow probe without instrumenting from outside the engine.
What every exit says now
[HLSVideoEngine] first live manifest served after 18.154s: 5 segments / 20.000s >= 18.000s holdback (TARGETDURATION 6s)
[HLSVideoEngine] first live manifest served after 0.088s: 5 segments / 20.000s >= 18.000s holdback (TARGETDURATION 6s)
[HLSVideoEngine] first live manifest served after 10.284s: 2 segments / 10.000s < 15.000s holdback (TARGETDURATION 5s), fastZap bounded start after 2.000s grace
Two of those exits changed more than their wording:
- The bounded
.fastZapstart reported its grace alone, which is the last leg of the wait rather than the wait. The third line above described itself as2.000swhile having held for10.284s. - The exit where no segment was ever cut returned in silence, which from a host's side is the one outcome indistinguishable from a merely slow origin. It says so now.
The interval is monotonic, because it is a measurement. One account per session, so a steady playlist refresh does not repeat it.
Reading a slow live start
startupProgress stalls at sessionConstructed for the whole wait, so the checkpoint at the slow moment separates this from the demux probe (streamsProbed) and the display handshake (routed).
LoadOptions.liveJoinProfile is the lever. .fastZap collapses TARGETDURATION to the source keyframe cadence and the holdback follows it down, but the win belongs to the source GOP rather than to the flag, since TARGETDURATION can never fall below ceil(max EXTINF): on a 1 s-GOP fixture .fastZap reached readyToPlay at 1.98 s against .standard's 18.66 s, and on a 5 s-GOP one it still took 12.59 s.
docs/api.md gains Where a live start's seconds go, the paragraph this section summarises.
Raised by @ksktech-dev while measuring AetherEngine against libVLC across their device matrix (#374).
6.27.0 - The legacy Microsoft video tail
One bundled-FFmpeg change and its documentation. No engine source change beyond the pin and a routing test, no API change, drop-in from 6.26.0.
Six codecs that routed correctly and then had nothing to decode them
A pre-2005 AVI rip carrying MS-MPEG4 v3 (the codec everyone knew as "DivX 3.11") reached SoftwareVideoDecoder and stopped there:
[AetherEngine] dispatch: codec=16 → software
LOAD FAILED: unsupportedCodec(id: 16)
The routing was right. Since FFmpegBuild#1 the default for every codec the native path does not carry is the software path, so nothing here needed inverting. What was missing sat one layer down: the FFmpeg build's --enable-decoder= allowlist named mpeg4 and vc1 but not their Microsoft variants, so avcodec_find_decoder returned nil and the load failed at the last possible moment. wmv3 (WMV9) had the same gap for the same reason: --enable-decoder=vc1 does not register the separate wmv3 decoder, so plain VC-1 worked and its sibling did not.
FFmpegBuild 2.4.3, which this release pins, adds all six: msmpeg4v1, msmpeg4v2, msmpeg4v3, wmv1, wmv2, wmv3. Only two were reported, but the family moves together, since msmpeg4v1/v2/v3 and wmv1/wmv2 all select msmpeg4dec and wmv3 selects the vc1_decoder that was already enabled. The whole set costs 32 KB on an arm64 device slice.
What now plays
MS-MPEG4 v1 / v2 / v3 and WMV1 / WMV2 wherever a supported container carries them, AVI included, since the avi demuxer was already in the build. WMV3 covers WMV9 inside Matroska and MPEG-TS, where the container's own demuxer supplies the stream.
Measured with aetherctl play on real media: msmpeg4v3 in AVI and in Matroska, msmpeg4v2, wmv1 and wmv2 in AVI, and, for wmv3, which no encoder anywhere produces, a real WMV9 Main stream remuxed -c:v copy into Matroska.
[SWDecoder] Opened: 640x480, codec=msmpeg4, threads=1, 8-bit → state=ended
[SWDecoder] Opened: 320x240, codec=wmv3, threads=1, 8-bit → plays through
None of the six supports frame threading, so they decode single-threaded where mpeg4 opens eight threads. The content that carries them is SD, so this is a note rather than a limit.
What still does not play, on purpose
A native .wmv / .asf file. It fails at avformat_open_input, because the build carries neither the asf demuxer nor a WMA decoder, and that set has to move as one. With the demuxer but no WMA decoder, AudioCodecCompat maps the unrecognised audio id to .unsupported and the session drops to video-only, so the file would play silently. A silent file reads as a playback bug; an unsupported-format error is at least honest about what happened. If a real .wmv case turns up in the field, asf plus wmav1 / wmav2 plus the matching audio-route entry ship together.
Reported by @cmcpherson274 in FFmpegBuild#3.
6.26.0 - The error string is a payload, not a key
Drop-in from 6.25.4. One additive API, no change to how anything plays. It comes out of #374, where a shipping dual-engine host set out to build an analytics classifier over state = .error(...) and, incidentally, out of reading our own documentation back against the code.
Half of that sentence was never ours
The docs said the message inside .error is the engine's own sentence, worth logging verbatim. That is true for the messages that name a cause, and false for the ones most failing sessions actually produce. On the native paths the published string is AVPlayerItem.error.localizedDescription, forwarded with no prefix and no wrapper, so it arrives in whatever language the device is set to, and the NSError domain and code that would classify it are gone by the time a host sees it.
Both halves arrive through the same publisher. Nothing on the surface separates them except the text, which is precisely the thing that cannot be a key. A host bucketing failures by English substring therefore files every non-English device under "unknown", and reads that back as "cause unknown" when the truth is "cause untranslated".
What is published
player.$state
.sink { state in
guard case .error = state, let info = player.errorInfo else { return }
analytics.record(failure: info.kind.rawValue, // stable token, carries no origin
domain: info.underlyingDomain, // nil where the engine authored it
code: info.underlyingCode,
route: player.videoRoute.rawValue)
log(info.message) // for a human, not for a bucket
}PlaybackErrorKind is a string-backed struct rather than an enum, deliberately: the set grows whenever the engine learns a new way to fail, and a host switching exhaustively over an enum would stop compiling on a minor release. The raw values are API and do not change. Fifteen kinds ship, from .sourceOpenFailed and .liveSourceUnavailable through .nativeItemFailed, .noPlayableTrackWithinBudget and .masterPlaylistRejected to .reloadFailed and .audioTrackSwitchFailed.
A non-nil underlyingDomain is also the marker for "this message has been through a translator", which is the other question a host could not answer before.
There is a second consumer of this shape, and it is not analytics: a telemetry contract that forbids raw player error strings leaving the device, because those strings can name a stream host. The engine never interpolates a URL or a host into .error, so the engine-authored half is origin-free by construction, and a numeric code is origin-free whatever produced it. Both are shippable where the message is not.
One funnel, and a test that keeps it
errorInfo is assigned before state, so a $state sink reads this failure's own info rather than the previous failure's, and it is cleared by the state's own move away from .error, so the two cannot drift apart. Every failure site now publishes through a single funnel, and a test fails the build if a new state = .error(...) appears anywhere outside it. An unclassifiable error is the shape this release exists to remove, so it should not be reachable by writing ordinary-looking code.
Also in this release
docs/api.md gained the host-side answer to a live retune against a rotating per-session token. The #168 carriage verdict is remembered per exact absolute URL, which a rotated token misses by construction, so the retune re-pays the native mount plus up to 4 s of watchdog grace every lap. The key the memory cannot have is one an IPTV host does have: the channel. $videoRoute publishes the reroute as it happens, so a host can record the verdict against its own channel id and open the next session on HLSLiveIngestReader directly.
Not claimed
The kinds classify what the engine knows about a failure, not what AVFoundation knows: underlyingCode is passed through untouched and this release maps no CoreMedia or AVFoundation codes to meanings of its own. Kinds may be added in later minor releases, which is why they are not an enum.
Thanks to @ksktech-dev and @kskchaitanya1993 on #374, whose classifier is the reason this gap was measured rather than assumed.
6.25.4 - The record loses its SEI at the door
Drop-in from 6.25.3. One fix on the fMP4 remux path, no API change. Second round on #365, reported and retested by @RomanLiberda: 6.25.0 fixed the sample framing on that path, the file still failed, and this is the next candidate standing on it.
Five NAL types, not three
When a source's config record is Annex B and its packets are Annex B too, the two agree and the record is forwarded as it is. That is deliberate: movenc reads the record to decide whether to convert the samples, and these samples do need converting. What follows from it is that movenc builds the hvcC itself, out of those Annex-B parameter sets, and ff_isom_write_hvcc collects five NAL types rather than three:
static const uint8_t array_idx_to_type[] =
{ HEVC_NAL_VPS, HEVC_NAL_SPS, HEVC_NAL_PPS,
HEVC_NAL_SEI_PREFIX, HEVC_NAL_SEI_SUFFIX };So a prefix SEI in a Matroska CodecPrivate, which is where x265 leaves its options string, becomes a fourth array in the init sample description. That is the record Apple TV's HEVC track builder rejects: asset.tracks count=0, no format description, the item fails before a frame is decoded. AE#187 is the same shape arriving through the other door.
The defense could not see this door
The engine has stripped non-parameter-set arrays since #187, and that defense sits on the record path: it guards on configurationVersion == 1, which an Annex-B buffer fails by construction, and the record movenc builds is never handled by the engine at all. Between the two of them there was no point at which the finished record was inspected.
The fix drops the non-parameter-set NALs on this side of the muxer and leaves the record in Annex B, so movenc's decision about the samples comes out exactly as before. Only the SEI is gone.
Generalised, because it is not specific to HEVC: a guard keyed on the shape of the input misses the door where the muxer builds the output itself.
Measured
Real 1080p Main10 PQ parameter sets (78 B of NAL payload in total) plus a 506 B user-data SEI, in Annex B, through the same movenc path the session muxer uses, comes back as a record whose arrays are [32, 33, 34, 39]. After the rewrite the same input yields [32, 33, 34].
Diagnostics
The #365 forward branch now names what the record is made of and what it dropped:
#365 ... the muxer builds the config record itself out of 726 B of Annex B
[VPS×1 (28 B), SPS×1 (112 B), PPS×1 (10 B), SEI_PREFIX×1 (570 B)],
dropped the non-parameter-set NALs before it does (→ 156 B, AE#187)
A record's size on its own does not separate a large SPS from an SEI, and only the SEI reaches the hvcC.
Not claimed
The file behind #365 has not been retested against this yet and its record has never been seen here. If that line comes back saying nothing to drop, this is not that file's defect and the DV configuration boxes in the init are next in line.
Two readings from the round-1 retest were checked against the code and do not hold, both narrowing the search rather than widening it. All four arms of the #35 readiness-gate ladder read the same init.mp4, because the ladder swaps the playlist URL in place and the reduced master is a playlist and not a second remux, so the DV-stripped arm failing identically rules out the playlist signalling and nothing about the init. And hvcc_add_nal_unit drops every NAL with nuh_layer_id > 0 unless it is writing an lhvC box, so enhancement-layer parameter sets cannot reach the record at all, whatever the CodecPrivate carries.
6.25.3 - A set can take its end back
Drop-in from 6.25.2. One fix on the PGS subtitle path, no API change. Reported, diagnosed and retested by @rrgomes in #362, which this is the second round of: 6.23.1 fixed the ends that reached a set from its own clear, and this fixes the ones that reached it from the far side of a stretch nobody read.
The answer was in the store, and it was final
A PGS display set has no end of its own; whatever packet follows on the stream closes it. Since 6.23.1 an open set takes the PTS of the next packet the store holds, which is the end the author put there and is available from the harvest long before the drain window reaches it.
After a seek burst the store also holds islands an earlier run harvested, so the first packet after a set can be a real packet that is not this set's successor. Two from the report, on a 4K HEVC Dolby Vision Profile 7 title after nine seeks in twelve seconds: a set at 75.117 s closed at 144.978 s with its own clear at 78.579 s, and a set at 145.187 s closed at 223.306 s, which is not a clear at all but the next set, 78 s out.
Publishing that packet is still right. The true successor can only be nearer, so the answer is an upper bound, and the alternative is a placeholder window that renders until something else closes it. Refusing was measured end to end in the first round and was worse (4 to 5 late ends per run against 0 to 2, worst by 74 s), because an open cue is laundered by the next seek into the reconstruction boundary. That boundary sits 15 s behind the landing, which on this round's worst fixture cue works out at +46 s against the island answer's measured +37 s.
What was wrong is that it was final. The close ran only over cues still carrying the placeholder window, so the moment a set took any end short of it, it was never revisited, and the clear that lands a second later, whose entire job is to trim that set, found a cue it was no longer allowed to touch. Nothing else was going to correct it either: the drain cursor moves forward only, so a packet that fills a hole behind it is never decoded and its pgsTrimAt never runs. A bitmap set has no end of its own, so every stored packet after it is a bound on its end and taking the nearest is monotone. The derivation now runs every tick and can only ever shorten, which is what makes the bound self-correcting rather than merely bounded. Text cues keep the placeholder gate: an authored duration is nobody else's to set.
A horizon, because the store cannot answer where it has been
The derivation walked the whole retained window with no bound at all, so it could answer with a packet the drain was not even looking at. It now stops at the drain window plus the forward prefetch's park margin, which is exactly as far as the harvest is designed to lead the drain, and which is why that margin exists (6.23.1, so the set at the window's forward edge has its own clear stored). Inside it, a stored packet is evidence the harvest was here and found this. Beyond it, the store holds whatever earlier runs left behind. The report's second case is 18 s past the window's edge and is now withheld, so the clear 5 s after the set wins the race it was losing.
Withholding is the only option that needed a bound, because the property it wants is not one the packets can carry. Whether the ground between a set and a stored packet was ever read is not a function of what arrived: a reader restarted BEHIND leaves a descending harvest sequence at the boundary, which is the discriminator 6.23.1 introduced, and a reader re-anchored FORWARD leaves an ascending one across the ground it skipped. On the fixture that reads as sequence 19, then 20, with 46 s of unread source between them. That one-sidedness is also why the harvestHole line stayed silent through all of this, which the report noticed and asked about.
It has no consequence on the delivery side, and that was measured rather than assumed: the ground a forward re-anchor skips lies behind the playhead that caused it, delivery deliberately never waits on a hole behind the playhead, and a return to that ground is a seek, which restarts the pump there and fills it. Across three seek shapes on the fixture, every authored set in the stretch the playhead actually played was delivered (23/23, 15/15, 13/13), including one run whose store carried that exact 46 s gap.
Diagnostics
A tick now reports endsWithheld=N when it left bitmap cues open because the only answer lay past that horizon. harvestGapAt reports where DELIVERY stopped and says nothing about an end derived from the same store on a different horizon, so a window carrying a wrong end with no gapAt beside it had no line of its own at all.
aetherctl play gained DROP #id and a closing WINDOW census next to its CUE and TRIM lines. It reported arrivals and end changes and nothing for a cue LEAVING the window, so a wrong end that a later reconstruction replaced read exactly like one the host still carries, which made the first two rounds of measurement here worthless.
Not fixed, and named
For a region a session abandons, no signal in the engine can produce the authored end: the ground is never read, so the nearest stored packet stays the best bound anyone has. On the fixture that leaves ends up to +37 s in a burst that never returns, unchanged from 6.25.2 and unchanged by any of the alternatives measured.
Measured
400 s H.264 fixture with a real PGS stream repeated every 26 s, 80 authored sets, clears 3.5 to 4 s later, served through an origin with a 250 ms delay on every request. Loopback cannot show this: it refills an island before the island can matter, and every arm reads identical there. Three runs per arm of the report's own recipe, scrub hard then land before a dense stretch and play through: 8 of 10 wrong ends corrected before, 9 of 10 after, and 9 of 9 on the release build.
6.25.2 - A timeshift archive on its own axis
Drop-in from 6.25.1. Three fixes on the sequential-origin path (IPTV timeshift archives served over a single byte-0 connection), no API change. All three were reported, diagnosed and fixed by @tschuegy, in #368, #369 and #370, against an Xtream timeshift .ts on tvOS 26.6; each merged PR carries a follow-up commit for what the review turned up.
A sequential origin is the source class from #346: a connection that can only be read from byte 0, no ranges, no reconnect, and an append-only EVENT playlist whose EXTINF values are the durations actually muxed. All three defects live in that combination.
A chunk seam is a 2^33 leap (#368)
An IPTV timeshift archive is a chunked recording, and every chunk restarts near PTS 0. libavformat's 33-bit wrap correction turns that backward seam into a forward leap of exactly 2^33: on the device trace the dts delta was 8226410192 ticks, and 363524400 + 8226410192 is 2^33 to the tick. The leap reached the keyframe-gated cutter unmodified and walked its monotonic segment index to the plan tail, after which the session was structurally dead: the playlist froze, the backpressure park waited on a segment the playlist can never advertise, and the wedge recovery's answer is a reposition, which is exactly what a sequential origin refuses.
The live path has folded program boundaries for a long time. That same rebase now runs for sequential-origin VOD, and the cutter, the #65 ledger and the append playlist needed no change, because they already work on post-shift output time. No EXT-X-DISCONTINUITY is added at the seam: the archive is content-continuous and the output timeline stays continuous after the rebase.
The follow-up is the other half of it. The rebase keeps the item axis straight by moving the producer's shift, and currentTime was folded as item + shift - origin against a source origin latched once at session start, so the entire wrap landed on the scrubber instead: 250 s to 63378 s, measured, on an archive whose declared duration is one hour, with bufferedPosition and sourceTime following it. A sequential archive has no source axis for a display origin to sit on, since every chunk restarts near PTS 0, while its item axis starts at 0 by construction and is exactly what the declared duration measures. It now publishes the item axis; every other source keeps the latched origin and true source PTS from #270.
What escapes the rebase (#369)
Same trace, further downstream. Three containment gaps, each of which turned a leap that got past the rebase into a long-lived zombie session rather than a bounded failure.
The look-behind sample duration handed movenc the wrap itself as a duration: 8226410192 ticks, rejected as invalid, packet silently lost. It is capped at the discontinuity threshold now, and the write return code is logged on first failure so the loss is visible at all. Discontinuity-scale fold runs were discarded above 64 indices, which is precisely the width most certain to arm the #358 recovery, so they now reach the fold counters. And the advance-path backpressure park could park on a release target beyond the sequential playlist's advertisable frontier, a frontier only this pump's own finalize reports can move, so parking on it was waiting for oneself (field case: a fold-to-tail parked at target 364 while the playlist ended at segment 61).
The follow-up covers the two edges those left. The duration cap only guarded the inferred delta and then fell through to the container's own declared duration untouched, and that fallback branch is not an edge case here: it is what runs when no forward delta exists, at the EOF tail of exactly the wrapped stream the cap was written for. movenc rejects a sample on the number, not on where the number came from.
The skipped park is the more consequential one. The #207 disk park deliberately carries no wedge breaker, on the reasoning that the advance park catches a frozen consumer first. Skipping the advance park walked the pump straight into that assumption with the consumer possibly frozen: it races to the retention budget (2 GiB, about an hour of a 5 Mbps timeshift) and then holds there for good, which is worse than the multi-minute zombie the containment set out to kill. The skip itself is right, so the detector moves with it: the disk park now arms the same one-second wedge detector whose cadence it was already polling at.
A gate that waited for two of something that costs three (#370)
On a slow origin the session never started at all. AVPlayer's first /media.m3u8 GET was held for the full 30 s startup wait and the asset load timed out (-1008, CoreMedia -12884) while two segments and about 12 s of media already sat on disk.
The startup gate reused LiveEdgePolicy.minStartupSegments = 2, a live sliding-window constant whose reasoning is a live-edge holdback that cannot fit into a one-segment window. An append-only EVENT playlist has neither: it starts at media sequence 0, never removes anything, RFC 8216 §6.3.3 exempts EVENT from the three-target-duration rule, and the playlist's own refresh counter already defeats AVPlayer's unchanged-playlist patience. The demand was also steeper than it reads, because a sequential segment's real EXTINF is final only once the next segment's ledger opens, so two published durations are three segment opens, 12 to 18 s of media through a possibly throttled origin. The gate is one published duration now, and the live constant is untouched: it stays correct for live.
The second cost was in planning. For the uniform-stride fallback plan the keyframe-spacing scan (#358) starts with a seek, a silent no-op on the non-seekable sequential pb, and then consumes up to 20000 packets or 30 s of content from the single byte-0-only connection. A sequential origin never anchors its first producer past segment 0, so nothing seeks back between planning and the pump: those packets were not merely time lost, they were the archive's first GOPs, dropped. In both field traces the measurement came out at 0.480 s and 2.000 s, below the 4 s floor, so the scan had bought nothing either time. Sequential plans go straight to the target stride now, which costs nothing in cut geometry: cuts are keyframe-gated either way, so a stride finer than the GOP produces the same segments and only leaves index holes, which the append playlist already renders away.
The follow-up ties the release of a held playlist GET to the failure surface rather than to the two call sites this trace ran through. A sequential origin reaches two more: a muxerFailed exit revives through requestRestart, which a sequential origin refuses, and the #366 moov-prime revive ends on its own exhaustion cap. Either can fire before the first duration is published (an E-AC-3 archive whose first segment carries no audio packet is exactly that shape), and the held GET then still sat out its remaining 30 s on a session that had already surfaced failure. Every VOD source failure now surfaces through one method that releases the wait with it.
The gate also counts what the playlist can advertise rather than raw appended entries. A zero-duration entry is a plan index a long GOP skipped and gets no URI on EVENT, so counting entries could answer the held GET with a playlist that renders empty, which is the -12888 the gate exists to prevent. The producer's report ordering makes that unreachable today; with the cushion down to one entry there is no longer a second entry keeping it unreachable by accident.
Credits
#368, #369 and #370 were reported and fixed by @tschuegy, each with a device trace that pins the cause rather than the symptom. The rebase, the containment and the startup gate are their work; the follow-ups are noted per section above.
1845 swift-testing plus 509 XCTest green, four-platform CI clean.
6.25.1 - A pump that produced nothing says so
Drop-in from 6.25.0. One arm added on the loopback VOD path, no API change.
A pump that produced nothing now says so, whatever killed it
isFatalVODPumpExit decides that a non-live pump exit with nothing ever produced (no packets written, empty segment cache) is a dead source: the playlist exists, no segment will ever land, and AVPlayer parks in waitingToPlay forever unless it surfaces. That reasoning, from #126, is entirely about what the pump produced. The guard on it also required case .readError, which does not follow from it, and in the meantime nothing called the predicate at all: the read-error arm had grown its own inline copy of the same decision, leaving the tested predicate as dead code.
So a source that reaches EOF having written nothing fell through every arm on the way out. Measured on such a file: reason=eof packetsRead=322 packetsWritten=0 cacheCount=0, then 404 init.mp4 empty from the provider, and the host reporting state=playing phase=rebuffering for the whole session, with nothing in it to act on. It now reports state=error("Source produced no playable media (code -5)") in about a second.
The gate-starvation re-anchor (#169 round 3) reports whether it actually re-anchored, so a spent budget or a keyframe that maps to no plan segment falls through to that same surface instead of ending on a bare return. That is the third arm of this shape found in this file after 6.25.0, and the pattern is worth naming: an exhausted recovery that returns silently leaves a session no one can distinguish from a slow one.
What keeps this safe is the produced-nothing condition rather than the exit reason. An ordinary EOF after real playback carries packets and segments and is untouched (the control fixture still reaches state=ended), teardown is excluded, and every reason that owns its own recovery arm returns before this decision is reached.
How it was found
While checking whether #365 needed an AVC counterpart. It does not: matroskadec.c routes every video codec except HEVC through libavformat's parser, so an H.264 file whose config record and packets disagree cannot be demuxed at all, with or without the 6.25.0 framing repair, and there is nothing left for an engine-side fix to act on. That file is, however, exactly the shape above: demuxable enough to open, incapable of yielding a keyframed packet, and previously silent about it.
1827 swift-testing plus 504 XCTest green, tvOS build clean.
6.25.0 - A source is measured, not taken at its word
Drop-in from 6.24.0. Two black-screen classes on the loopback VOD path, no source-breaking API change. Both reported on 6.21.0 by @RomanLiberda, in #365 and #366, against full UHD-BD remuxes.
Neither turned out to be what the reports said, and in both cases the false lead was a reasonable one.
The framing of a source was decided from its description (#365)
A Dolby Vision Profile 7 MKV reached AVPlayer as -11855 Cannot Decode, with the video track reporting fourCC=<no fdesc>. The stated cause was that the generated hvcC carries no parameter-set arrays, the class fixed for DV P5 MP4 in 2.0.2.
The hvcC is fine. Three fixtures built from one source (proper record, Annex-B record, Annex-B record plus Annex-B blocks) produce a byte-identical, correct 116-byte hvcC in all three cases. And fourCC=<no fdesc> does not say what it looks like it says: the engine logs it from item.tracks on an item that has already failed, where the AC-3 track prints exactly the same thing.
What the report's own log does pin down is the source's real anomaly, in one line: extradata=726B head=00000140010c01ff. That config record is Annex B, not an hvcC, which is the shape a Matroska remux has when its CodecPrivate is Annex B or missing entirely (libavformat then synthesises Annex-B extradata from the first in-band parameter sets).
From there it is the mp4 muxer's own rule that does the damage. mov_write_packet_internal decides whether to convert samples by looking at the extradata, not at the packet: "extradata is Annex B, assume the bitstream is too and convert it". On a source whose packets are in fact length-prefixed, ff_hevc_annexb2mp4 then runs over MP4-framed samples and finds start codes only where a 4-byte NAL length happens to read as 00 00 01. Measured on a 1080p fixture: a 2,158,448 B segment came out at 61,912 B, the init.mp4 stayed byte-for-byte valid, and AVPlayer reached readyToPlay without ever producing a frame.
The engine now measures the framing on real packets at open (does a length-prefixed walk consume the packet exactly?) and converts the config record to an hvcC when the two disagree, so the muxer's own test comes out right. The head bytes are deliberately not the discriminator: every NAL of 256 to 511 bytes carries the length prefix 00 00 01 xx, and SEI or parameter-set NALs sit in that band routinely.
The measured framing is then handed to every NAL walker in the session, which repaired a second, quieter defect on the same sources. The DV P7 to 8.1 RPU rewrite assumed length prefixes, so on Annex-B packets it read 00 00 01 40 as a 320-byte NAL, found no RPU, reported success, and shipped the P7 RPU and the enhancement layer inside a container the muxer had already rewritten to 8.1: the mixed-profile hazard #135 warns about, reached silently. It now takes the framing and emits the packet in the framing it received.
The in-band parameter-set rebuild from #19 no longer runs on Annex-B extradata either. Bytes 21 and 22 of an Annex-B HEVC record pass its two checks by construction rather than by luck, because the 00 00 03 emulation-prevention pattern in a Main10 VPS sits exactly there, so byte 21 reads as naluLengthSize 4 and byte 22 as numOfArrays 0. canonicalizeHEVCConfigRecord has always had the configurationVersion guard; that path never did.
A search bounded in bytes, on a source measured in seconds (#366)
A second remux died with muxerFailed and a permanent black screen, because the selected audio track was a sparsely interleaved legacy dub whose first packet lies far past the start.
The first segment of an AC-3 / E-AC-3 source cannot be cut until one parsed audio packet has reached the muxer (#222), and the search for that packet read forward from wherever the pump stopped, bounded at 128 MiB. That bound is a byte bound, so what it buys shrinks as the bitrate grows: five minutes of a 3 Mbps encode, ten seconds of a 97 Mbps UHD one. No byte budget fixes a track whose first packet sits hundreds of MiB in.
It never had to be the first frame. AC-3 and E-AC-3 are one complete syncframe per packet, movenc builds the whole sample entry from whichever frame it gets, and the prime frame's timestamp is discarded anyway (the primed fragment is truncated and frag_discont re-armed). So when the forward scan comes back empty, the pump now seeks to four positions, midpoint first, and takes any frame the track yields there. Nothing in the container could have pointed at the track instead: on a fixture whose first audio packet sits at 211 MiB, that track's AVStream.start_time still reads 0.
On that fixture the forward scan reads its 128 MiB and finds nothing, the midpoint probe reads 32 MiB and finds nothing, and the 90 % probe captures a 418 B frame after two packets. The session then plays, the init.mp4 carries a real dac3, and the audio lands exactly where the source put it: segment 19 holds video 76.000 to 79.958 plus the source's first audio packet at 79.994, segment 20 holds audio 80.028 to 83.965. The prime taken from 84 s did not drag the timeline with it.
Falling back to a different audio track was considered and not built: with the prime found, the track the viewer selected is the one that plays.
An unmuxable source now says so
The reason #366 reached a viewer as a black screen rather than an error is separate from the search. The exhausted arm of the VOD muxer-failure revive was a bare return: no producer, no restart, no error, so the provider answered 404 init.mp4 empty forever while AVPlayer sat in waitingToPlay. Its sibling arm for read errors, 150 lines up the same file, has surfaced its own exhaustion since #169.
Measured on a source whose audio track carries no blocks at all: before, state=playing phase=rebuffering for the whole session; after, state=error("Source audio cannot be muxed (code -22)") inside a second.
The terminal failure now carries a reason as well as a code, which fixed three existing call sites in passing: the #358 unproducible segment and the sequential-origin reposition were both reporting a failed read for something that read fine.
The structural verdict is recorded on the session, so the three revive attempts do not each pay the full search again (about 256 MiB of reads per attempt) to reach the same answer. It is deliberately not recorded when a read threw or the pump was stopped: those say nothing about the track, and treating them as final would turn a transient I/O hiccup into a dead session.
API
VideoNALFraming is new and public (.annexB / .lengthPrefixed(size:)). DoviRpuConverter.convertPacketToProfile81 and enhancementLayerType take it as a defaulted parameter, so existing calls compile and behave unchanged; a caller that hands them Annex-B packets now has a way to say so. Nothing else changed, and hosts need no change.
Verifying it
No muxer will write the file shape behind #365: matroskaenc always converts the config record with ff_isom_write_hvcc and always reformats Annex-B packets to length prefixes. It has to be patched into a file that already exists, so the generator is in the repo:
ffmpeg -i src.mp4 -c copy base.mkv
python3 Scripts/mkv-annexb-fixture.py base.mkv annexb-record.mkv --annexb-codecprivate
python3 Scripts/mkv-annexb-fixture.py base.mkv annexb-both.mkv --annexb-codecprivate --annexb-blocksBoth edits are byte-count preserving (the config record is padded back to length with an EBML Void, and a 4-byte length prefix is the same width as a start code), so no parent element size has to be rewritten. The three files are each other's control: after the fix all three produce a byte-identical init.mp4 and a byte-identical 2,158,448 B seg0 through aetherctl serve, and all three reach rfd=y in a real AVPlayer.
The #366 fixture needs no patching, only an offset input:
ffmpeg -f lavfi -i "testsrc2=size=1920x1080:rate=24" -t 95 -c:v libx264 -b:v 22M late-video.mp4
ffmpeg -i late-video.mp4 -itsoffset 80 -f lavfi -i "sine=duration=14" \
-map 0:v -map 1:a -c:v copy -c:a ac3 late-audio.mkvIts first audio packet sits at byte 221,172,397. Cutting the same file with -t 60 -c copy leaves the AC-3 track declared with zero blocks, which is the source the terminal arm is measured against.
1824 swift-testing plus 504 XCTest green, tvOS build clean.
6.24.0 - A live playlist reaches the path that carries its headers
Drop-in from 6.23.1. One behaviour change on the live path, no source-breaking API change. Reported on 6.21.0 by @parthp1808 in #363, against tokenized IPTV streams that enforce an STB profile header per request.
What the measurement said before anything was written
The report's stated cause was that AVURLAsset drops LoadOptions.httpHeaders when it follows a cross-origin 302 to a CDN edge, so the origin answers 401 or 403. That is not what happens here.
A fixture origin that answers 403 to every request missing an exact header, and logs the verdict per request, was pointed at both shapes of the case: a 302 that changes host and port, and a master whose variants name a second origin absolutely. On the nativeRemoteHLS bypass the headers survived both. Every playlist, every variant and every segment request arrived with the header intact, on the reporter's own header (User-Agent) and on a custom one, and the sessions played through.
So the header carriage added in #119 is intact, and a 401 from an origin like that is worth reading as the origin's own decision: how many headers it wants (the reporter's log shows header count: 1, while portals commonly want a Referer or a Cookie alongside the profile), or a per-token connection cap that the bypass's parallel fetches fill.
What was genuinely missing is the routing around such a refusal. The engine holds HLSLiveIngestReader, the only live path that puts LoadOptions.httpHeaders on the playlist, on every segment and on every AES key, and both ways of reaching it required the host to wire it by hand.
A live playlist routes itself now
load(url: <m3u8>, options: LoadOptions(isLive: true)) without nativeRemoteHLS lands on the raw byte path, where AE#140 detects the #EXTM3U body in the position a container's first byte belongs and stops before the endless-feed reconnect loop. That detection stays; its destination changes. Instead of throwing an error that names the reader the host should have built, the engine builds it, with the session's headers on every fetch.
AetherEngineError.hlsPlaylistOnRawLivePath still exists and still throws for a custom IOReader carrying the same misroute: that source has no playlist URL for the engine to ingest from, so only the host can re-point it. A host that catches the error for a URL source will simply stop seeing it and get a playing session instead.
This mirrors what the non-live side of the same misroute has done since #154.
A refused mount is handed to a different client
A live nativeRemoteHLS session that the origin turns away no longer fails the load. HTTP 401 reaches the item as NSURLError -1013 and HTTP 403 as -1102, both with an empty HLS error log, and both arrive that way whether the refused request was the master playlist or the first segment, since neither ever reaches readyToPlay. The engine now hands such a session to the live ingest.
That is worth doing because the ingest fetcher is a different client at that origin: it carries the configured headers on every request, caps itself at four concurrent fetches, and sends no AVFoundation user agent, so a UA filter or a full per-token connection cap that turned AVPlayer away can still serve it. Measured against an origin that refuses AppleCoreMedia and serves everyone else, the bypass dies at the mount and the rerouted session plays through.
It is gated by LoadOptions.nativeRemoteHLSIngestFallback exactly like the #168 carriage recovery, fires once per session, and is deliberately not remembered for the next load the way a carriage verdict is: a carriage verdict is a property of the master, while a refusal can be an expired token or a cap that frees up a second later.
Verifying it
Origins that enforce a header could not be driven from the CLI at all, which is why this report could only be reasoned about. aetherctl now has both ends of the contract:
# portal on 8099 answers /entry.m3u8 with a 302 to the edge on 8100, both enforcing the header
aetherctl hlsfixture --segments-dir ./segs --master --codecs "avc1.4d401f,mp4a.40.2" \
--resolution 1280x720 --require-header "User-Agent: Mozilla/5.0 (QtEmbedded; TestSTB)" \
--redirect-entry --redirect-port 8100 --port 8099
aetherctl play --live --header "User-Agent: Mozilla/5.0 (QtEmbedded; TestSTB)" \
--seconds 60 http://127.0.0.1:8099/entry.m3u8play --header "Name: Value" is repeatable and fills LoadOptions.httpHeaders, including the ingest reader's own fetches. hlsfixture gained --require-header, --deny-status (401 and 403 are different codes at the item), --deny-segments-only (refuse after readiness rather than at the master), --deny-user-agent (the one origin shape that tells the two live clients apart), --redirect-entry / --redirect-host / --redirect-port, --media-origin, and --segments-dir.
That last one serves pre-cut, GOP-aligned segments instead of byte slices, so a live run can be asked whether it plays rather than only whether it routed: byte slices start mid-GOP, decode nothing and rebuffer forever, which is fine for a routing claim and worthless for a playback one.
Hosts need no change.