Skip to content

6.25.2 - A timeshift archive on its own axis

Choose a tag to compare

@superuser404notfound superuser404notfound released this 14 Aug 03:04
· 382 commits to main since this release

Drop-in from 6.25.1. Three fixes on the sequential-origin path (IPTV timeshift archives served over a single byte-0 connection), no API change. All three were reported, diagnosed and fixed by @tschuegy, in #368, #369 and #370, against an Xtream timeshift .ts on tvOS 26.6; each merged PR carries a follow-up commit for what the review turned up.

A sequential origin is the source class from #346: a connection that can only be read from byte 0, no ranges, no reconnect, and an append-only EVENT playlist whose EXTINF values are the durations actually muxed. All three defects live in that combination.

A chunk seam is a 2^33 leap (#368)

An IPTV timeshift archive is a chunked recording, and every chunk restarts near PTS 0. libavformat's 33-bit wrap correction turns that backward seam into a forward leap of exactly 2^33: on the device trace the dts delta was 8226410192 ticks, and 363524400 + 8226410192 is 2^33 to the tick. The leap reached the keyframe-gated cutter unmodified and walked its monotonic segment index to the plan tail, after which the session was structurally dead: the playlist froze, the backpressure park waited on a segment the playlist can never advertise, and the wedge recovery's answer is a reposition, which is exactly what a sequential origin refuses.

The live path has folded program boundaries for a long time. That same rebase now runs for sequential-origin VOD, and the cutter, the #65 ledger and the append playlist needed no change, because they already work on post-shift output time. No EXT-X-DISCONTINUITY is added at the seam: the archive is content-continuous and the output timeline stays continuous after the rebase.

The follow-up is the other half of it. The rebase keeps the item axis straight by moving the producer's shift, and currentTime was folded as item + shift - origin against a source origin latched once at session start, so the entire wrap landed on the scrubber instead: 250 s to 63378 s, measured, on an archive whose declared duration is one hour, with bufferedPosition and sourceTime following it. A sequential archive has no source axis for a display origin to sit on, since every chunk restarts near PTS 0, while its item axis starts at 0 by construction and is exactly what the declared duration measures. It now publishes the item axis; every other source keeps the latched origin and true source PTS from #270.

What escapes the rebase (#369)

Same trace, further downstream. Three containment gaps, each of which turned a leap that got past the rebase into a long-lived zombie session rather than a bounded failure.

The look-behind sample duration handed movenc the wrap itself as a duration: 8226410192 ticks, rejected as invalid, packet silently lost. It is capped at the discontinuity threshold now, and the write return code is logged on first failure so the loss is visible at all. Discontinuity-scale fold runs were discarded above 64 indices, which is precisely the width most certain to arm the #358 recovery, so they now reach the fold counters. And the advance-path backpressure park could park on a release target beyond the sequential playlist's advertisable frontier, a frontier only this pump's own finalize reports can move, so parking on it was waiting for oneself (field case: a fold-to-tail parked at target 364 while the playlist ended at segment 61).

The follow-up covers the two edges those left. The duration cap only guarded the inferred delta and then fell through to the container's own declared duration untouched, and that fallback branch is not an edge case here: it is what runs when no forward delta exists, at the EOF tail of exactly the wrapped stream the cap was written for. movenc rejects a sample on the number, not on where the number came from.

The skipped park is the more consequential one. The #207 disk park deliberately carries no wedge breaker, on the reasoning that the advance park catches a frozen consumer first. Skipping the advance park walked the pump straight into that assumption with the consumer possibly frozen: it races to the retention budget (2 GiB, about an hour of a 5 Mbps timeshift) and then holds there for good, which is worse than the multi-minute zombie the containment set out to kill. The skip itself is right, so the detector moves with it: the disk park now arms the same one-second wedge detector whose cadence it was already polling at.

A gate that waited for two of something that costs three (#370)

On a slow origin the session never started at all. AVPlayer's first /media.m3u8 GET was held for the full 30 s startup wait and the asset load timed out (-1008, CoreMedia -12884) while two segments and about 12 s of media already sat on disk.

The startup gate reused LiveEdgePolicy.minStartupSegments = 2, a live sliding-window constant whose reasoning is a live-edge holdback that cannot fit into a one-segment window. An append-only EVENT playlist has neither: it starts at media sequence 0, never removes anything, RFC 8216 §6.3.3 exempts EVENT from the three-target-duration rule, and the playlist's own refresh counter already defeats AVPlayer's unchanged-playlist patience. The demand was also steeper than it reads, because a sequential segment's real EXTINF is final only once the next segment's ledger opens, so two published durations are three segment opens, 12 to 18 s of media through a possibly throttled origin. The gate is one published duration now, and the live constant is untouched: it stays correct for live.

The second cost was in planning. For the uniform-stride fallback plan the keyframe-spacing scan (#358) starts with a seek, a silent no-op on the non-seekable sequential pb, and then consumes up to 20000 packets or 30 s of content from the single byte-0-only connection. A sequential origin never anchors its first producer past segment 0, so nothing seeks back between planning and the pump: those packets were not merely time lost, they were the archive's first GOPs, dropped. In both field traces the measurement came out at 0.480 s and 2.000 s, below the 4 s floor, so the scan had bought nothing either time. Sequential plans go straight to the target stride now, which costs nothing in cut geometry: cuts are keyframe-gated either way, so a stride finer than the GOP produces the same segments and only leaves index holes, which the append playlist already renders away.

The follow-up ties the release of a held playlist GET to the failure surface rather than to the two call sites this trace ran through. A sequential origin reaches two more: a muxerFailed exit revives through requestRestart, which a sequential origin refuses, and the #366 moov-prime revive ends on its own exhaustion cap. Either can fire before the first duration is published (an E-AC-3 archive whose first segment carries no audio packet is exactly that shape), and the held GET then still sat out its remaining 30 s on a session that had already surfaced failure. Every VOD source failure now surfaces through one method that releases the wait with it.

The gate also counts what the playlist can advertise rather than raw appended entries. A zero-duration entry is a plan index a long GOP skipped and gets no URI on EVENT, so counting entries could answer the held GET with a playlist that renders empty, which is the -12888 the gate exists to prevent. The producer's report ordering makes that unreachable today; with the cushion down to one entry there is no longer a second entry keeping it unreachable by accident.

Credits

#368, #369 and #370 were reported and fixed by @tschuegy, each with a device trace that pins the cause rather than the symptom. The rebase, the containment and the startup gate are their work; the follow-ups are noted per section above.

1845 swift-testing plus 509 XCTest green, four-platform CI clean.