Releases: superuser404notfound/AetherEngine
Release list
6.50.0 - the local origin answers only its own session
One security fix in the engine and a dependency refresh that carries one upstream security fix. No API change.
Fixed
-
The local HLS origin served the playing stream to anyone on the same network.
HLSLocalServerbinds0.0.0.0so an AirPlay receiver can reach it over the LAN (#86). That also puts it in front of every other host on that network, and it had no access control: the endpoint names are fixed (/master.m3u8,/media.m3u8,/init.mp4,/segN.mp4), and the peer address was read only for the #227 diagnostic line, never to filter. The ephemeral port was therefore the only thing between a port scan on the same WiFi and whatever was playing.Every path now carries a 128-bit per-session token as its first component, and a request without it is refused before it reaches the router. Nothing else had to change: playlist URIs are relative, so segments, the init segment and the subtitle renditions resolve under the prefix on their own, and only the three entry-point accessors (
playlistURL,mediaPlaylistURL,reducedHDRMasterPlaylistURL) name it.AirPlayPlaylistDecision.receiverURLused to overwrite the whole path when it swapped inmedia.m3u8, which would have handed the receiver an address the server now refuses. It replaces the last component and keeps what precedes it.Stated plainly so this is not read as more than it was: no path traversal existed and none is added (subtitle paths parse as integers, segments are served from memory, no request maps to the file system), and no credential was ever reachable there. What was reachable was the stream itself, to anyone who found the port.
Changed
-
FFmpegBuild 2.4.3 to 2.5.0, for a libzvbi security update. libzvbi 0.2.45 fixes an out-of-bounds read, an out-of-bounds write and an integer underflow (GHSA-86rm-g7qf-j2fh, moderate, no CVE assigned). The
libzvbi_teletextdecoder is built and the teletext path is wired end to end (LoadOptions.teletextPagethroughEmbeddedSubtitleDecoder), so a DVB teletext stream reaches that code. Consumers doing live TV should take this release.The same FFmpegBuild release removes the
concatdemuxer. It is a script demuxer: a stream beginningffconcat version 1.0makes libavformat open the paths listed inside it through thefileprotocol. Nothing in this engine asks for it by name, so probing was the only way to select it. It also carries dav1d 1.5.4 and zimg 3.0.6, neither under an advisory. FFmpeg itself stays onn8.1.2. -
LibDovi 2.0.0 to 2.1.0 (
dolby_vision3.4.0). No advisory. The C header change is additive only: two new CMv4.0 metadata entry points, nothing removed.dovi_parse_unspec62_nalu,dovi_convert_rpu_with_modeanddovi_write_unspec62_nalu, the Profile 7 to 8.1 path, are untouched.
Both pins are .upToNextMinor, so neither dependency floats onto its new minor without this release.
Verification
Built and tested against the new binaries before tagging: 2159 tests in 304 suites pass, including a new suite that starts the server on a real socket and confirms the tokenised URL answers 200 while /media.m3u8, /init.mp4 and /seg0.mp4 answer 404.
dav1d 1.5.4 and dolby_vision 3.4.0 change decode paths that unit tests cannot exercise end to end. If you ship AV1 software decode or Dolby Vision, run your own pass on real content before shipping this.
Upgrading
No source change is required. Consumers pinning .upToNextMinor need to move their own pin.
Full notes: CHANGELOG.md · 6.49.0...6.50.0
6.49.0 - A resume is a rate, not a boolean
play() is rate 1.0 by definition. A transport that remembers only THAT it was playing comes back at someone else's speed, and a client cannot hold the difference from outside.
Fixed
A resume came back at 1.0 and discarded the playback speed, and no client could hold it from outside (AE#436). The native video host latched a boolean play intent rather than a rate, so every pause dropped the speed. Re-applying it from the outside did not work either, which the report measured as well as the defect:
resume → rate=1.0
we set rate=1.5
rate=1.0 ← 45 ms later, from a play() the client did not issue
we set rate=1.5
rate=1.0 ← final, and the phase never changed, so nothing re-asserted
The engine re-issues play() from paths no client can observe: the readyToPlay re-assert after an item swap, interruption and background resume, the #287 premature-end recovery. AVKit's transport and the remote command centre call it straight on the player, where the engine cannot see it either.
The rate a resume comes back at is AVPlayer.defaultRate, the platform's own "rate at which to start playback when play is called". Both AVPlayer-backed hosts record the speed there, so every one of those sites resumes at it and nobody writes rate inside a resume window at all.
Zero is now a pause rather than a speed at all four hosts. Remembered as one it became the rate the next resume, the software path's clock arming and a rebuffer recovery all came back at, which brought a session back frozen while it reported itself playing. Reachable through the documented setRate(0).
desiredRate joins desiredVolume as the engine's own memory: seeded into every host a load builds, re-clamped to that host's ceiling (2.0 video, 3.0 audio-only), so the rebuilds a session makes on its own keep the speed (reload at position, audio-track switch, AirPlay LAN swap, background return). It is seeded even when nothing was set, because both AVPlayer hosts are reused across loads and carry defaultRate on the player, so a new item would otherwise inherit the previous one's speed. The speed belongs to the item: a load of a different source, or stop(), returns to 1.0, which keeps a host whose own speed control resets per item in step.
New CLI drill, aetherctl play --host-calls ratehold: set 1.5, pause, resume with no client write, then read the rate back off the transport itself. Native and software both hold 1.5 across the pause and across an audio-track switch rebuild. Removing the defaultRate write reproduces the report (1.00 after the resume); removing the engine seed holds the resume and loses the rebuild.
A 3D Blu-ray MVC remux played black on the native path (AE#435). These files carry both eyes inside one H.264 track, which Matroska declares as StereoMode 13 / 14 (block_lr / block_rl, both eyes in one block) and libavformat reports as stream-level AV_STEREO3D_FRAMESEQUENCE:
TAG:stereo_mode=block_lr
[h264 @ ...] sps_id 1 out of range
The dependent view's slices reference a subset SPS the base decoder never receives, so a plain H.264 decoder can only skip them. libavcodec does exactly that and decodes the base view, which is the left eye and the 2D fallback every non-3D player shows. VideoToolbox is handed whole samples with both views' NALs inside and renders nothing, so the session played its audio over a black screen. Nothing in the engine had ever read the stereo declaration, so these files took the native path on the strength of being progressive H.264.
Routing now reads that declaration at load, before a packet is decoded: H.264 declaring either both-eyes-in-one-block carriage takes the software path, the one decoder that produces a picture from it. Same shape as the rules already there for interlaced H.264 and High 4:2:2, where the format looks native on paper and comes out wrong in practice.
The frame-packed modes (side by side, top / bottom, checkerboard, row or column interleaved, anaglyph) are single self-contained pictures and keep the native path with hardware decode; cropping an eye out of one of those stays the host's call. MV-HEVC keeps the native path too, being Apple's own format with a base layer AVPlayer plays. Real MVC 3D output is offered on no path. The dispatch logs the decision, so a session that took this route says so.
setRate documented the software path as playing speed without pitch correction, and it never did (AE#434). Both transport surfaces were running AVFoundation's TimeDomain algorithm, the default an app linked on or after iOS 15 / macOS 12 gets, and the engine set audioTimePitchAlgorithm nowhere, so the sentence read perfectly while describing nothing in the build. The report measured it the other way round on a VP9 / Opus MKV routed to software decode, with the route confirmed in the log: 1.25, 1.5 and 1.75, no pitch shift.
A wrong capability claim costs more than a missing one. This one came within a step of a per-peer capability bit in a group-playback protocol, recomputed per title because routing depends on codec, resolution, frame rate and hardware, to avoid offering speed to whoever landed in software.
audioTimePitchAlgorithm is now pinned to TimeDomain through one AudioRatePolicy at all four hosts. Pitch behaviour is unchanged on any current build; the guarantee stops depending on the host app's link age (the default has moved once already and differs per platform below those versions) and is identical on every route. setRate's docstring and the docs/api.md row now say so, with a test holding all three statements to the configured value.
Notes for adopters
- No API changes.
setRate(_:)keeps its signature and its clamp; what changed is that the speed now outlives a pause, a resume and an in-session rebuild, and that it resets when a different source is loaded. - A host that re-applied its own rate after every resume can drop that code. Writing rate inside AVPlayer's resume window is a poor place for a client to be, which is the other thing AE#436 measured.
Thanks to @rrgomes (AE#434, AE#436) and @TheyCallMeSpy (AE#435). Both reports arrived with the measurement that decided the diagnosis.
Full notes: CHANGELOG.md - 6.48.0...6.49.0
6.48.0 - The phase axis belongs to the reader that is serving
A dedupe gate answers a question about what a LISTENER has heard. Give it a listener that arrived late, and it answers about a conversation that never reached anyone.
Fixed
A producer restart that replaced the reader left playbackPhase on .stalled(reconnecting:) for the rest of the session (AE#433). The axis a host reads for "the source is delivering" lives per session. The gate that feeds it lives per reader instance. The handover between two readers is where the two came apart.
The restart opens its replacement demuxer first and wires the phase sink one step later:
try fresh.open(...) // find_stream_info reads megabytes: the gate latches .flowing into a nil sink
...
demuxer = freshDemuxer
freshDemuxer.onNetworkPhaseChanged = onNetworkPhaseChanged // the listener arrives after the fact
By the time the sink exists, the gate believes it has already reported delivery, so the reader that is now serving the session says nothing for the rest of it and the phase keeps describing the reader that was just aborted. Reported from the field as 454.8 s of .stalled(reconnecting: true), 298 s of it over normally playing video, with a "Reconnecting" indicator up the whole time.
This is why the .flowing-on-first-statement rule from 6.41.0 did not cover it: that made a fresh gate start with no opinion, and this reader forms its opinion before anyone is listening. A recovery that does NOT restart the producer clears in seconds, because the surviving reader's own reconnect cycle flaps the gate.
Two adjacent holes came out of the same reading. The replaced demuxer kept its sink, so an aborted pump outliving the swap could still move the axis for a session it no longer feeds. And the live reopen never wired the sink onto its fresh demuxer at all, which left that path unable to recover the axis for the rest of the session.
The gate now deduplicates for a listener rather than for a reader instance: attaching a sink clears its history. Sink and gate moved under one leaf lock, since the handover thread installs while the demux thread emits. At the swap the outgoing demuxer is unwired and the incoming one takes the sink, on both the restart and the live-reopen paths. The fresh reader therefore publishes a non-stalled phase off its own first measured delivery, rather than anything asserting health at the moment of the swap.
setReaderNetworkPhase logs its transitions now:
[AetherEngine] source network axis flowing -> reconnecting
[AetherEngine] source network axis reconnecting -> flowing
The axis moves a handful of times per session and was named nowhere in the log, which is what forced the report to reconstruct the state from reader generation counters.
Measured against an origin that stops delivering on established sockets without closing them, so the reader parks in a blocking read and the recovery takes the wedged-producer restart (old producer wedged in a read past 5s; aborted it and reopened a fresh demuxer). Three runs per arm, identical every time:
| arm | ticks reading stalled(reconnecting: true) |
playing ticks after the swap |
|---|---|---|
| before | 24, to the end of the asset | 0 |
| after | 3, the outage itself | 21 to 22 |
In the before arm the clock advances at 1.0x with zero dropped frames through 21 of those seconds.
A live source that stopped carrying timestamps wedged the segment cutter (AE#432). A live MPEG-TS whose video PES headers stopped carrying PTS/DTS put 1792 packets and 30 keyframes into one 85 MB segment advertised as 0.5 s, and produced nothing after it.
The repair for a packet arriving with neither dts nor pts was lastValidDts + 1. One tick satisfies the muxer's monotonic invariant and nothing else: on the 90 kHz MPEG-TS axis it claims 11 microseconds of presentation time for a 20 ms frame. The live cutter's clock IS that timestamp, since it opens a segment at a keyframe's pts and cuts at the next keyframe past the cut target. A run of timestamp-less packets froze that clock, so none of the keyframes in the window could cut.
A packet with no timestamp of its own now advances by a plausible frame interval instead: the demuxer's own duration for the packet, else the last genuine inter-packet delta this stream showed, else the frame duration the producer already carries, else the historical single tick for a stream that has never carried a usable timestamp at all. The learned delta comes from genuine timestamps only, never fed back from a repaired one, and never from a delta past a second, which is a program boundary rather than a cadence. The dts-only case (matroska reconstructing dts from ReferenceBlock relations) keeps its minimal bump: pts is real there and must not be crossed.
Two silent things became readable. The repair states once per pump that the source stopped carrying timestamps and what was put in their place, and the no-cut stall line reports how many packets in its window carried a synthesized timestamp, so videoPtsAdvance reads as a statement about the source rather than about the engine's own repair.
Measured on a 50 fps HEVC plus mp2 MPEG-TS whose PES headers lose their timestamps at t=20 s, with the timestamp bytes turned into legal PES stuffing so every byte offset is unchanged, served at 2.6x real time over loopback:
| arm | segments finalized | no-cut stall | playhead at 50 s |
|---|---|---|---|
| 6.47.0 | 5, last at 19.2 s | cutter wedge, retune | 21.3 s, dead |
| 6.48.0 | 12, through 52.8 s | none | 39.6 s, playing |
A suspected regression in the FLAC audio bridge was ruled out by measurement rather than by argument: 6.31.0 with the EAC3 bridge wedges packet-for-packet identically on the same fixture.
Compatibility
Public API is unchanged. PlaybackPhase and playbackPhase keep their shape and their meaning; what changed is which reader gets to speak for them. Consumers that clear their own reconnect state only while the phase is neither stalled nor seeking need no change.
14 new tests (3 for the handover, 11 for the synthesized stride), suite 2139 green.
Full CHANGELOG: https://github.com/superuser404notfound/AetherEngine/blob/main/CHANGELOG.md
Diff: 6.47.0...6.48.0
6.47.0 - A log line is read by someone else
A diagnostic line exists to be handed to a person who is not the one who wrote it. That is what makes an unredacted one a delivery mechanism.
Fixed
Every emitted log line carried its URL's credentials into OSLog and into the host handler. The engine logs whole URLs on purpose: host, path and query are what a playback report is diagnosed from. Media servers routinely put the access token in that same query (Jellyfin's api_key=), so [AetherEngine] load url=, [NativeAVPlayerHost] load url= and asset.url= held a live credential.
Emission uses .public privacy, which is right for a diagnostic and wrong for a secret: a Console.app capture or a sysdiagnose showed the token in clear text, and the host handler passed it to whatever in-app log a consumer built on it. Three sinks, from one line.
This belongs in the engine rather than in each consumer. The engine composes the line, two of those three sinks are ones no consumer controls, and a host-side scrub only ever covers the one sink that host owns. The case that surfaced it: Sodalite enabled its diagnostic log for App Store users precisely so reporters can hand logs over after release, which is the exact situation where an unredacted line ships a working token to a public issue tracker.
Both emit overloads now funnel through one path that strips the value of api_key, apikey, access_token, token, secret, password, signature, x-emby-token, x-mediabrowser-token and connect.sid, in the query form (api_key=abc&next=1), both header forms (Token="abc", X-Emby-Token: abc) and the cookie form (connect.sid=abc; Path=/). Names are generic rather than tied to one server product. Each value goes whole rather than truncated to a prefix, since a prefix still narrows a brute-force and answers no question a playback report asks.
Everything else about the URL survives, which is the part the report is diagnosed from:
[AetherEngine] load url=https://host/Videos/abc/stream.mkv?api_key=<redacted>&Static=true&MediaSourceId=abc
Redaction sits at the funnel, never at the call sites, so a URL logged by code added later is covered without its author knowing the redactor exists. The boundary rule keeps ordinary diagnostics intact: hasToken=true, [SWDiag] enq=48 layerDrop=0 and the loopback serving URLs are untouched.
Public API is unchanged and the redactor is internal, so no docs/api.md entry and no migration. .verbose lines are unaffected in reach, they still go to OSLog .debug and never to the handler.
Note on cost
The redactor works on UTF-8 bytes and allocates its output only once something matches, so a line with no credential is returned unchanged and uncopied. That is not premature. A first version compared Characters and built a lowercased String per position, which cost enough to shift request timing in ServedFromMemoryProgressTests: emit is called from the demuxer and the segment producer, so anything per-line here is per-line everywhere. That test is what caught it.
11 tests cover the forms, the boundary rule, and that the host handler never receives the raw token.
Full CHANGELOG: https://github.com/superuser404notfound/AetherEngine/blob/main/CHANGELOG.md
Diff: 6.46.0...6.47.0
6.46.0 - A fixed reach back is not a search
Split out of AE#408 while measuring its fix at the serving layer, and the split issue's own cost model turned out to be wrong.
Fixed
A cold seek into a keyframe drought landed past its target and silently skipped content (AE#412). Audio routes packets into segments by plan boundary while video routes them keyframe-gated (#92), so a boundary no random-access point reaches is stepped over by the video cutter and opened by the audio anyway. Every segment cut inside the drought then starts mid-GOP, and the point that covers the drought stays behind in the segment before it.
That only shows on a COLD arrival, and it shows because AVPlayer's reach back below a seek target is a fixed span, not a search. Measured with aetherctl play --picture-probe on a 4 s grid, the first fetch of a cold seek was the segment holding target - 8 s in one run shape and target - 6 s in another, and the same reach happens on a control fixture where every segment is independent. So a drought wider than that reach leaves the earliest decodable frame ABOVE the target.
Both arms below are the same 120 s source with the same 12 s drought at 43 s, produced sequentially from 0 so the session runs on a zero axis, seeked back from 88 s with the drought already cached and no producer restart. They differ only in where the cut falls: the control's plan boundary sits on the source's real sync sample, so its drought is ONE segment beginning at its covering point.
| target | before | after | control |
|---|---|---|---|
| 46.0 s | 46.00 | 46.00 | 46.00 |
| 50.0 s | 55.00 | 50.00 | 50.00 |
| 54.0 s | 54.96 | 54.00 | 54.00 |
capErr stayed small throughout, so the clock was never lying: it described the wrong position correctly.
The producer now records, per adopted segment, where its first random-access point sits, as an offset from the segment's advertised start so the claim carries no axis with it. A cold seek asks that of the segment it lands in and of the ones within reach below it, and re-cuts the landing segment from its covering point only when nothing down there can open a decode run at the target. In the runs above the re-cut fires on 50.0 and 54.0 only, never on the control, and never on 46.0, which AVPlayer's reach already covered.
A boolean "is this segment independent" would not have been enough in either direction: a segment can start mid-GOP and still serve a target above its first sync sample, and it can carry a sync sample and still not serve a target below it.
The seek target is deliberately not moved to compensate for the re-cut. Doing so, which is what the AE#418 placement model predicts, landed exactly that far past the target (capErr +0.975, +5.075 and +9.117 at offsets of 1, 5 and 9 s). Measured 3 of 3, AVPlayer puts the re-cut segment at its own tfdt inside the timeline it is already building (axisErr 0.000). Placement by playlist reproduces on the FIRST segment of a timeline, where a resume on the same fixture reads axisErr -1.000, -5.000 and -9.000. For the same reason a re-cut epoch records no axis offset.
Unchanged for a sequential arrival, for live (whose playlist only ever offers what was finalized), for a segment the producer made no claim for, and for a landing a random-access point already covers. A re-cut whose gate does not open within 2 s falls back to the previous behaviour rather than delaying the seek further.
Diagnostics
[HLSSegmentProducer] #412 seg-N carries no random-access point (advertised …s) marks a segment audio opened on a folded boundary. [HLSVideoEngine] #412 seek to item …s lands in segN …; re-cutting from the covering one and #412 segN re-cut opened …s below its boundary and now covers item …s bracket the repair.
API
No public API change.
Full changelog: CHANGELOG.md · 6.45.0...6.46.0
6.45.0 - An axis offset composes
Follow-up to 6.43.0's AE#418 fix. The offset was right and its lifetime was wrong.
Fixed
An axis offset composes, and 6.43.0 treated it as something a decode run owns (AE#418). 6.43.0 published the offset a re-aimed gate puts into AVPlayer's timeline, and ended it when AVPlayer began a fresh decode run, reading that from the fetch order: any request that did not follow its predecessor. rrgomes' forward-seek burst falsified it. AVPlayer asks for a segment below its target on a seek, and it asks out of order while continuing the run it is already playing, so the axis was republished from under a picture that had not moved and the captions ran 14 s ahead again, the pre-fix shape re-entered through the burst.
Measured with aetherctl play --picture-probe on a fixture whose picture states its own source time, at re-aims of 0.5, 0.875, 1, 3, 5, 7, 9 and 11 s, what the axis turns on is placement, and it composes. AVPlayer puts a segment at its advertised start read through the mapping its timeline already carries, so re-placing an overlong segment adds its offset again:
| case | true axis | 6.43.0 published |
|---|---|---|
| resume whose gate opened 9 s below its boundary | -9.000 |
-9.000 |
| ... then a seek onto an axis-true segment | -9.000 |
0.000 |
| ... then a seek that re-places that same segment | -18.000 |
0.000 |
| ... then a seek whose restart re-aims 5 s more | -14.000 |
-5.000 |
So the axis now moves by exactly what a placed segment carries below its advertised start, and the seam sits at that advertised start read through the axis in effect before it landed. The single anchor pair becomes a record keyed by index, because several epochs can leave an overlong segment in the cache at once; a new epoch drops the entries at and above its own index, which it rewrites axis-true. A gate no longer publishes on its own: it records what its segment is worth and the placement publishes it, so an epoch AVPlayer never fetches from cannot move the clock.
One exception, also measured: AVPlayer discards a sub-second axis at a seek and snaps back to the playlist. -0.500 and -0.875 read axisErr=0.000 after one, -1.000 and everything above it survive unchanged. The VOD seek path publishes that snap from the landing forward.
Thirteen arms of the fixture matrix, including the four that read capErr=-8.983 before, now read +0.017, which is one frame at 24 fps. The control fixture (Cues that ARE its sync samples) is untouched.
Diagnostics
[HLSVideoEngine] #418 segN placed (advertised …s, worth …s): axis shift …s -> …s from item …s replaces the decode run re-anchored line, and names the composition rather than a re-anchor. [HLSVideoEngine] #418 axis …s discarded at the seek landing …s marks the sub-second snap.
API
No public API change.
Full changelog: CHANGELOG.md · 6.44.0...6.45.0
6.44.0 - A constant rate is not always a whole number of ticks
Drop-in from 6.43.0. No API change. One behaviour change: the #409 composition-offset repair now also covers sources whose frame duration is not a whole number of ticks, which is the class its own reporter's asset turned out to belong to.
A constant rate that no step describes
6.41.0 repaired MP4s that dropped the composition-offset table while the H.264 bitstream still reorders pictures. @orut34iop retested it on the original reporting asset and it still juddered from the first frame, with the measurement to say why: the file's sample table alternates between 40040 and 40041 ticks in a five-picture cycle, and the classifier demanded one identical step. It read that as variable frame timing and fell closed, so nothing was repaired.
The diagnosis is right, and it points at an assumption worth naming: a constant frame rate does not always produce a ladder that advances by one constant. At a 1200000 timescale that asset's pictures are 200202/5 ticks apart. No integer describes it, so the sample table has no choice but to alternate between the two neighbouring counts, and a repair built on one step has nothing to stand on.
Reproduced here before anything was changed, on a twin pair built to that shape (one encode at 1000000/33367 fps in a 1200000 timescale, muxed twice, the composition offsets stream-copied away from the second): the shipped implementation passes all 33 packets through with PTS == DTS, every picture still in decode order.
A two-valued ladder is now read as the quantization it is:
- The cycle it repeats names the fraction. The smallest period the sampled deltas repeat under gives the cadence exactly, and a cycle only counts once it has been seen through twice, so a short run of jitter cannot pose as one.
- The pattern it rounds to names the phase of the lattice it was quantized from. This is the part a uniform ladder cannot carry, and it is not implied by anything else: the reporting asset sits on phase 2 while its reorder delay is 1. Exactly one phase of the period may reproduce the sampled window picture for picture; if more than one does, or none, nothing is repaired.
- Ranks are then placed on that lattice instead of on
anchor + rank * step, so the repair reproduces what the muxer would have written rather than landing a tick beside it. A whole-tick ladder takes the path it always took.
Reading the phase has a second effect. Measured on the same file, a session that sampled after a seek instead of at the head classified it as unrepairable, because the ladder starts on a different phase there. That verdict is now independent of where the sample was taken, which matters for any consumer that opens at a resume position.
Three things deliberately did not change:
- How far the ladder runs ahead of presentation still comes from the container header. Every alignment fits the ladder equally well, so the ladder cannot answer that question;
presentationShiftreads it fromstart_timeagainst the first index entry and clamps it to one reorder head, now resolved to a whole number of pictures. - The container index is still folded by one constant, the same one the packets move by, so an index entry cannot disagree with the packet it points at and none can go missing from a segment plan.
- A sequence the lattice cannot place falls back to the rounded step, anchored on the container's own timestamp, so it cannot drift and nothing is ever handed on in decode order because an arithmetic path did not close.
Verified against the fractional twin pair (33 packets, three coded video sequences, both writer shapes, from the head and after a seek): every repaired packet carries the healthy twin's PTS and DTS exactly. The whole-tick pair from the first round still matches exactly, unchanged. Genuine variable frame timing, a ladder with a dropped picture, and a wobble that never repeats are all still left as the container delivered them.
The verdict line names what it measured, so a report can be read off the log:
#409 missing H.264 composition offsets confirmed on stream 0: repair step=40040 lead=80081
shift=80081 pocStep=2 cadence=200202/5 phase=3 ladderAhead=2 samples=12
Reported, retested and diagnosed by @orut34iop.
Commits: 69daa3d3 (repair), 14a079ce (verdict line). Full CHANGELOG, diff 6.43.0...6.44.0.
6.43.0 - A clock has to describe the picture it is over
Drop-in from 6.42.0. No API change. Four behaviour changes, all on the native loopback-HLS path, and the first of them repairs something 6.40.0 introduced.
A clock has to describe the picture it is over
@rrgomes reported captions running about four seconds ahead of the dialogue after a resume, further out after later seeks, on an asset whose Matroska Cues sit on non-random-access points. Lip sync was fine, because audio and video ride in the same segment; the clock every consumer reads was not.
6.40.0 gave the producer a gate that re-aims BELOW a plan boundary that turns out to carry no sync sample, so the segment covers its own advertised start. It shipped with the assumption that a segment keeping its own timestamps leaves the item axis where the plan puts it, and therefore published no shift for that case. The assumption was not testable at the time: every axis observable in the engine describes what it WROTE (MuxedVideoFrameTime, prodShift / hostShift), and none of them says where AVPlayer then PUT it.
aetherctl play --picture-probe now reads that out. It attaches an AVPlayerItemVideoOutput to the running item and decodes the source time from the frame itself, against a fixture whose picture states its own frame number in binary (Scripts/timecode-fixture.sh). The reading:
AVPlayer presents a segment at the position the playlist gives it, not at the tfdt the segment carries, and then plays continuously from there.
So a gate that opened below its boundary shifts the whole run by the re-aim. Two consequences, and the fix needs both. The offset a consumer folds is measured against the segment's ADVERTISED start, never against where the gate actually opened; a pinned (late) gate makes those identical, which is why publishing the muxer's shift held until a gate that opens early existed. And that offset belongs to the decode RUN rather than to the timeline: a seek that leaves the loaded region without provoking a restart begins a fresh run on an axis-true segment, where the previous offset must stop applying. Publishing only the first half mirrors the defect instead of fixing it.
Measured on a fixture carrying the reporting shape (Cues at non-sync positions, 12 s drought at 43 s), resuming at 53 s so the gate re-aims to 38.417, source time decoded from the picture:
| arm | published shift | picture vs item axis | picture vs sourceTime |
|---|---|---|---|
| 6.39.0 (pinned, late gate) | +3.000 | +3.000 | +0.075 |
| 6.40.0 to 6.42.0 | 0.000 | -13.583 | -13.550 |
| 6.43.0 | -13.583 | -13.583 | -0.009 |
and +0.892 to +0.017 for a seek that leaves the loaded region. The control fixture, identical except that its Cues ARE its sync samples, is untouched: no re-aim, no seam, axisErr 0 on every tick. The muxer's own shift is unchanged, so no landing moves.
One thing to expect rather than to report: after a resume or seek into a drought the clock now reads LOWER than the target, by the re-aim. That is not a new landing error. The picture was already there; it is being described honestly for the first time.
A re-aim must not step over the sample that covers its boundary
Found while measuring the above, and only visible once the clock stopped hiding it. Each re-aim attempt opens on the first sync sample at or above where it aimed, and everything above the previous aim is already proven empty, so the DISTANCE between two attempts is the worst case by which the gate can overshoot the last covering sample. The backoff doubled, which spends that error where it is largest.
| aims | gate opens | published shift | |
|---|---|---|---|
| before | 48.0, 44.0, 36.0 | 38.417 | -13.583 |
| after | 48.0, 44.0, 40.0 | 43.000 | -9.000 |
Three attempts either way; the 8 to 16 jump flew over the 43.0 between it and the boundary at 52.0. seektest settles the same improvement from the seek side: settleError 3.80 s against 8.38 s, same burst and same throttle. Even steps cost no more to walk, because gateProvenEmptyFromPts stops each scan at the previous aim rather than at the boundary. Reach is unchanged at 32 s.
A wedge on a stored segment is the consumer's
Also from @rrgomes: landings of 11.94 s (Apple TV) and 15.75 s (Mac) after a restart, spent as 5 s of park detection plus 6 s of re-engage grace, while the producer had already served its first segment about two seconds in.
The wedge is an AVPlayer state (#65 / #93: zero GETs while the item never fails) and does not reproduce on the loopback path. What both logs settle is that the first repair could not have worked: the pump had marched to segment 15 and was sent back to segment 3, the consumer fetched nothing for the entire grace window, and the nudge that followed landed the seek in 240 ms.
A producer re-anchor is the repair for a consumer STARVED of content nobody is producing. A consumer silent about a segment that is already on disk is not starved, and re-anchoring throws the pump's forward work away to rebuild what it already has. The repair is now chosen on that question, with the other kept as the fallback inside the same grace window. WEDGE BROKEN carries consumerTargetStored= and highStored= so a report can say which repair a wedge called for. The 5 s park detection is deliberately unchanged.
Recovery paths must not read AVPlayer on the main actor
@rrgomes measured AVPlayerItem.currentTime() from a host's main actor not returning for 13.3 s during that wedge, with the app blocked throughout; it came back 30 ms after the re-engage watchdog fired.
These getters are synchronous XPC round trips to mediaserverd. AVFoundationOffMain has carried that warning since #134, including what it costs past the watchdog threshold, but only the 30 s memory probe had adopted it. The paths that run precisely BECAUSE the server is not answering had not: the seek-deadline loop took four round trips per pass (one island, three bufferedEnd), the stall nudge and item reload read currentTime() twice per call, the VOD shift-publish line read a buffer figure for a log, and the #287 premature-end recovery read three witnesses separately. All four now read off-main or from the rendered-position mirror.
For the recovery anchors the mirror is also the correct VALUE rather than merely the cheap one: recoveryAnchorPosition(currentRendered:) exists to keep the anchor off a frame the viewer has already passed (#115), and currentTime() is the clock, which diverges from the rendered frame during exactly the landing those paths run in (#123). The wedge path was already passing the mirror. Reads inside load() and the seek completion stay synchronous: both run where AVPlayer has just answered.
Harness
play --picture-probe and Scripts/timecode-fixture.sh are the instrument the first item needed: what SOURCE frame is AVPlayer presenting at a given item time. Per tick it prints pic, picItem, axisErr and capErr. docs/cli.md has the usage.
2082 tests green, including the fixture-backed restart-continuity suite.
Full diff: 6.42.0...6.43.0
6.42.0 - A landing line has to stand on ground somebody read
Drop-in from 6.41.0. No API change. One behaviour change: a PGS set can no longer claim a seek landing over source nobody read.
A landing line has to stand on ground somebody read
@rrgomes reported a caption that survives a re-aimed restart: on the 6.40.0 path, a coalesced far seek lands on target and the overlay comes up carrying a set from half a minute earlier, closed at the far side of the authored silence instead of at its own clear. Two devices, same asset, same day: an Apple TV 4K and an M4 Pro under a controlled repro (drain target 199.1, boundary re-aimed twice, gate open at 191.983, the 187.729 set standing over the scene at 199.4).
A PGS display set has no end of its own, so the reconstruction pass at a landing decides which set is still on screen by the store holding nothing between that set and the playhead. Over a stretch nobody read, that silence is not evidence. A run re-aimed just after it harvested a set leaves the set's own clear inside the ground the re-aim skipped: it decodes at the landing looking unclosed, becomes the landing's active line, and alignCueEnds then closes it at the next stored packet, which is the far side of the silence rather than its successor.
The report's premise about the store is worth correcting because it changes the fix: the packet store does not prune behind the producer. Since #125 its retention is byte-bounded only (32 MiB per stream, evict-oldest); the time-based prune was removed precisely because a backward seek into cache-resident content could land on packets it had evicted. The clear was not dropped, it was never read.
#362 round 2 measured that the packets alone cannot show this. A reader restarted BEHIND leaves a descending harvest sequence at the boundary, which is what harvestGapCut reads; one re-anchored FORWARD hangs its packets in ascending order behind the stretch it skipped, so the pair is indistinguishable from an authored silence. The coverage ledger was named there as the precise signal and left unbuilt for want of a reproducible defect. This is that defect, so it is built now.
SubtitleHarvestCoverage keeps one span per harvest run inside the packet store. The forward prefetcher reports its anchor, every in-place re-anchor and its read position as it goes; the pump's run begins where the producer opens or restarts (native and software paths) and reaches at least the playhead, since playback is rendering there, plus every packet it harvests. A set whose ground up to the playhead is not covered can no longer be the landing's active line, and the same rule closes the #100 stale hold's door onto the identical claim. A store nobody reports coverage to answers every span with yes, so a harvest path without notes behaves exactly as it did before.
The cost is the landing line in the case where a set really is still up and the proof is missing, which takes an authored dwell long enough to span the whole unread stretch. The alternative was paying it for every normally authored set that ends inside one.
Neither of the two shapes the report offered was taken. A PGS dwell cap is an end nobody authored, and it would have to be short enough to cut a two-second caption off before the landing while cutting a long sign short in the same file; three rounds of #362 went into removing laundered ends rather than adding one. A one-shot backfill of the skipped ground is the better of the two and the ledger makes it possible, but it spends a read on a link that has just paid for a seek, and it buys nothing the refusal does not in the reported case: the authored state at the landing is an empty screen either way.
Measured
500 s H.264 + PGS fixture with a scheduled sup (set at 184, authored clear at 190, then silence to 240), served over a loopback origin, seeking from 100 to 197 so the initial run's park edge falls between the set and its clear while the restarted pump opens at 192, above it. Counted over 10 runs per arm:
| arm | landing tick met the hole | stale CUE 184.00-240.00 |
landingWithheld= |
authored set at 240 |
|---|---|---|---|---|
| 6.41.0 | 4/10 | 4/10 (every one of them) | 0/10 | 10/10 |
| 6.42.0 | 6/10 | 0/10 | 8/10 | 10/10 |
Whether the landing tick meets the hole is a race with the re-anchored side reader, which
repositions to target - 2 on the subtitle axis and so re-reads the clear it lands on; that is the
correction the report saw arriving after about seven seconds on the Mac and after tens of seconds on
the Apple TVs. The rows above are the same race on both arms. The refusal count is higher than the
hole count because the ground between the set and the playhead is unread in every run, whether or
not the clear has arrived by the time the tick decodes it; where it has, the outcome was already
correct and the refusal changes nothing.
Controls, unchanged in both arms: a far seek into fresh ground landing inside an authored dwell still publishes its line (CUE 292.00-295.50), and a plain playthrough delivers the same cues with no withholding.
A refusal names itself as landingWithheld=N on the #357 subtitle-delivery line, beside endsWithheld=.
2055 tests green (16 new), tvOS, iOS and visionOS Simulator builds green.
6.41.0 - The bitstream knows what the container dropped, and a stalled source outranks a seek
Drop-in from 6.40.0. No API change, one behaviour change worth reading: playbackPhase now ranks a stalled source above an in-flight seek.
A container that lost the presentation axis
@orut34iop reported an MP4 that judders from the first picture, with no seek needed to provoke it. The writer omitted the composition-offset table while the H.264 bitstream still reorders pictures, so every sample reports PTS == DTS, the container hands decode order out as presentation order, and the native stream-copy carries it into fMP4 unchanged.
Measured through AVFoundation's own decoder on a twin pair (one encode, muxed twice, ctts removed from one), matching decoded content against the healthy twin: 45 of 66 pictures presented at a time belonging to a different picture, and the content order stepped backwards 30 times, in a repeating +67 ms / -100 ms shuffle per B-group.
The reporter's proposed remedy (read best_effort_timestamp and route the file to software decode) was measured and does not hold: best_effort_timestamp is not the presentation axis, it is pkt_dts of the packet just fed in, which is the decode ladder displaced by the reorder delay. On the same pair that is +2002 ticks (two frames) on 63 of 66 pictures and a backwards step at the EOF flush. It trades judder for a permanent lip-sync error, and it would have cost hardware decode.
The container lost the information; the bitstream did not. libavcodec's H.264 parser reads each access unit's picture order count without decoding a pixel and takes MP4's length-prefixed payload directly, so the demuxer rewrites the packet to what the muxer should have written: presentation by display rank, decode pulled back by the reorder delay, which is also what keeps PTS >= DTS. The shift between the sampled ladder and the presentation timeline is read from the container (start_time against the first index entry) and clamped to one reorder delay, so both edit-list shapes land on the same axis and a malformed header cannot drag the picture off its audio.
Repairing at the demuxer boundary keeps one axis for every consumer (fMP4 producer, segment plan, software decoder, still extractor) and keeps hardware decode: a container defect no longer costs the native path.
Verified: 432 packets across three fixture pairs (both edit-list shapes, seven IDR boundaries) match their healthy twin exactly, the served output presents all 301 frames at the same times as the healthy twin's, segments stay independently decodable, and a 13-seek burst lands identically on both arms.
Detection is fail-closed and nearly free on healthy files: the first real PTS-DTS offset ends the sample. A source is repaired only when every sampled pair is equal, the decode ladder is uniform, the picture order regresses, and the ranks it produces are distinct and fill the sampled window. Live, still-extraction and video-discarded demuxers never sample at all.
A dead source is not "seeking"
@rrgomes reported that PlaybackPhase folds isSeeking ahead of the reader's reconnect axis, so a source outage disappears from the phase for as long as any seek is alive.
Over a source that stopped delivering, a seek cannot land, so that is the entire outage. From the report, an origin killed mid-playback and restored later:
03:41:57.861 playbackPhase → .stalled(reconnecting: true) 1.9 s after the cut
03:43:10.298 seek#1 nativeScrub began target=140.93 producer restart, not a host seek
03:43:10.299 playbackPhase → .seeking
03:43:31.789 [AVIOReader] pump reconnect exhausted at offset 0 status=0 (5 unproductive)
03:43:31.789 [HLSVideoEngine] restart at idx=35: old producer wedged; reopen failed
03:43:39.683 [AVIOReader] pump gen=42 first data after 37ms origin back
29 s of a dead origin, two exhausted reconnect ladders and a failed reopen, all reported as .seeking, and the seek doing it was the engine's own: the producer's restart coalescer issues nativeScrub seeks while recovering, so the engine hid the outage it was recovering from with no host seek involved at all. A second run with host seeks on top reached 41 s.
Precedence is now:
error > ended > idle > loading > stalled > seeking > rebuffering > playing/paused
A seek remains fully observable through isSeeking and seekEvents, which carries the outcome a level signal structurally cannot (6.x, #38); the reader axis is observable nowhere else, which is what playbackPhase exists for. Over a delivering source nothing changes: the reader is flowing, a seek reads .seeking exactly as before, and a seek that lands from cache over a reconnecting reader still clears itself in milliseconds.
.stalled(reconnecting: false) now has a meaning. The value was reserved for "stalled, retries paused" and is emitted when a reconnect ladder is spent and recovery has passed to the producer's reopen. Until now the dying reader claimed delivery on its way out, so the whole reopen window read as a healthy source.
Only bytes that crossed the network move the axis back to healthy. This is the same definition of progress the reconnect ladders have used since #380: a serve out of the resident window, the retained head/tail spans or a resident detour block hands back read-ahead the origin paid for before it died, so it can no longer erase a stall. Without this the honest give-up would have been wiped by the next read out of the window.
A metered detour fetch now reports the stall it was already charging its ladder for. 429 / 503 / 509 on the arm built for throttling origins moved the ladder and told the phase nothing; measured against a local 509 origin, streak=3 with the phase still reading healthy.
One case is deliberately unchanged: the faulted-runway refill replaces a dead connection while megabytes are still resident and stays silent, because playback there is uninterrupted. The empty-window ladder reports it when playback genuinely starves.
The reconnect loop is also under test for the first time (it was left as device-verify pending when the phase was introduced): a real reader against a local refusing origin, pinning the phase sequence across runway, refusal and give-up, a transient refusal clearing on the origin's own delivery, and sixteen retained-head serves that must not move the axis.