Releases: superuser404notfound/AetherEngine
Release list
6.56.3 - A swap the session survives keeps its contract
Three silent exits from one field capture on 6.56.2, and the one that mattered was not silence at all: it was a lever that had been disarmed for a whole class of loads since it was written.
An in-place item swap keeps the contract its session was loaded under (AE#440)
A swap replaces the item under a session that stays whole. All six swap sites called the host's load with the item arguments only, so every one of them re-declared the session with whatever the parameter defaults say: not live, no HTTP headers, the loopback forward buffer, no readiness deadline, no carriage probe.
It was found from outside, as a live rejoin that produced no AE#440 line at all, neither a decision nor a witness:
11:22:30.241 [AetherEngine] #446 the window ran out and the source is delivering again; swapping the
item so the session is live once more, at 8265.25s, the place it held
11:22:30.242 [NativeAVPlayerHost] #14 load url=.../media.m3u8 startPos=nil
11:22:30.258 [NativeAVPlayerHost] #14 timeControlStatus=waitingToPlay reason=...MinimizeStalls t+0.02s
The lever was not declining to act on that path, it was not armed on it. The same isLive: false reaches the AE#287 premature-end recovery, which is gated on exactly that flag, on a live session that had just closed a window with ENDLIST and therefore has a finite duration to fall short of.
The contract is now a value the host holds for the session (SessionLoadContract), and swapItem carries it forward, so a swap has no contract argument left to get wrong while a genuinely new session states its own. On the remote-HLS bypass this also restores the origin's auth headers, the adaptive forward buffer, the readiness deadline and the carriage probe across a recovery reload, all of which a swap used to drop.
A witness that ended before its first sample says so (AE#440)
A refusal whose hold ended inside the first 250 ms reported ahead 0.00s, empty=true, which is the value the sampler was initialised with rather than anything it read. It contradicted the empty=false the refusal itself had measured one line earlier, and it described a starved buffer on a join that reached playing at t+0.15s.
The reading is optional now and the line names the absence, so a measured cushion and a witness that never got to look are different sentences.
A decision abandoned mid-reading says so (AE#440)
The buffer reading behind the decision is asynchronous, so a hold that ends while it is in flight has to be left alone rather than acted on from a state that no longer exists. Leaving it alone silently is what was wrong, and it is the exit a rejoin takes: those swaps held for about 70 ms before the rate rolled, which is shorter than the reading, so even an armed lever would have produced nothing to read.
A rate-only gate that reaches its cap names what the dwell did (AE#449)
The cap line now carries how many times the cadence run broke and the longest unbroken run it managed, so a .multiple that never settles says whether the panel kept changing what it reported or stopped reporting at all.
This is evidence rather than a guard. mode check (native) reads throughput averaged over its whole sample, which missed ticks drag down; the gate reads a single tick's mode interval. A capture with panel=35.456Hz next to nominal 50.002 therefore cannot move the gate's reading, but a block of missed ticks longer than the freshness window can still break a dwell, and that outcome is safe: the cap stands, exactly as it did before the fix.
Notes
No public API change. Everything here is internal to the native host and the display-criteria gate.
Full notes in the CHANGELOG, diff against the previous release: 6.56.2...6.56.3.
6.56.2 - A witness must speak on the ordinary ending
Two follow-ups from one two-minute device capture on 6.56.1, both on the live-join path, and both cases of an earlier round leaving something unmeasured rather than getting a number wrong.
A refused live-join hold reports every ending, including the ordinary one (AE#440)
The witness added in 6.56.1 samples a refused hold and promised exactly one line per refusal, on the stated grounds that a witness silent about its own negative cannot be told from one that never ran. Its first field run produced three refusals and one line.
The cause is an ordering rather than a missing case. The override's one-shot is spent on the .playing edge, deliberately, so it can never reach a mid-stream rebuffer. That makes the rate rolling and the one-shot being spent the same event, written by the same observer callback one line apart:
10:39:18.683 #1 AE#440 live join: leaving the stall-avoidance wait alone
(buffer ahead 0.00s, empty=true, floor 1.50s)
10:39:20.683 #1 timeControlStatus=playing reason=- t+2.00s
Nothing in between, and nothing after. The sampler read that spend first and as a reason to stop without a line, so the most common way a hold ends was the one that said nothing, and the ending it was written to report was reachable only by a pause. The one refusal that did speak escaped it because the cushion crossed the floor 17 ms before the roll, so the sample landed while the hold was still standing.
The ending is now a pure decision and every branch of it emits. Two of them get a line of their own rather than borrowing a wrong one: an override that cut the wait short is the opposite fact from the wait ending on its own, and an item replaced under the witness is a join abandoned rather than resolved. The host going away emits too, since the session id is held rather than read back off the host.
Also per load rather than per process: liveJoinHoldWitnessStarted was documented as one witness per load and never actually reset, so on the path where the host is reused across loads the second live join would have armed no witness at all.
A panel holding an integer multiple settles the rate-only gate (AE#449)
6.56.1 released the display-criteria gate as soon as the panel was measurably running the rate a rate-only criteria write asked for, and left the integer multiple spending the full 2 s cap. The reasoning was that at any single instant 50.002 Hz satisfies a 25.000 request both as the mode the panel is staying in and as the mode it has not left yet, so one reading cannot rule a pending switch out.
The device capture that decides it came back twice. The panel read 50.002 Hz in the cap line and again in mode check (native) three seconds later, the picture was up at t+0.06s, and the frame then sat visibly still for 2.05 s and 2.02 s, while the 50 fps tunes in the same run reached motion 44 to 84 ms after picture.
What separates the two cases is time, not a single reading. A real switch on this hardware runs about 2.8 s and blanks the panel while it does, so a display link that keeps delivering ticks at an unbroken cadence is a panel that is presenting. A multiple now settles once it has been held unbroken for 300 ms, and any tick that reads a different cadence, or none at all, restarts the run rather than extending it. The run compares the relation and not the hertz, because the per-tick figure moves in the fourth decimal and comparing values would restart the dwell on nearly every tick.
[DisplayCriteria] rate-only criteria already satisfied (..., panel 50.002Hz, an exact x2 multiple
of the requested rate, held unbroken for 302ms; released 1698ms before the 2000ms cap)
This does not claim that no switch will begin later, and the 2 s cap never covered that either: a 2.8 s switch outlives it, so the gate was already releasing about 800 ms into a real one. The mode check (native) line a few seconds on reads the panel again, so a switch that did arrive late stays visible in the same capture.
Notes
No public API change. Both fixes are internal to the native host and the display-criteria gate.
Full notes in the CHANGELOG, diff against the previous release: 6.56.1...6.56.2.
6.56.1 - An axis is measured where the picture landed
Three defects and two witnesses, all of them from device reports on 6.56.0 and none of them reproducible here without being told where to look. The two axis findings share one root: this side was predicting where AVPlayer would put a run instead of reading where it did.
The axis is measured where a segment landed, not where it was fetched (AE#418)
After a six-seek burst the reporter's captions ran 2 to 3 s BEHIND the picture, the opposite direction from every earlier round of this issue. A fetch is not a placement. During a burst AVPlayer asks for a segment and seeks away before the bytes are used, so nothing on its timeline moves, while this side had already folded that epoch's worth into the axis. Every placement after it composed onto a base AVPlayer never carried, and the error is permanent for the session:
seg88 placed (advertised 352.936s, worth -3.045s): axis 0.000 -> -3.045
seg187 placed (advertised 748.122s, worth -2.043s): axis -3.045 -> -5.088
seg197 placed (advertised 788.204s, worth -5.589s): axis -5.088 -> -10.677
His own host log carried the disproof in a line that was already there: at the failing moment the item's loaded range began at 791.2, and 788.204 + 3.045 = 791.249. AVPlayer had composed that segment onto the resume axis, the middle fetch moved nothing, the honest axis was -8.634, and the 2.043 s between the two is the caption lag he heard.
So the axis stops being predicted. AVPlayerItem.loadedTimeRanges is the on-device account of where AVPlayer PUT a run, one subtraction inverts the placement, and after every VOD seam the engine reads the range holding the playhead and either confirms the base it composed onto or corrects it. It only ever collapses onto an axis this session published, so a range read before the bytes landed or after eviction trimmed its start is refused rather than believed. The harness confirms the oracle exactly: a resume predicting a seam at 52.000 reads loaded [52.000-64.958], a far seek predicting 21.000 reads [21.000-38.622].
An epoch worth nothing still owns the stretch it took over (AE#448)
Found while measuring the round above, on the same arm. After a seek whose restart opened a fresh epoch, the reported clock sat about 1.7 s ABOVE the frame on screen for seven seconds and then settled by itself.
The axis was right the whole time. What was missing was a seam. A placement published only when the placed epoch was worth something, and the shift table dropped a zero rather than recording it, so an epoch whose first segment opens exactly on its boundary announced nothing at all, while its bytes still take over everything from their placement upward. After a backward seek that stretch is still covered by an older epoch's seam:
prodShift=-10.67s hostShift=-9.00s seams=2
The session's axis was -10.667 and the clock folded -9.000, because the playhead sat below the newest seam and the map answered from the one underneath it. The 1.667 s between them healed the moment playback crossed that seam, which is why it read as a settle rather than as a wrong axis. An epoch's first segment now publishes a seam whatever it is worth: the value is unchanged for a zero-worth epoch, since its content begins exactly at its advertised start, and the point is which stretch of the item axis those bytes own. Same arm, the landing goes from rendered=4.67 target=3.00 to rendered=3.00 target=3.00, within one frame from the first tick.
A rate the panel already runs is not a switch to wait out (AE#449)
The display-criteria gate in front of play() had no way to tell "the handshake is still in flight" from "there was never anything to hand over". A rate-only write of 50.000 to a display already running 50.002 Hz posts a mode-switch start, never posts an end, and holds isDisplayModeSwitchInProgress for the whole cap. Eight of nine gates in an 18 minute session spent the full 2000 ms, with play() released in the same millisecond the cap expired, every time.
That cost nothing measurable while the live manifest gate held 2.4 to 3.4 s. With the 6.56.0 holdback work the first manifest is served 1.2 s into a native join, so the criteria cap became the term deciding press to motion, and the second it saved ahead of the picture it spent again holding the picture still.
A display link armed at gate entry now reports the mode the panel is running per tick, and a rate-only write whose requested rate the panel already runs has nothing left to settle. Kept narrow on purpose: engine rate-only writes only, exact match only (a 50.002 Hz panel satisfies a 25.000 request, but so would a switch to 25 still in flight, so a multiple is measured and logged instead), and a stale display link keeps the gate waiting, because a panel not putting frames on screen is what a blanked HDMI re-sync looks like from in here.
Two terms that could not be read (AE#447, AE#440)
Both are the same defect in a log line: an absence that has two meanings printed one way.
The live TARGETDURATION seal reported measured floor none yet whenever its cadence term was absent. The meter exists only for live INGEST sources, where an upstream hands over finished segments whose arrival intervals can be observed; a raw MPEG-TS source is cut inside the engine and has no arrival cadence at all. Two device runs read the pending wording as a meter that had not measured anything yet, and credited fixes on a path they could not reach. A source with no meter now says so, none yet is kept for the one case it was right about, and the served value is unchanged in both.
The live-join guard that may cut AVPlayer's stall-avoidance wait short decides on a transport-status or waiting-reason change, and nothing else re-reads the buffer. A join that begins starved and fills while the reason stands still therefore gets no second look, and left no line saying whether that had happened. A refused hold is now sampled every 250 ms for up to 5 s, which outlives both holds measured in the field (1.55 to 2.81 s) and resolves inside the shortest, and exactly one line says which of three things happened: the cushion reached the floor with the hold still standing, the hold ended first, or the budget ran out. All three speak, because a witness silent about its own negative cannot be told from one that never ran. It observes only, and does not start playback: firing on a cushion that has just crossed and is still climbing is the bet the floor exists to refuse.
API
None. Patch release, no public declaration added, removed or changed.
2326 tests in 321 suites pass. Thanks to rrgomes and tschuegy, whose retests carried the two lines that made the axis findings answerable.
6.56.0 - A rejoin keeps the place it held
Three live threads, each found on a reporter's device and none of them reproducible on this harness without being told where to look. Two of them are the engine measuring itself and believing the answer.
An outage rejoin lands where the session was held (AE#446)
A live seek clamped its target against LiveWindow.edgeTime, a running maximum over publish ticks, and converted it by subtracting that edge from the item's seekableEnd sampled now. The pair describes one state only while both come from the same epoch, and a rejoin runs in exactly the two moments where they do not. During an outage the published edge freezes, because an item that has seen an ENDLIST never reloads its playlist, while the playhead runs on through the runway. The held position therefore ends up above the frozen edge, the clamp pulls it back down, the distance behind live collapses to zero, and the fresh item joins the live edge.
measured on a device: a viewer 31 s back rejoined 29 s past the place it held,
timeshift discarded
An edge published on a post-rebase shift, against an item still on the old one, lands the same seek backward instead. Both ends now read one sample: the edge comes from the item being seeked, and the conversion runs through the seam-aware PresentationAxisMap the engine already uses for scrub thumbnails, which reads the shift that was in force for that position.
The hold now lasts as long as the read it depends on (AE#446)
The no-cut watchdog abandons the source read 35 s after the last cut, and that read is the only thing able to observe the source coming back.
harness, 76 s outage, before: read aborted with 46 s of runway still being
delivered, the source's return at +76 s never seen, last frame held for the
rest of the run
A starvation verdict now defers while the closed window still holds segments above the consumer's fetch point, bounded by the hold budget that already exists, so the deferral lasts only while pictures are still being delivered. A wedged cutter is untouched, and a read that has been given up now blocks both the item swap and the watcher rather than being polled: the no-cut exit flushes a last partial segment in the same millisecond as its abort, which refreshes the finalize timestamp, so a dead read read as "delivering again" for one cadence and the watcher swapped into a window whose source was gone.
three legs: 25 s outage and 76 s outage rejoin with zero segments skipped,
150 s outage spends the budget and hands the session to the host
The aetherctl live --freeze-after leg reports and judges in segments now rather than in seconds. The fixture serves every connection from packet 0 of its loop, so a reconnect during the freeze skips the rest of that loop and reaches the engine as a real rebase (28.7 s on the bundled seed), which the seconds verdict could not tell from a lost position.
A holdback is decided at the resolution it is served (AE#447)
The first manifest of a .fastZap join is gated on 3 x TARGETDURATION of media. On a reporting stack cutting a strictly 2.000 s GOP, the seal landed on 3 rather than 2, so every zap withheld 10 s where 6 s was the promise: a measured 1.07 to 1.09 s of wall clock on 7 of 7 tunes.
Round 1 removed four reasons the cadence floor was inflated, two of which were this engine measuring its own wait. The meter was seeded with the upstream's self-declared TARGETDURATION, which is the exact number the class exists to distrust; the playlist poll ran at half that same advert, so a 2.000 s origin advertising 3 was sampled every 1.5 s and its arrivals quantized upward (measured floor 3.133 s on one join of three); the floor read the still-open gap from inside the gate that was holding it open, which feeds back through the holdback (three consecutive joins sealed 3, then 4, then 4); and an arrival interval entered as ceil(gap), asking for 4.5 x gap of startup depth nobody chose, where the patience it answers to is 1.5 x TD. The whole derivation is printed once per session now, so the term that carried a seal is nameable from a log instead of by elimination.
live TARGETDURATION sealed at 2s (holdback 6.000s): max EXTINF 2.000s,
1.5 x cut target 0.750s, measured floor 2.069s needs 2s of patience;
upstream advertises 3.000s (reported, not used)
Round 2 came back from the device: the four fixes landed, the floor reported none yet, and the holdback did not move. What made it answerable is that the new line contradicts itself, printing max(ceil(2.000), ceil(0.750), -) and sealing at 3. A live #EXTINF is nextStart - startSeconds, a difference of two accumulated item-axis doubles whose operands carry different representation error.
max EXTINF 2.000s [exact=2.0000000000000004 ceil=3]
74 of 80 segments exactly 2.0, six one to four ulp above
The seal takes the max over the window, so one is enough: ceil charges a whole second for an excess of 4e-16, and 3 x TD turns it into a 9 s holdback. The playlist writes #EXTINF with %.3f, so a millisecond is the finest distinction any client can ever read, and nothing below it may decide anything. Every term of the derivation is now taken at that resolution, with rounding that matches the formatter (ties away from zero), so the value is never below what the playlist prints and the promise still covers every segment the client was handed. A genuine excess is untouched: 2.0006 s still seals at 3.
The same error runs the other way through the first-serve gate, and sealing at 2 is what exposes it. The gate then wants exactly three 2.000 s segments, and their float sum lands a hair below 6.0 for some first-segment starts (0.030 s does, 0.060 s does not), so judged raw the gate would have held for a fourth segment it does not need, a full extra segment duration, on some sessions and not others. The cushion check and the first-serve account now use the same served resolution as the value they are checked against. A subtitle rendition and the whole-program sideload had each rebuilt this derivation by hand and could advertise a depth the video did not; both call the shared one.
This harness cannot reproduce any of it: its fixture starts the first segment at exactly 0 and cuts a binary-exact duration, so every difference is exactly 2.0 and five joins in a row seal at 2. The case lives in the test suite instead, built by accumulating the way the producer accumulates, and one test asserts that the seal line adds up to the number it reports.
A growth ratio names its allocator (AE#445)
A footprint pinned to one REALLOC-tagged block on an exact x1.25 ladder is attributable from the ratio alone. Foundation's Data adds newLength >> 2 above 128 KB, av_fast_realloc adds a sixteenth, the AVIO dynamic buffer adds a half, Swift's Array doubles. Nine rungs at 1.2500 to 1.2502 is a Data and nothing else, so the census prints bigGrowth=1.2501x(Data) beside bigExact, with the memprobe walk and the 8 Hz trigger walk each keeping their own previous value: a shared one would report the ratio the other sampler's interval produced. An unrecognised ratio is printed bare rather than rounded into the nearest family.
setLargeAllocationCensusEnabled takes a triggerCaptureCap (0 = uncapped). A steady mux-rate climb spends one capture per threshold crossed, so the previously fixed twelve ran out 4.4 minutes before the reporting session's kill and the decisive final step survived only in the 30 s grid.
aetherctl customio --live gains --host-carry removeFirst|subdata, a positive control for an ingest-side parse carry on the delivery path. A Data consumed from the front with removeFirst only advances the slice's lower bound, so count stays under one TS packet while the backing store holds the whole stream.
same fixture, 260 s at 14 Mbit
removeFirst physFP 174 -> 450 MB = 1.38 MB/s, carry count=48B with its
slice lower bound at 436.9 MB
subdata physFP 63 -> 60 MB = -0.01 MB/s, lower bound 0.0 MB
API
HLSLiveIngestReader.upstreamSegmentDurationSeconds: the longest segment the upstream has actually served, the measured counterpart to its self-declaredTARGETDURATION.HLSLiveIngestReader.closedLiveCadenceSeconds: the longest CLOSED inter-arrival interval, excluding the currently open gap, which inside the first-serve gate measures this engine's own wait rather than the source's cadence.setLargeAllocationCensusEnabled(_:triggerThresholdMB:triggerPollHz:triggerCaptureCap:), the last parameter new and defaulting to the previous fixed twelve.
Both additive. upstreamTargetDuration still exists and is now reported in the seal log and used nowhere else.
2290 tests in 318 suites pass. Thanks to cmcpherson274 and tschuegy, both of whom went back to the device with probe builds rather than stopping at the log, which is the only reason two of these had an answer.
6.55.0 - A live window is sized by what the session can hold
A window measured in seconds and a bound counted in segments, and nothing in the session made them meet. For any DVR window deeper than the producer's resident cap, the cache filled to the cap before the playlist had slid once, the pump parked against it, and the live edge froze there for the rest of the session. Downstream that read as a dead origin, and it was read that way for three campaigns, across two engine families and a server-side investigation that had already been opened against Jellyfin and dispatcharr.
Two of this engine's own diagnostics agreed with that reading. the producer is starved, not the consumer and source stopped delivering both name the source, and neither could tell a pump this engine was holding from one nothing was feeding.
180 x EXTINF
The playlist does not start sliding until windowSegmentCount segments exist. Below the cap that is fine and the window slides forever. Above it the cache reaches the cap first, the pump parks, and evictBelow is never reached to release it. The comment on that cap claimed a consumer that had stopped polling was the only way to approach it: true for a 60 s live-only window, false for every DVR one.
That predicts the freeze instant exactly, and the prediction is what closed the argument. Reproduced with no remote server, no Jellyfin and no seek at all, on the loopback fixture at a 1 s cadence and an 1800 s window:
[+179.28s] live headroom PARK head=180 resident=180 cap=180 parked=0s
edge pinned at 178.49 from that second on, never released
503 /media.m3u8 reason=blocking reload msn=180 unsatisfiable
[+194.36s] #65 stage-2 skipped: no segment finalized since the stall; the producer is starved
[+215.46s] #93 item death -> #65 nudge did not revive; reloading at the live edge (rejoin)
re-firing every ~36 s, 16 x -12888 "Playlist File unchanged"
The reporting run cut 3.9 s segments and froze at t=703. 180 x 3.9 = 702. Two earlier runs on 6.21.0 froze at 737 and 747, i.e. a 4.1 s cadence. A 33 minute control that never froze turned out to run a 60 s window, 15 segments, never within a mile of the cap.
The window is now sized by what the session can actually hold: the retention budget over the observed segment size, under a playlist ceiling of 900 entries, because the whole visible window is rebuilt and re-served on every poll. It then slides at its own depth instead of the producer parking against a smaller number, and seekableLiveRange, which reads the cache since 6.52.0, advertises the depth that exists. The resident cap becomes a backstop above the window rather than a bound below it. When the ask cannot be served, one line says which bound bound and at what depth: an 1800 s ask at 3.9 s and 7 MiB per segment resolves to 292 segments, about 19 minutes, instead of 30 minutes that ends the stream at 12.
same fixture, 250 s parks item deaths -12888 edge at t=228
before 8 5 16 frozen at 178.49
after 0 0 0 advancing, 211 resident
The park is also what stopped the origin
A parked pump is not reading, so nothing drains the socket. Measured across the park with the reader's own runway gauge, pumpAheadMB went 0 to 8 to 16 while the reader kept pulling at mux rate into a buffer the demuxer no longer consumed. When the runway fills the reader stops, the socket backs up, and a single-connection live source dies behind it. At the reported 1.8 MB/s that takes about 35 s to fill, which is exactly the reported gap between "segment production froze" and "origin flattened", with the upstream broken pipe 18 s after that.
So the teardown that looked like a server decision was downstream of this engine's own backpressure. The stall ladder now says PARKED by this engine (live headroom cap) when that is what it is.
A stalled live window is a finite asset
A live playlist whose tail stops moving stops being fetched. AVPlayer reloads it, finds it unchanged (-12888), and after a handful of those it stops polling and stops requesting segments, including ones it has never downloaded that the playlist still lists and that are sitting on disk. With a viewer 147 s inside the window and the source frozen: six more segments at playback rate, then silence with 115 s of runway resident. And when the playlist finally moves again, AVPlayer rejoins at edge-minus-HOLD-BACK by itself, a second way to lose a rewind position that no reload policy here can cover (measured forward step 117.76 s).
Two cheaper answers were built, measured, and are documented as dead ends rather than left to be tried again. A byte-distinct refresh tag changes nothing, because the unchanged test reads the parsed playlist. Sliding the window on the clock so MEDIA-SEQUENCE advances does not reset that clock either, and it spends the viewer's rewind depth for nothing. What AVPlayer watches is the tail.
viewer 149.96 s inside the window, source frozen
before 24.00 s then silence, 6 fetches, 3 item deaths
without CAN-BLOCK-RELOAD 78.57 s then silence, 18 fetches
window slides on the clock 48.10 s, 3 item deaths
ENDLIST 166.75 s, the whole runway, 0 deaths, 0 x -12888
An item that has seen an ENDLIST never reloads its playlist, so a source that comes back is picked up with an item swap, which is the 6.54.0 one and therefore keeps the place. didPlayToEndTime at the end of such a window is not forwarded as .ended: that state is terminal, and a source hiccup must not end a session.
Both ends of a live clamp read the same bound
The resume clamp has landed on the honest floor since 6.52.0, but it still triggered on window arithmetic. Where retention runs short of the window for a session's whole life, a 420 s window advertising about 405 s of real depth, a resume between the two got no clamp at all for a position the cache no longer held. Both ends read the same bound now. The margin applies only once the window is sliding: before it fills, the floor is the session's own start rather than an eviction frontier, and a margin there would only shove a resume near the start forward.
live window slid past the consumer was reading the last fetch when the cost of a slide is decided by the next one. The consumer walks indices forward, so everything below the declared target is already in AVPlayer's buffer, and a viewer parked at the floor sits one segment below firstVisible for part of every slide with nothing lost. Chasing four such benign lines turned up why the tolerance is not cosmetic: at exactly that off-by-one, eviction unlinked the segment currently being served. A serve holds a URL rather than a file handle, so that is a 404 window for an index the playlist offered when it was asked for. Eviction now stops at the fetch point, the bound evictBelow already documented for itself, and never trails firstVisible by more than that one segment, so a consumer that stopped fetching cannot pin retention behind it either.
The pool goes at the seam the engine owns
HLSSegmentProducer pumps on a bare Thread, which has no autorelease pool of its own, and FFmpeg's read callback reaches a host's IOReader from inside that loop. Anything Foundation hands a custom reader back at +0, an NSData out of FileHandle for instance, is therefore stranded for the length of the session, and on a live source that never EOFs that is unbounded by construction. The engine had paid for this twice and fixed it one reader down both times; neither pool can cover a reader the host wrote, and a host cannot be asked to know which of this engine's threads its callback lands on. The pool now sits at CustomIOReaderBridge, the single door every custom reader comes through, around read, seek, the size and seekability probes and cancel.
aetherctl customio --live, 5 min at 0.95 MB/s physFP slope retention ratio
before 0.95 MB/s 1.00
after 0.00 MB/s 0.00
A reader that preads into the buffer the bridge hands it allocates nothing per read and was never affected by any of this, which is the control that had been missing: that arm is the harness default now, and it measures flat (ratio -0.03 over 541 s, 0.00 with a 300 s DVR window) across 900 MB of source. The harness also reports how far behind the live edge the engine ever reaches back, because that is the figure a live host sizes its own ring by. It is 0.0 MB over a seek in both arms, DVR included: the timeshift lives in the segment cache on disk, not in the source.
A live join starts on a cushion that was measured
LoadOptions.liveJoinStartsImmediately defaults to true now. The device A/B the opt-in was waiting for ran two sets of ten channel changes on the reporting stack: press to moving picture fell from 6.4 / 6.5 / 7.2 s to 4.3 / 4.8 / 5.1 / 5.6 s, press to first picture was unchanged at 3.4 to 3.9 s in both arms, and stalls and dropped frames stayed at zero in both. What the lever removes is exactly the frozen tail. Cold joins, where tuner spin-up is inside the first byte, were identical in both arms, which is the guards keeping the lever out of the starved case.
Three of those guards changed with the default. The lever requires 1.5 s of contiguous buffer ahead of the playhead rather than merely a non-empty one, since isPlaybackBufferEmpty reads false for a single served fragment exactly as it does for a four second cushion. It is evaluated when the waiting reason changes rather than only when the transport status does, because a live join runs EvaluatingBufferingRate then ToMinimizeStalls inside one unchanged timeControlStatus. And all three readings behind the decision are taken off the main actor in one batch, because the guard runs while the media server is least li...
6.54.0 - A recovery keeps the place it held
Two live reloads that look alike and are not. One rebuilds the pipeline, and the position it leaves behind really is on a dead axis. The other swaps the AVPlayerItem under a session that never stops, and the position is still resident content in a cache that has not moved. One rule covered both, written for the first, and it was the second that kept costing viewers their place.
A downstream reporter lost 540 s of rewind twice on a frozen source, and the log line that settled it named neither recovery I had asked about. The path was a third one, and the answer to the question it raised turned out to be two defects deep: where the recovery rejoins, and why the item died at all while nine minutes of runway sat on disk.
An in-place item swap is not a session teardown
reloadStalledConsumerItem calls host.load(inPlaceSwap: true) and nothing else. No stopInternal, so HLSVideoEngine, its VideoSegmentProvider, the segment cache and the served playlist all survive it, which is what the #65 line means by (same URL, same host). LiveReloadPolicy still handed it the rule written for the pipeline rebuild: live reload, therefore no start position, therefore skip the initial seek. On that path zero was the only target available and a stale clock really could wedge the fresh item against a re-served backlog. On this one neither holds, and since 6.52.0 seekableLiveRange is the intersection of the advertised window and what the cache actually holds, so the session can prove a position is still there before it decides to throw it away.
It now rejoins at the playhead clamped into that range, and keeps the edge rejoin wherever nothing can vouch for the position: remote-HLS live, the software live path, and a viewer who was at the edge anyway.
Measured on the harness, same seed and parameters, a viewer 102 s inside an 1800 s window when the source froze:
before forward step 100.99s reloading item at the live edge (rejoin)
after forward step 1.10s reloading item at 61.31s, the place it held
The second line of that table is not the interesting one. In the before run the session died again afterwards; in the after run there was no item death at all. A rejoin at the edge of a source that has stopped delivering starves straight back into the ladder it came from.
The distance that decides cannot be measured during the stall
behindLiveSeconds grows by exactly the length of a stall, because the edge runs on while the playhead does not. Asked at the moment a recovery fires, it cannot tell a viewer parked in the DVR window from an edge viewer whose picture froze forty seconds ago, and answering it wrong drags that edge viewer permanently backwards. The engine now records the distance from the last publish where the clock actually moved, tick to tick rather than against a running maximum, so a backward seek can raise it again.
The threshold is the served TARGETDURATION rather than a constant picked to fit. The edge advances one segment at a time, so a healthy playhead's distance from it oscillates inside exactly that span by construction, and anything past it is a position (measured: at most 3.2 s on TD 5).
The seek is stashed in the #127 slot rather than issued. A live seek does not defer itself, so issuing one here would convert against a seekableEnd the fresh item has just reset to zero and land on zero, which is the wedge the old rule existed to prevent. Replayed at readiness it converts as a distance behind the edge, so it lands on the same content whatever axis the new item came up on.
A poll a dead source can never answer is no longer held
Rebuilding the reporter's failure exposed the step before the one reported. A viewer parked 192 s inside the window, with range=1.4...253.4 saying the whole runway is resident, stalls anyway when the upstream stops delivering.
AVPlayer refreshes the playlist with a blocking reload and issues no segment requests while one is outstanding. Once the source stops, that poll can never be satisfied, and RFC 8216bis has us hold it for 3 x TARGETDURATION before the 503 is allowed. Timed off one run: poll out at +89.18, last segment fetch +93.21, buffer drained and playbackStalled at +102.12, 503 at +107.19. Eighteen seconds of hold against a four second forward buffer. This is not the local server serializing anything; the request log carries the connection fd now, and it shows the segment connection sitting idle beside the parked playlist one for the whole hold.
The doc comment on liveBlockingReloadHoldSeconds already states the rule the bound violates here. It is only tight while the source still cuts segments.
So the session withdraws the advertisement instead of holding. This is the liveProductionHalted policy from the #167 follow-up applied one watchdog earlier: that one turns CAN-BLOCK-RELOAD off for the rest of the session, but only once the no-cut watchdog has run at 35 s, and the starvation begins within ten. The new signal is delivery latency at the provider, 1.5 x TARGETDURATION because that is AVPlayer's own patience for an unchanged live playlist, and latched for the same reason #167 latched: a source that has missed its cadence once belongs in the plain-reload regime, and letting the advertisement return would flap it across every recovery. A poll already in flight when the source died wakes in one second slices rather than riding out the whole bound.
before withdrawal never, first fetch back at +108.2, two stalls in the outage window
after withdrawal at +98.2, first fetch back at +99.3, none
playbackStalled across the run falls from 3 to 1, and the -15410 Invalid server blocking reload behavior that used to end the starvation by accident, AVPlayer demoting itself out of low latency, does not occur at all. The withdrawal produces the same demotion deliberately: one 503, and a playlist that no longer claims to support what it cannot deliver.
What it does not do is let a client ride out an arbitrarily long outage. Once the playlist stops changing altogether AVPlayer parks regardless, and the no-cut watchdog hands the session to the host with the producer is starved, not the consumer. Serving the remaining cached window as a finite playlist is the next lever and a larger question.
The harness can fail the way a real source fails
aetherctl live had --drop-after, which sends an RST and drives the reader's reopen ladder. That is not how a dead upstream usually looks. --freeze-after leaves the connection open and writable and simply stops delivering, which is the shape both defects above need, and neither reproduces without it. --rewind-before-freeze parks the playhead inside the window first, and --force-recovery-reload-at drives the stage-2 recovery directly instead of waiting for a real consumer to starve into item death.
Consumers
No API changes. AetherEngine.forceStalledConsumerReloadForTesting() is new and is a harness hook, not app surface.
6.53.0 - A session counter describes the session
One diagnostic mistake, made twice one layer apart: a total that describes a session was read off an object the session replaces. It restarted from zero exactly where a session became worth measuring, and the line it appeared in said nothing about that.
The bill arrived from outside. A downstream reporter watched rx= fall in the middle of a deep backward live seek, read it as the engine swapping its origin socket, and built a server-side theory on top of it across two rounds. The socket swap never happened, on his logs or on ours: one pump conn start for a whole campaign, and the live path installs no proactive backward-jump producer restart at all. What fell was a counter's scope.
AVPlayerItemAccessLogEvent counts per entry, not per session
networkTransferredBytes and droppedFrameCount read the newest access-log entry. Those counters are totals PER ENTRY, and AVFoundation opens a new entry whenever the playback session changes under it, so a healthy session watches its own byte and drop totals fall back to a fresh entry's partial count.
Measured on the live loopback harness across a rewind into the DVR window and a return to the edge. One origin connection for the whole run, no producer restart, no reopen:
before: rx=1.7MB -> 3.4MB -> 2.2MB -> 0.6MB drop=0 -> 44 -> 0
after: rx=1.7MB -> 5.0MB -> 7.3MB -> 8.4MB drop=0 -> 44 -> 88 -> 112
Summed across entries, both describe the session they are read as describing. The rate stays a newest-entry read on purpose: observedBitrate is meant to be instantaneous.
The same defect one layer down
demuxerBytesFetched, muxedBytesLifetime and producerRestartCount read the live demuxer and the live producer. A live reopen builds a fresh demuxer and a fresh producer under a session that continues, and a muxer rotation (program switch, ad pod) builds a fresh ByteCounter under a producer that continues, so every one of them zeroed on the recoveries they exist to measure. A leak baseline that drops to near zero on the one session that had a recovery in it is worse than no baseline.
Each replacement now folds the outgoing instance's totals into the session's, at the install funnel rather than at the call site, and only once a swap is final: the reopen ladder puts its old demuxer back when the producer build fails, and folding at the swap would count one reader twice.
producerRestartCount stated more than it could see
A producer restarts at most once, because it is built for one aim and replaced for the next, so the field was a 0/1 flag on the current instance. On a live session it is 0 by construction: the live recoveries replace the producer instead of restarting one, and performRestart bails on the empty live segment plan. It counts across producers now, and the live case is documented rather than left to be inferred from a zero. The live recoveries announce themselves in the log instead, live reopen attempt N/M and live producer rebuilt in place.
aetherctl shows the origin link next to the consumer link
rx and origin are two different links, and on the native path they cannot be the same one: rx is what AVPlayer pulled from the engine's own loopback server, so a live session whose source has gone quiet keeps raising it out of the segment cache. An origin question needs the origin number, and the CLI never printed it. play prints origin= beside rx= now, and live prints origin= and restarts= per tick, which is where a --drop-after recovery is driven.
Full changelog: CHANGELOG.md - 6.52.0...6.53.0
6.52.0 - A live surface states what it holds
Three live surfaces that described an intention rather than a fact. A join signal that fired before the picture moved, a rewind depth the session had never written, and a resume that decided something the host wanted to decide itself.
playbackPhase now tracks motion, not intent
state is transport INTENT: every autostart writes .playing the moment play() has been called, which is why the startup ladder deliberately ends at .presenting rather than at .playing. playbackPhase simply followed it, so the one observable documented as the single source of truth for what playback is doing announced .playing over a picture that was standing still, and AVPlayer's pre-play .paused status arriving after the autostart added a millisecond of .paused on top.
Measured on the loopback harness, a live join before and after:
before: loading -> playing t+0.07s -> paused t+0.08s -> rebuffering t+0.08s -> playing t+2.56s
after: loading -> playing t+2.60s (rate roll at t+2.52s)
The phase folds a fourth axis now, whether this load's transport has rolled once, and reads .loading until it has. state is unchanged, and the paths that publish no transport status credit the roll with their host, so their phase is bit-identical. A paused mount stays .paused rather than looking like a session still arriving.
hasFirstFrameReadyForDisplay keeps meaning what it says, that a picture exists rather than that it is moving. Both it and state carry that warning in docs/api.md now.
LoadOptions.liveJoinStartsImmediately
Past the first serve, AVPlayer can present the first frame and then hold it perfectly still while it decides whether its cushion will sustain playback (AVPlayerWaitingToMinimizeStallsReason). Against a source delivered at 1x that cushion is bought in wall-clock time, and nothing on the item shortens it: preferredForwardBufferDuration was measured inert, because the wait is a rate evaluation and not a buffer target. Reported from an Apple TV 4K over 11 consecutive tunes of a raw MPEG-TS channel: 1.5 to 2.8 s of bit-static picture on 9 of them, HDMI capture confirming the freeze.
Set, the first such hold of a live session is cut short with playImmediately(atRate:). Once per load, only while the host still wants to play, never for the EvaluatingBufferingRate reason (which AVPlayer.h describes as a brief monitoring period and says not to show waiting UI for), and only over a buffer AVPlayer reports as non-empty. That last guard is the documented precondition: over an empty buffer the call behaves as a stall instead, which is the shape that parks rate at 0 and never resumes.
Off by default. The hold does not reproduce on a macOS loopback harness in any window geometry (six of them, from a window shallower than its own holdback to one entered on a full backlog: the gap between first frame and rate roll stayed between 10 and 60 ms), so this is an opt-in whose effect only real hardware can measure. aetherctl play --live-start-immediately drives the engine end of that A/B.
seekableLiveRange states what the cache holds
The lower bound was max(0, edgeTime - dvrWindowSeconds), pure window arithmetic that never consulted the segment cache. Two regimes where that over-promises, both measured:
- The session's own start. A session joining a source already 181 s into its timeline advertised a floor of 0.00 for its whole run; a seek to 0.20 landed at 181.66, the first position ever written. The over-promise is exactly the join offset.
- Retention shallower than the window. With
dvrWindowSeconds: 30on 1 s segments the cache kept 24 s while the bound claimed 30. The window is a policy and the cache is a fact; they diverge for the life of a session, not only its firstwindowseconds.
The bound is the intersection now, and LiveWindow.clamp reads the same range, so seek(to:) asks for a target it can reach. The floor is a backward-contiguous walk from the newest resident segment rather than the cache's lowest index: a minimum is not proof of coverage, retained scrub bands leave interior holes, and a rewind advertised below a hole cannot play forward. Paths with no such cache to ask report no floor and keep the arithmetic exactly as before.
LoadOptions.clampsLiveResumeToWindow
play() moves a behind-live playhead by itself: an edge snap on a live-only source more than 45 s back, and a landing above the retained floor when a DVR window has slid past the playhead. Both are recoveries from a position that no longer exists, and both are right by default. What they are not is policy, and they were running as if they were, because the seek is issued inside play() and lands before a host with its own live-pause semantics can answer.
Defaults to true, exactly the previous behaviour. Set false and play() moves nothing: the engine keeps publishing behindLiveSeconds, seekableLiveRange and isAtLiveEdge, and seekToLiveEdge() performs the same recovery on request. A resume that deliberately does not move logs that it deferred, because silence there is indistinguishable from a resume that was never behind. The two inline branches are one pure liveResumeAction, and the 45 s threshold and 5 s landing margin are named rather than literals.
aetherctl
play prints a PHASE <phase> t+Ns line on every phase edge, stamped on the same clock as the first-frame line; the 1 Hz tick samples the phase and cannot tell a start signal apart from the roll. Its live telemetry also carries edge=, behind= and range=, which previously needed a patched copy of the CLI to sample at all. New flag: --live-start-immediately.
Suite 2220/2220 green. Thanks to @cmcpherson274, whose millisecond logs, HDMI capture and per-second range sampling turned four policy questions into measurements.
6.51.0 - A frame count is not a cadence
A software-path session that judders while every counter reads healthy is not a contradiction, it is a gap in what the counters cover. This release closes it, and gives each frame the duration the reorder buffer already knew.
The 1 Hz [SWDiag] line
enq is a count per wall second, and it is taken in the decoder callback. An even 23.976 fps timeline, one carrying a doubled interval or a duplicate timestamp, and one where a frame never reached the display layer at all, all read +24. Four fields separate them:
disp=+Ncounts frames the renderer handed to the queue target. A frame lost between the decoder and the layer (an unschedulable timestamp, a sample buffer that would not build) is now a gap betweenenqanddisprather than nothing at all.layerDropcannot cover that class: it is the layer's verdict on frames it received.lost=N(+n)names that class directly, cumulative and per second.dpts=min/maxis the shortest and longest gap in milliseconds between the timestamps handed over during the interval. A hole, a duplicate or a reordering fault shows here and nowhere else.vLeadis how far ahead of the clock the newest admitted frame sat, at its lowest over the interval.aLeadhas always reported the audio cushion; the video cushion had no field.
parked is now parkedPkts: it counts undecoded video PACKETS, not queued frames, and was read as a video queue depth in a bug report.
Reference reading from a 1080p23.976 VP9 + FLAC session on macOS:
[SWDiag] clk=5.90 dclk=1.00 aLead=4.09 vLead=0.27 parkedPkts=84 rebuf=n
enq=+24 disp=+24 lost=0(+0) dpts=41.0/42.0 layerDrop=... status=rendering
Frame duration
CMSampleTimingInfo.duration was .invalid on every software-path frame. The reorder buffer is already holding the successor when a frame goes out, so the exact length costs nothing and adds no latency. The last frame of a stream keeps an invalid duration deliberately: at end of media that is the frame that stays on screen, and a length is exactly what it must not have. A non-positive gap (duplicate, reordering fault) or one past a second (discontinuity) is not a duration either.
Diagnostics
[applySubtitleEvent] names the open-ended PGS placeholder instead of printing it as a cue that ends 4294967.3 s after it starts. A PGS composition carries no end of its own, end_display_time is UINT32_MAX, and the successor composition's trim closes it. The value was always correct; only the line was misleading, and it has been read once as an unsigned-32-bit overflow.
Suite 2186/2186 green. Thanks to @classicjazz, whose capture on #407 is what showed the line could not answer the question it was asked.
6.50.1 - The CODECS attribute states the source's profile
One manifest fix. No API change, no segment or init byte moves.
Fixed
-
The H.264
CODECSattribute misspelled every profile that carries a constraint flag. The master's entry was formatted straight fromAVCodecParameters.profile, and that field is not a bareprofile_idc: libavcodec ORs the constraint flags into the high bits, so Constrained Baseline arrives as66|AV_PROFILE_H264_CONSTRAINED= 578.%02Xis a minimum width rather than a maximum, so it printed as three digits and the attribute came outavc1.2420028, seven hex digits where RFC 6381 defines exactly six (avc1.PPCCLL). Constrained High and the High 10 / 4:2:2 / 4:4:4 Intra profiles overflow the same way. Main and High carry no constraint flags, which is why remuxed sources never showed it and low-bitrate streaming encodes did.The hardcoded middle byte was the other half.
profile_compatibilitycarries those same constraint flags, and declaring zero for every source contradicted the sample entry the muxer writes from the same extradata.All three bytes now come from the source in whatever form it carries them, the shape
plainHEVCCodecsalready had: the avcC states them outright (bytes 1..3), an MPEG-TS stream states them in the first three bytes of its SPS, and only a source with neither falls back to the codecpar fields, masked, with the two flags libavcodec preserved mapped back into the compatibility byte. Deriving the attribute from the same extradata the muxer stream-copies into the sample entry is what keeps the manifest and the init segment from disagreeing by construction.
Scope
Only the manifest attribute changes, and only on the master route: a media-direct session declares no CODECS at all. On tvOS that means this reaches HEVC sessions always, and H.264 sessions whose host prepared a native subtitle rendition.
Sources whose profile carries no constraint flags are byte-identical before and after. Verified side by side on the same fixtures through aetherctl serve --native-subs: High stayed avc1.640028, Constrained Baseline went avc1.2420028 to avc1.42C028.
Not claimed
That the malformed string broke playback. macOS AVFoundation accepted both spellings in an A/B on identical media, so this is a specification violation whose consequence on tvOS is untested. Take it as correctness, not as a fix for a symptom you are chasing.
Verification
2175 tests in 305 suites pass, including 16 new ones in AVCCodecStringTests covering both source forms, the masked fallback, and a shape assertion across all 13 H.264 profiles. CI green on macOS, tvOS, iOS and visionOS.
Upgrading
Drop-in from 6.50.0. .package(url: "https://github.com/superuser404notfound/AetherEngine", from: "6.50.1")
Full diff: 6.50.0...6.50.1