Skip to main content

hls_runtime/client/
engine.rs

1//! [`HlsClient`] — the sans-IO caller-driven engine.
2
3use alloc::collections::{BTreeMap, BTreeSet, VecDeque};
4use alloc::string::{String, ToString};
5use alloc::vec::Vec;
6
7use broadcast_common::Unpackage;
8use broadcast_hls::{ByteRange, MapTag, MediaPlaylist, MediaSegment, OpenSegment, PreloadHintType};
9use transmux::{DemuxEvent, Fmp4Demux, Sample, StreamingTsDemux, TrackSpec};
10
11use super::action::{Action, BlockingReload, ResourceId};
12use super::error::{Error, Result};
13use super::output::Output;
14use super::url;
15
16/// First byte of every MPEG-2 TS packet (ITU-T H.222.0 / ISO/IEC 13818-1
17/// §2.4.3.2 `sync_byte`). Classic MPEG-TS-segment HLS (HLS v3, RFC 8216 —
18/// the dominant legacy/IPTV form) has no `EXT-X-MAP`/init segment at all:
19/// each `.ts` segment is a self-contained PAT/PMT/PES stream, so this byte
20/// is the only available signal to distinguish one from an fMP4/CMAF
21/// segment (which starts with an ISOBMFF box: `ftyp`/`styp`/`moof`) once the
22/// playlist itself has never advertised a Media Initialization Section.
23const TS_SYNC_BYTE: u8 = 0x47;
24
25/// Movie timescale of the init segment synthesized for classic TS-segment
26/// HLS: the 90 kHz MPEG-2 system clock the PES PTS/DTS are expressed in
27/// (ISO/IEC 13818-1 §2.4.3.7), the same clock `transmux`'s TS demuxer stamps
28/// its samples with.
29const TS_MOVIE_TIMESCALE: u32 = 90_000;
30
31/// How many Target Durations before the end of a live Playlist the first
32/// segment the client plays must start at the latest (RFC 8216 §6.3.3: "the
33/// client SHOULD NOT choose a segment that starts less than three target
34/// durations from the end of the Playlist file").
35const LIVE_EDGE_TARGET_DURATIONS: f64 = 3.0;
36
37/// [`StreamingTsDemux`] with a `Debug` impl (the transmux type has none), so
38/// [`HlsClient`] can keep deriving it.
39struct TsDemuxState(StreamingTsDemux);
40
41impl core::fmt::Debug for TsDemuxState {
42    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
43        f.write_str("TsDemuxState(StreamingTsDemux)")
44    }
45}
46
47/// `usize` index -> `u64` (lossless on every supported target; saturates
48/// rather than casting if a target ever had a wider `usize`).
49fn index_u64(i: usize) -> u64 {
50    u64::try_from(i).unwrap_or(u64::MAX)
51}
52
53/// The Media Sequence Number a tracked resource belongs to, `None` for the
54/// MSN-less init segment.
55fn resource_msn(id: &ResourceId) -> Option<u64> {
56    match id {
57        ResourceId::Part { msn, .. } | ResourceId::Segment { msn } => Some(*msn),
58        _ => None,
59    }
60}
61
62/// How a new playlist relates to the previous one (see
63/// [`HlsClient::continuity`]).
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65enum Continuity {
66    /// The same stream, moved on (or the same window again).
67    Continues,
68    /// The same stream, but an older view of it that still overlaps the
69    /// previous window.
70    Lagging,
71    /// The same stream, but a view wholly *older* than the previous window
72    /// (nothing in common): its segments are skipped, never delivered behind
73    /// what was already delivered.
74    OlderWindow,
75    /// A different stream: an origin restart.
76    Restart,
77}
78
79/// The `EXT-X-PROGRAM-DATE-TIME` value attached to `seg`, if any.
80fn program_date_time(seg: &MediaSegment) -> Option<&str> {
81    seg.pre_tags
82        .iter()
83        .find_map(|t| t.strip_prefix("#EXT-X-PROGRAM-DATE-TIME:"))
84}
85
86/// [`program_date_time`] as milliseconds since the Unix epoch.
87fn program_date_time_ms(seg: &MediaSegment) -> Option<i64> {
88    parse_rfc3339_ms(program_date_time(seg)?)
89}
90
91/// Parse an RFC 3339 / ISO 8601 date-time (`2024-05-01T12:00:00.250Z`,
92/// `...+02:00`, `...-0500`) into Unix milliseconds. `None` for anything that
93/// does not parse — never a guess. Delegates to [`jiff::Timestamp`] (an offset
94/// is required; a leap second is clamped to `:59`).
95fn parse_rfc3339_ms(text: &str) -> Option<i64> {
96    text.parse::<jiff::Timestamp>()
97        .ok()
98        .map(|t| t.as_millisecond())
99}
100
101/// A driveable, sans-IO Low-Latency HLS (RFC 8216bis) playback client.
102///
103/// `HlsClient` never touches a socket or a clock. The caller drives it:
104///
105/// 1. [`HlsClient::new`] seeds the first [`Action::FetchPlaylist`]; drain it
106///    with [`HlsClient::poll`] and perform the GET.
107/// 2. Feed the response back with [`HlsClient::on_playlist`] (playlist) or
108///    [`HlsClient::on_resource`] (init/part/segment bytes) —
109///    [`Action::FetchResource`]'s `id` correlates the two.
110/// 3. Drain [`HlsClient::poll`] again for the next round of actions (a new
111///    reload, newly discoverable parts, a preload-hint prefetch, ...) and
112///    [`HlsClient::next_output`] for newly available [`Output`]s.
113///
114/// # Behaviour
115///
116/// - **Reload scheduling** (issue #717 slice 2): once a playlist advertises
117///   `EXT-X-SERVER-CONTROL`/`EXT-X-PART-INF` **and** the origin's
118///   `CAN-BLOCK-RELOAD` attribute is `YES`
119///   ([`broadcast_hls::LowLatencyConfig::can_block_reload`] is `true` —
120///   *not* merely [`broadcast_hls::MediaPlaylist::low_latency`] being
121///   `Some`, since an origin may carry parts/PART-INF while still
122///   advertising `CAN-BLOCK-RELOAD=NO`), every reload is a Blocking
123///   Playlist Reload (RFC 8216bis §6.2.5.2) naming the next not-yet-seen
124///   Partial Segment's `_HLS_msn`/`_HLS_part`. Otherwise reloads are plain GETs
125///   paced by an [`Action::WaitMs`] hint derived from `#EXT-X-TARGETDURATION`.
126///   `EXT-X-SKIP`/`CAN-SKIP-UNTIL` Playlist Delta Updates (RFC 8216bis §4.4.5.2)
127///   are requested once a full-playlist baseline exists, and merged back into
128///   a full view before further processing — see `merge_delta` internally.
129/// - **Fetch pipeline** (slice 3): the `EXT-X-PRELOAD-HINT`ed part is fetched
130///   ahead of its own appearance as a numbered `EXT-X-PART`; `BYTERANGE`
131///   parts are supported, including the RFC 8216bis §4.4.4.9 "omitted offset
132///   means immediately after the previous sub-range of the same resource"
133///   rule (tracked per resource URL). The Media Initialization Section
134///   (`EXT-X-MAP`) is fetched once and reused for every following resource
135///   until the map changes.
136/// - **Dedup / coalescing**: once *any* of a segment's parts have been
137///   individually fetched, that segment is never re-fetched whole — when it
138///   later closes (`#EXTINF`+URI), the client only fetches whichever of its
139///   parts (if any) are still missing, and marks the segment "delivered" once
140///   every part is accounted for (fetched, or `GAP=YES`). A playlist whose
141///   segments carry **no** parts at all (a non-LL origin) falls back to
142///   fetching the whole segment resource — the two paths never overlap for a
143///   single segment, so a part's samples are never double-counted against its
144///   parent's.
145/// - **Output adapter** (slice 4): exactly one [`Output::Init`] precedes any
146///   [`Output::Samples`]; parts/segments are demuxed via
147///   [`transmux::Fmp4Demux`] (by concatenating the cached init bytes with the
148///   fetched resource — this crate never re-implements ISOBMFF box parsing,
149///   only reuses transmux's), so `Output::Samples` carries real access units,
150///   not opaque container bytes. `#EXT-X-DISCONTINUITY` on a segment surfaces
151///   as [`Output::Discontinuity`] immediately before that segment's first
152///   samples. **Known limitation**: an in-progress ([`OpenSegment`]) segment
153///   carries no discontinuity flag of its own (only a *closed*
154///   [`MediaSegment`] does) — if every part of a segment was already
155///   delivered while it was still open, a discontinuity revealed only once it
156///   closes is signalled late (after those parts' samples, not before). This
157///   is a gap in the current wire model ([`broadcast_hls::OpenSegment`]), not
158///   something this crate can fix locally.
159/// - **Classic MPEG-TS-segment HLS** (issue #760): a playlist that never
160///   advertises an `EXT-X-MAP` (HLS v3, the dominant legacy/IPTV form —
161///   self-contained `.ts` segments carrying their own PAT/PMT/PES, no
162///   separate init resource) routes each fetched Part/Segment through
163///   [`transmux::TsDemux`] instead, content-sniffed by the MPEG-TS sync byte
164///   rather than blocked on an init fetch that will never come. The first
165///   successfully demuxed segment's recovered
166///   [`TrackSpec`]s synthesize the one [`Output::Init`] this crate's contract
167///   requires (via [`transmux::build_init_segment`]) so downstream callers
168///   (e.g. `multimux`'s `HlsPull`, which recovers track specs from
169///   `Output::Init`) need no TS-specific handling of their own. The
170///   fMP4/CMAF plus LL (parts/preload-hint) path above is entirely
171///   unchanged; the two never overlap for a single playlist.
172#[derive(Debug)]
173pub struct HlsClient {
174    playlist_url: String,
175
176    pending_actions: VecDeque<Action>,
177    /// The caller-clock instant at which the `WaitMs` now at the front of
178    /// `pending_actions` was first observed by [`Self::poll_timeout`]; the
179    /// wait's absolute deadline is `anchor + wait`. Cleared whenever an action
180    /// is drained, so a later wait is anchored afresh.
181    #[cfg(feature = "std")]
182    wait_anchor: Option<std::time::Instant>,
183    pending_outputs: VecDeque<Output>,
184
185    init_uri: Option<String>,
186    init_bytes: Option<Vec<u8>>,
187    init_emitted: bool,
188    /// Part/Segment resources delivered before the init segment arrived —
189    /// buffered (in arrival order) and replayed once [`Self::init_bytes`] is
190    /// set, so the caller's fetch/response IO can complete in any order
191    /// (a real HTTP client has no reason to serialize on init-first).
192    pending_demux: VecDeque<(ResourceId, Vec<u8>)>,
193
194    requested: BTreeSet<ResourceId>,
195    /// Ids whose bytes `on_resource` has accepted (delivered *or* buffered in
196    /// [`Self::pending_demux`]) — the exactly-once guard against a second
197    /// delivery of the same id. Pruned with the other MSN-keyed sets.
198    fulfilled: BTreeSet<ResourceId>,
199    /// `true` once the first playlist has been processed (the live-edge join
200    /// of RFC 8216 §6.3.3 applies to that one only).
201    joined: bool,
202    /// Classic-TS-HLS demuxer kept across segments, so the 33-bit PTS/DTS
203    /// unwrap state and the PMT-derived track set survive a segment
204    /// boundary. Replaced by a fresh one at `EXT-X-DISCONTINUITY`.
205    ts_demux: Option<TsDemuxState>,
206    /// The track specs `ts_demux` has reported, by track id.
207    ts_specs: BTreeMap<u32, TrackSpec>,
208    /// The init segment last synthesized from `ts_specs`; a new
209    /// [`Output::Init`] goes out only when a re-synthesis differs from it.
210    last_ts_init: Option<Vec<u8>>,
211    delivered_parts: BTreeSet<(u64, u64)>,
212    delivered_segments: BTreeSet<u64>,
213    discontinuous_msns: BTreeSet<u64>,
214    discontinuity_emitted: BTreeSet<u64>,
215    byte_range_cursor: BTreeMap<String, u64>,
216
217    outstanding_fetches: u64,
218    saw_endlist: bool,
219    end_emitted: bool,
220    last_full_playlist: Option<MediaPlaylist>,
221    /// `EXT-X-MEDIA-SEQUENCE` of the previous playlist: RFC 8216 §6.2.2
222    /// forbids it ever decreasing, so a smaller one is an origin restart
223    /// (see [`Self::restart_after_regression`]).
224    last_media_sequence: Option<u64>,
225}
226
227impl HlsClient {
228    /// Create a new client for the Media Playlist at `playlist_url`, seeding
229    /// the first [`Action::FetchPlaylist`] (a plain, non-blocking GET — the
230    /// client does not yet know whether the origin supports blocking reload).
231    pub fn new(playlist_url: impl Into<String>) -> Self {
232        let playlist_url = playlist_url.into();
233        let mut pending_actions = VecDeque::new();
234        pending_actions.push_back(Action::FetchPlaylist {
235            url: playlist_url.clone(),
236            blocking: None,
237            skip: false,
238        });
239        Self {
240            playlist_url,
241            pending_actions,
242            #[cfg(feature = "std")]
243            wait_anchor: None,
244            pending_outputs: VecDeque::new(),
245            init_uri: None,
246            init_bytes: None,
247            init_emitted: false,
248            pending_demux: VecDeque::new(),
249            requested: BTreeSet::new(),
250            fulfilled: BTreeSet::new(),
251            joined: false,
252            ts_demux: None,
253            ts_specs: BTreeMap::new(),
254            last_ts_init: None,
255            delivered_parts: BTreeSet::new(),
256            delivered_segments: BTreeSet::new(),
257            discontinuous_msns: BTreeSet::new(),
258            discontinuity_emitted: BTreeSet::new(),
259            byte_range_cursor: BTreeMap::new(),
260            outstanding_fetches: 0,
261            saw_endlist: false,
262            end_emitted: false,
263            last_full_playlist: None,
264            last_media_sequence: None,
265        }
266    }
267
268    /// The Media Playlist URL this client is following.
269    pub fn playlist_url(&self) -> &str {
270        &self.playlist_url
271    }
272
273    /// Drain the next IO [`Action`] the caller must perform, if any.
274    pub fn poll(&mut self) -> Option<Action> {
275        #[cfg(feature = "std")]
276        {
277            self.wait_anchor = None;
278        }
279        self.pending_actions.pop_front()
280    }
281
282    /// The `WaitMs` hint at the front of the action queue, as a `Duration`;
283    /// `None` when the next queued action is a fetch (or nothing is queued). A
284    /// wait is queued BEHIND the fetches `on_playlist` queued with it, so it is
285    /// reported only once those have been drained with [`Self::poll`].
286    /// Peeking never consumes it, and the core never reads a clock.
287    pub fn next_wait(&self) -> Option<core::time::Duration> {
288        match self.pending_actions.front() {
289            Some(Action::WaitMs(ms)) => Some(core::time::Duration::from_millis(*ms)),
290            _ => None,
291        }
292    }
293
294    /// The absolute deadline of the queued `WaitMs`, for callers that schedule
295    /// on `std::time::Instant`: `None` unless a wait is at the front of the
296    /// queue (see [`Self::next_wait`]).
297    ///
298    /// The deadline is anchored the first time this is called for a given wait
299    /// (`now` + the wait) and every later call returns that SAME instant, until
300    /// the wait is drained with [`Self::poll`]. Re-querying after unrelated
301    /// wake-ups therefore never re-arms the timer. The core has no clock of its
302    /// own, so "when the wait was scheduled" is the caller's `now` at the first
303    /// query: query as soon as the wait reaches the front.
304    #[cfg(feature = "std")]
305    pub fn poll_timeout(&mut self, now: std::time::Instant) -> Option<std::time::Instant> {
306        let wait = self.next_wait()?;
307        let anchor = *self.wait_anchor.get_or_insert(now);
308        Some(anchor + wait)
309    }
310
311    /// Drain the next [`Output`] event, if any.
312    pub fn next_output(&mut self) -> Option<Output> {
313        self.pending_outputs.pop_front()
314    }
315
316    /// Feed a freshly fetched Media Playlist response.
317    ///
318    /// # Errors
319    /// [`Error::PlaylistNotUtf8`] / [`Error::PlaylistParse`] on malformed
320    /// input.
321    pub fn on_playlist(&mut self, bytes: &[u8]) -> Result<()> {
322        let text = core::str::from_utf8(bytes)?;
323        let playlist = MediaPlaylist::parse(text)?;
324        let playlist = self.merge_delta(playlist);
325        // An older view of the same stream must never make the client deliver
326        // backwards: its segments below the previous window are skipped.
327        let mut lag_floor: Option<u64> = None;
328        if self.last_media_sequence.is_some() {
329            match self.continuity(&playlist) {
330                Continuity::Restart => self.restart_after_regression()?,
331                Continuity::OlderWindow => {
332                    lag_floor = self.last_full_playlist.as_ref().map(|p| p.media_sequence);
333                }
334                Continuity::Lagging | Continuity::Continues => {}
335            }
336        }
337        // A lagging copy never lowers the high-water mark.
338        self.last_media_sequence = Some(
339            self.last_media_sequence
340                .map_or(playlist.media_sequence, |p| p.max(playlist.media_sequence)),
341        );
342
343        // `EXT-X-MEDIA-SEQUENCE` is an unbounded remote `u64`: every MSN
344        // derived below is `media_sequence + index` with `index <
345        // segments.len()`, so proving the end fits proves them all.
346        let next_msn = playlist
347            .media_sequence
348            .checked_add(index_u64(playlist.segments.len()))
349            .ok_or(Error::MediaSequenceOverflow {
350                media_sequence: playlist.media_sequence,
351                segments: playlist.segments.len(),
352            })?;
353
354        if !self.joined {
355            self.joined = true;
356            self.join_start(&playlist)?;
357        }
358
359        for (i, seg) in playlist.segments.iter().enumerate() {
360            let msn = playlist.media_sequence.saturating_add(index_u64(i));
361            if lag_floor.is_some_and(|floor| msn < floor) {
362                self.skip_segment(msn, seg)?;
363                continue;
364            }
365            self.process_closed_segment(msn, seg)?;
366        }
367
368        if let Some(open) = &playlist.open_segment {
369            self.process_open_segment(next_msn, open)?;
370        }
371
372        // Prefer the *open* segment's map when present: it's the most
373        // recent (`#EXT-X-MAP` carries forward, so the open segment's view
374        // is never older than the last closed segment's) and, crucially, is
375        // the only way to learn the init segment's URI at all when NO
376        // segment has closed yet (issue #717 slice 5 fix — previously this
377        // only ever looked at the last *closed* segment's map, so a client
378        // tuning into a stream mid-segment couldn't fetch the init segment,
379        // and therefore couldn't demux any of that segment's parts, until
380        // it closed — needlessly inflating glass-to-glass latency by up to
381        // a full segment duration on every fresh connection).
382        let map = playlist
383            .open_segment
384            .as_ref()
385            .and_then(|o| o.map.as_ref())
386            .or_else(|| playlist.segments.last().and_then(|s| s.map.as_ref()));
387        if let Some(map) = map {
388            self.ensure_init_requested(map)?;
389        }
390
391        if let Some(ll) = &playlist.low_latency
392            && let Some(hint_uri) = &ll.preload_hint_part
393        {
394            match ll.preload_hint_type {
395                PreloadHintType::Part => {
396                    let part_idx = playlist
397                        .open_segment
398                        .as_ref()
399                        .map(|o| index_u64(o.parts.len()))
400                        .unwrap_or(0);
401                    let id = ResourceId::Part {
402                        msn: next_msn,
403                        part: part_idx,
404                    };
405                    let url = url::resolve(&self.playlist_url, hint_uri);
406                    let byte_range = self.resolve_hint_byte_range(&url, ll)?;
407                    self.request_resource(id, url, byte_range);
408                }
409                PreloadHintType::Map => {
410                    let map = MapTag {
411                        uri: hint_uri.clone(),
412                        byte_range: ll.preload_hint_byte_range_length.map(|length| ByteRange {
413                            length,
414                            offset: ll.preload_hint_byte_range_start,
415                        }),
416                        extra_attrs: Vec::new(),
417                    };
418                    self.ensure_init_requested(&map)?;
419                }
420                _ => {
421                    // RFC 8216bis §4.4.5.3 defines only PART/MAP today; a
422                    // future hint type from a newer transmux is simply not
423                    // prefetched rather than treated as an error
424                    // (`PreloadHintType` is `#[non_exhaustive]`).
425                }
426            }
427        }
428
429        if playlist.endlist {
430            self.saw_endlist = true;
431        } else {
432            // Issue #717 slice 1 fix: block only when the origin actually
433            // advertises `CAN-BLOCK-RELOAD=YES` — `low_latency.is_some()`
434            // alone is not enough (an origin sending `CAN-BLOCK-RELOAD=NO`
435            // still carries parts/PART-INF, e.g. while ramping up support).
436            let blocking = playlist
437                .low_latency
438                .as_ref()
439                .filter(|ll| ll.can_block_reload)
440                .map(|_| {
441                    let part = playlist
442                        .open_segment
443                        .as_ref()
444                        .map(|o| index_u64(o.parts.len()))
445                        .unwrap_or(0);
446                    BlockingReload {
447                        msn: next_msn,
448                        part: Some(part),
449                    }
450                });
451            let can_skip = playlist
452                .low_latency
453                .as_ref()
454                .and_then(|ll| ll.can_skip_until)
455                .is_some();
456            let skip = can_skip && self.last_full_playlist.is_some();
457            self.pending_actions.push_back(Action::FetchPlaylist {
458                url: self.playlist_url.clone(),
459                blocking,
460                skip,
461            });
462            if blocking.is_none() {
463                // RFC 8216 §4.3.3.1: a client SHOULD NOT reload more
464                // frequently than once per Target Duration; half that as a
465                // reasonable non-blocking poll cadence.
466                let wait_ms = (u64::from(playlist.target_duration.max(1)) * 1000) / 2;
467                self.pending_actions.push_back(Action::WaitMs(wait_ms));
468            }
469        }
470
471        self.prune(&playlist);
472        if playlist.skip.is_none() {
473            self.last_full_playlist = Some(playlist);
474        }
475
476        self.maybe_emit_end_of_stream()?;
477        Ok(())
478    }
479
480    /// Feed the bytes fetched for a previously requested [`ResourceId`]
481    /// (`init`/part/segment). Part/Segment resources delivered before the
482    /// init segment are buffered internally and demuxed once the init
483    /// arrives — the caller's fetches may complete in any order.
484    ///
485    /// # Errors
486    /// [`Error::UnrequestedResource`] if `id` was never requested (the
487    /// `requested` bookkeeping — or, for `Init`, `init_uri` — has no record
488    /// of it): a caller/driver bug, or a stale/duplicate delivery after the
489    /// client already moved past this id.
490    /// [`Error::Demux`] if `transmux::Fmp4Demux` rejects the concatenation of
491    /// the cached init + `bytes`.
492    pub fn on_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
493        let was_requested = match id {
494            ResourceId::Init => self.init_uri.is_some(),
495            ResourceId::Part { .. } | ResourceId::Segment { .. } => self.requested.contains(&id),
496        };
497        if !was_requested {
498            return Err(Error::UnrequestedResource { id });
499        }
500        let duplicate = match id {
501            ResourceId::Init => self.init_bytes.is_some(),
502            ResourceId::Part { .. } | ResourceId::Segment { .. } => self.fulfilled.contains(&id),
503        };
504        if duplicate {
505            return Err(Error::DuplicateResource { id });
506        }
507        self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
508        if id != ResourceId::Init {
509            self.fulfilled.insert(id);
510        }
511        match id {
512            ResourceId::Init => {
513                self.init_bytes = Some(bytes.to_vec());
514                if !self.init_emitted {
515                    self.pending_outputs.push_back(Output::Init(bytes.to_vec()));
516                    self.init_emitted = true;
517                }
518                let buffered: Vec<_> = self.pending_demux.drain(..).collect();
519                for (bid, bbytes) in buffered {
520                    if let Err(e) = self.finish_media_resource(bid, &bbytes) {
521                        self.fulfilled.remove(&bid);
522                        return Err(e);
523                    }
524                }
525            }
526            ResourceId::Part { .. } | ResourceId::Segment { .. } => {
527                let processed = if self.is_ts_segment(bytes) {
528                    // Classic MPEG-TS-segment HLS (issue #760): no init
529                    // resource will ever arrive for this playlist, so demux
530                    // this self-contained TS segment straight away rather
531                    // than buffering it forever waiting for one.
532                    self.finish_ts_resource(id, bytes)
533                } else if self.init_bytes.is_none() {
534                    self.pending_demux.push_back((id, bytes.to_vec()));
535                    Ok(())
536                } else {
537                    self.finish_media_resource(id, bytes)
538                };
539                if let Err(e) = processed {
540                    // Nothing was delivered: leave the id free for a retry.
541                    self.fulfilled.remove(&id);
542                    return Err(e);
543                }
544            }
545        }
546        self.maybe_emit_end_of_stream()?;
547        Ok(())
548    }
549
550    /// Demux + emit + mark-delivered for a Part/Segment resource, once the
551    /// init segment is known to be available.
552    fn finish_media_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
553        match id {
554            ResourceId::Part { msn, part } => {
555                self.emit_discontinuity_if_needed(msn);
556                self.demux_and_emit(id, bytes)?;
557                self.delivered_parts.insert((msn, part));
558            }
559            ResourceId::Segment { msn } => {
560                self.emit_discontinuity_if_needed(msn);
561                self.demux_and_emit(id, bytes)?;
562                self.delivered_segments.insert(msn);
563            }
564            ResourceId::Init => {}
565        }
566        Ok(())
567    }
568
569    /// The classic-TS-HLS counterpart to [`Self::finish_media_resource`]:
570    /// demux + emit + mark-delivered for a self-contained MPEG-TS Part/
571    /// Segment resource — never buffered pending an init fetch, since
572    /// [`Self::is_ts_segment`] only routes here once this playlist is known
573    /// to advertise no `EXT-X-MAP` at all.
574    fn finish_ts_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
575        match id {
576            ResourceId::Part { msn, part } => {
577                self.begin_ts_resource(id, msn)?;
578                self.demux_and_emit_ts(id, bytes)?;
579                self.delivered_parts.insert((msn, part));
580            }
581            ResourceId::Segment { msn } => {
582                self.begin_ts_resource(id, msn)?;
583                self.demux_and_emit_ts(id, bytes)?;
584                self.delivered_segments.insert(msn);
585            }
586            ResourceId::Init => {}
587        }
588        Ok(())
589    }
590
591    /// `true` when `bytes` should be routed to [`Self::finish_ts_resource`]
592    /// (classic MPEG-TS-segment HLS, issue #760) rather than the fMP4/CMAF
593    /// path: this playlist has never advertised an `EXT-X-MAP` (no init
594    /// fetch is outstanding or cached — [`Self::init_uri`] is `None`; by the
595    /// time any Part/Segment fetch response reaches [`Self::on_resource`],
596    /// [`Self::on_playlist`] has already fully processed the playlist that
597    /// requested it, including any map it carries, so this check is never
598    /// stale) **and** `bytes` starts with the MPEG-TS sync byte — an
599    /// fMP4/CMAF resource always starts with an ISOBMFF box
600    /// (`ftyp`/`styp`/`moof`), never [`TS_SYNC_BYTE`].
601    fn is_ts_segment(&self, bytes: &[u8]) -> bool {
602        self.init_uri.is_none() && bytes.first() == Some(&TS_SYNC_BYTE)
603    }
604
605    /// Report that a previously requested [`ResourceId`] (or the playlist
606    /// itself, via [`None`]) failed. Clears the id's "requested" bookkeeping
607    /// so the next [`Self::on_playlist`] call naturally re-requests it (no
608    /// automatic retry timer — the caller drives retry cadence).
609    pub fn on_error(&mut self, id: Option<ResourceId>) {
610        if let Some(id) = id {
611            self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
612            match id {
613                ResourceId::Init => self.init_uri = None,
614                other => {
615                    self.requested.remove(&other);
616                }
617            }
618        }
619        // `on_error` has no way to report: a failure here can only mean the
620        // held-back final TS access units could not be flushed, and the
621        // stream still ends.
622        let _ = self.maybe_emit_end_of_stream();
623    }
624
625    // -- internals ------------------------------------------------------
626
627    /// Reconstruct a full playlist view from an `EXT-X-SKIP` delta update
628    /// (RFC 8216bis §4.4.5.2), by splicing the skipped prefix back in from
629    /// the last full playlist this client observed. Best-effort: if there is
630    /// no cached baseline, or it doesn't cover the skipped range, the delta
631    /// is returned as-is (never an error — "at least don't break").
632    fn merge_delta(&self, playlist: MediaPlaylist) -> MediaPlaylist {
633        let Some(skip) = &playlist.skip else {
634            return playlist;
635        };
636        if skip.skipped_segments == 0 {
637            return playlist;
638        }
639        let Some(prev) = &self.last_full_playlist else {
640            return playlist;
641        };
642        if playlist.media_sequence < prev.media_sequence {
643            return playlist;
644        }
645        let Ok(prefix_start) = usize::try_from(playlist.media_sequence - prev.media_sequence)
646        else {
647            return playlist;
648        };
649        // `skip.skipped_segments` (`EXT-X-SKIP`'s `SKIPPED-SEGMENTS`,
650        // RFC 8216bis §4.4.5.2) is untrusted `u64` straight from the remote
651        // origin's playlist text, with no upper bound enforced by
652        // `broadcast_hls::MediaPlaylist::parse`. `usize::try_from` +
653        // `checked_add` guard both the u64->usize narrowing and the
654        // addition itself, so an adversarial/corrupt value falls through to
655        // the same "can't merge, return the delta as-is" fallback as every
656        // other guard in this function rather than panicking (debug) or
657        // wrapping to a bogus, silently-wrong slice bound (release).
658        let prefix_end = usize::try_from(skip.skipped_segments)
659            .ok()
660            .and_then(|skipped| prefix_start.checked_add(skipped));
661        let Some(prefix) = prefix_end.and_then(|end| prev.segments.get(prefix_start..end)) else {
662            return playlist;
663        };
664        let mut merged = playlist;
665        let mut segments = prefix.to_vec();
666        segments.extend(merged.segments);
667        merged.segments = segments;
668        merged
669    }
670
671    fn process_closed_segment(&mut self, msn: u64, seg: &MediaSegment) -> Result<()> {
672        if seg.discontinuous {
673            self.discontinuous_msns.insert(msn);
674        }
675        if self.delivered_segments.contains(&msn) {
676            return Ok(());
677        }
678        if seg.parts.is_empty() {
679            // Either a genuinely non-LL segment (never had parts), OR an LL
680            // segment whose parts were already fetched individually while it
681            // was still open and whose *closed* rendering simply omits them
682            // — RFC 8216bis does not require a closed segment to keep
683            // listing `#EXT-X-PART` lines, and real origins commonly don't
684            // (e.g. `multimux`'s: `MediaSegment.parts` is always empty for a
685            // closed segment; only the still-open segment carries parts).
686            // Detect the latter via `delivered_parts`: if any part for this
687            // `msn` was ever delivered, every one of its non-`GAP` parts was
688            // already requested while it was open (`process_open_segment`
689            // requests every known part each time it's polled, so by the
690            // time the segment closes none can have been missed) — fetching
691            // the whole segment *as well* would demux and emit its samples a
692            // second time. Caught by `hls-runtime/tests/glass_to_glass.rs`
693            // (issue #717 slice 5): every sample was double-delivered for
694            // the first two segments of a real, live-paced run.
695            let already_have_parts = self
696                .delivered_parts
697                .range((msn, 0)..=(msn, u64::MAX))
698                .next()
699                .is_some();
700            if already_have_parts {
701                self.delivered_segments.insert(msn);
702                return Ok(());
703            }
704            let id = ResourceId::Segment { msn };
705            if !self.requested.contains(&id) {
706                let url = url::resolve(&self.playlist_url, &seg.uri);
707                let byte_range = self.resolve_byte_range(&url, &seg.byte_range)?;
708                self.request_resource(id, url, byte_range);
709            }
710            return Ok(());
711        }
712
713        let mut fully_accounted = true;
714        for (i, part) in seg.parts.iter().enumerate() {
715            let i = index_u64(i);
716            if part.gap || self.delivered_parts.contains(&(msn, i)) {
717                continue;
718            }
719            fully_accounted = false;
720            let id = ResourceId::Part { msn, part: i };
721            if !self.requested.contains(&id) {
722                let url = url::resolve(&self.playlist_url, &part.uri);
723                let byte_range = self.resolve_byte_range(&url, &part.byte_range)?;
724                self.request_resource(id, url, byte_range);
725            }
726        }
727        if fully_accounted {
728            self.delivered_segments.insert(msn);
729        }
730        Ok(())
731    }
732
733    fn process_open_segment(&mut self, msn: u64, open: &OpenSegment) -> Result<()> {
734        for (i, part) in open.parts.iter().enumerate() {
735            let i = index_u64(i);
736            if part.gap || self.delivered_parts.contains(&(msn, i)) {
737                continue;
738            }
739            let id = ResourceId::Part { msn, part: i };
740            if !self.requested.contains(&id) {
741                let url = url::resolve(&self.playlist_url, &part.uri);
742                let byte_range = self.resolve_byte_range(&url, &part.byte_range)?;
743                self.request_resource(id, url, byte_range);
744            }
745        }
746        Ok(())
747    }
748
749    fn ensure_init_requested(&mut self, map: &MapTag) -> Result<()> {
750        let url = url::resolve(&self.playlist_url, &map.uri);
751        if self.init_uri.as_deref() == Some(url.as_str()) {
752            return Ok(());
753        }
754        self.init_uri = Some(url.clone());
755        self.init_bytes = None;
756        self.init_emitted = false;
757        let byte_range = self.resolve_byte_range(&url, &map.byte_range)?;
758        self.pending_actions.push_back(Action::FetchResource {
759            id: ResourceId::Init,
760            url,
761            byte_range,
762        });
763        self.outstanding_fetches += 1;
764        Ok(())
765    }
766
767    fn request_resource(&mut self, id: ResourceId, url: String, byte_range: Option<(u64, u64)>) {
768        self.requested.insert(id);
769        self.outstanding_fetches += 1;
770        self.pending_actions.push_back(Action::FetchResource {
771            id,
772            url,
773            byte_range,
774        });
775    }
776
777    /// Resolve a `PartSpec`/`MediaSegment`/`MapTag` `BYTERANGE` into an
778    /// absolute `(offset, length)`, honouring the "omitted offset continues
779    /// the previous sub-range of the same resource" rule (tracked per
780    /// resolved URL).
781    ///
782    /// # Errors
783    /// [`Error::ByteRangeOverflow`] if `offset + length` (both taken
784    /// straight from the untrusted remote playlist) overflows `u64` — see
785    /// that variant's doc for why this is rejected rather than saturated.
786    fn resolve_byte_range(
787        &mut self,
788        url: &str,
789        br: &Option<ByteRange>,
790    ) -> Result<Option<(u64, u64)>> {
791        let Some(br) = br.as_ref() else {
792            return Ok(None);
793        };
794        let offset = br
795            .offset
796            .unwrap_or_else(|| *self.byte_range_cursor.get(url).unwrap_or(&0));
797        let next_cursor =
798            offset
799                .checked_add(br.length)
800                .ok_or_else(|| Error::ByteRangeOverflow {
801                    url: url.to_string(),
802                    offset,
803                    length: br.length,
804                })?;
805        self.byte_range_cursor.insert(url.to_string(), next_cursor);
806        Ok(Some((offset, br.length)))
807    }
808
809    /// Same overflow contract as [`Self::resolve_byte_range`] — see
810    /// [`Error::ByteRangeOverflow`].
811    fn resolve_hint_byte_range(
812        &mut self,
813        url: &str,
814        ll: &broadcast_hls::LowLatencyConfig,
815    ) -> Result<Option<(u64, u64)>> {
816        let Some(length) = ll.preload_hint_byte_range_length else {
817            return Ok(None);
818        };
819        let br = ByteRange {
820            length,
821            offset: ll.preload_hint_byte_range_start,
822        };
823        self.resolve_byte_range(url, &Some(br))
824    }
825
826    fn demux_and_emit(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
827        let init = self
828            .init_bytes
829            .as_ref()
830            .ok_or(Error::InitNotYetAvailable { id })?;
831        let mut combined = Vec::with_capacity(init.len() + bytes.len());
832        combined.extend_from_slice(init);
833        combined.extend_from_slice(bytes);
834        let mut demux = Fmp4Demux::new();
835        let media = demux
836            .unpackage(combined.as_slice())
837            .map_err(|source| Error::Demux { id, source })?;
838        for track in media.tracks {
839            if !track.samples.is_empty() {
840                self.pending_outputs.push_back(Output::Samples {
841                    track_id: track.spec.track_id,
842                    samples: track.samples,
843                });
844            }
845        }
846        Ok(())
847    }
848
849    /// Emit the pending `EXT-X-DISCONTINUITY` for `msn` and, when one fires,
850    /// start the TS demuxer afresh: the timestamps after a discontinuity are
851    /// a new timeline (RFC 8216 §4.3.4.3), so the previous 33-bit unwrap
852    /// anchor and track set must not carry across it. The old timeline's
853    /// held-back final access units are flushed *before* the marker.
854    fn begin_ts_resource(&mut self, id: ResourceId, msn: u64) -> Result<()> {
855        if self.discontinuous_msns.contains(&msn) && !self.discontinuity_emitted.contains(&msn) {
856            self.flush_ts_demux(id)?;
857            self.emit_discontinuity_if_needed(msn);
858            self.ts_demux = None;
859            self.ts_specs.clear();
860        }
861        Ok(())
862    }
863
864    /// The classic-TS-HLS counterpart to [`Self::demux_and_emit`]: demux a
865    /// self-contained MPEG-TS Part/Segment resource through the one
866    /// [`StreamingTsDemux`] this client keeps across segments (no init bytes
867    /// to concatenate — each `.ts` segment carries its own PAT/PMT/PES), so
868    /// the 33-bit PTS/DTS wrap (ISO/IEC 13818-1 §2.4.3.7, every ~26.5 h) is
869    /// unrolled across segment boundaries instead of restarting per segment.
870    ///
871    /// The demuxer is **not** finished per segment: it resolves each video
872    /// sample's duration from the next access unit's timestamp, so a
873    /// segment's final access unit per stream is held back and emitted once
874    /// the next segment supplies its real duration (or, at
875    /// `EXT-X-ENDLIST`/`EXT-X-DISCONTINUITY`, flushed with the stream's last
876    /// known duration). Samples therefore stay in decode order with true
877    /// durations, one access unit per stream later than a per-segment flush.
878    ///
879    /// The [`Output::Init`] the crate's output contract requires is
880    /// synthesized from the demuxer's track set via
881    /// [`transmux::build_init_segment`] — a real `ftyp`+fragmented-`moov`,
882    /// byte-for-byte demuxable by `transmux::Fmp4Demux` like any other init
883    /// segment, so callers built against the fMP4 path (e.g. `multimux`'s
884    /// `HlsPull`) need no TS-specific handling. It is emitted before the
885    /// first samples and again whenever the track set (a new PID, a codec
886    /// change) makes the re-synthesized init differ from the last one.
887    fn demux_and_emit_ts(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
888        let TsDemuxState(demux) = self
889            .ts_demux
890            .get_or_insert_with(|| TsDemuxState(StreamingTsDemux::new()));
891        demux.feed(bytes);
892        self.drain_ts_demux(id)
893    }
894
895    /// Flush the TS demuxer's held-back access units (no more input on this
896    /// timeline) and emit them.
897    fn flush_ts_demux(&mut self, id: ResourceId) -> Result<()> {
898        let Some(TsDemuxState(demux)) = self.ts_demux.as_mut() else {
899            return Ok(());
900        };
901        demux.finish();
902        self.drain_ts_demux(id)
903    }
904
905    /// Turn the TS demuxer's pending events into [`Output`]s: the track set
906    /// (re-synthesizing the init when it changed), then the samples per track.
907    fn drain_ts_demux(&mut self, id: ResourceId) -> Result<()> {
908        let Some(TsDemuxState(demux)) = self.ts_demux.as_mut() else {
909            return Ok(());
910        };
911        let mut per_track: BTreeMap<u32, Vec<Sample>> = BTreeMap::new();
912        while let Some(event) = demux.poll_event() {
913            match event {
914                DemuxEvent::TrackAdded(spec) | DemuxEvent::TrackUpdated(spec) => {
915                    self.ts_specs.insert(spec.track_id, spec);
916                }
917                DemuxEvent::TrackRemoved { track_id, .. } => {
918                    self.ts_specs.remove(&track_id);
919                }
920                DemuxEvent::Sample {
921                    track_id, sample, ..
922                } => {
923                    per_track.entry(track_id).or_default().push(sample);
924                }
925                _ => {}
926            }
927        }
928        if !self.ts_specs.is_empty() {
929            let specs: Vec<TrackSpec> = self.ts_specs.values().cloned().collect();
930            let init_bytes = transmux::build_init_segment(&specs, TS_MOVIE_TIMESCALE)
931                .map_err(|source| Error::Demux { id, source })?;
932            if self.last_ts_init.as_deref() != Some(init_bytes.as_slice()) {
933                self.pending_outputs
934                    .push_back(Output::Init(init_bytes.clone()));
935                self.last_ts_init = Some(init_bytes);
936                self.init_emitted = true;
937            }
938        }
939        for (track_id, samples) in per_track {
940            if !samples.is_empty() {
941                self.pending_outputs
942                    .push_back(Output::Samples { track_id, samples });
943            }
944        }
945        Ok(())
946    }
947
948    /// Emit [`Output::Discontinuity`] once for a discontinuous `msn`;
949    /// `true` when it was emitted by this call.
950    fn emit_discontinuity_if_needed(&mut self, msn: u64) -> bool {
951        if self.discontinuous_msns.contains(&msn) && !self.discontinuity_emitted.contains(&msn) {
952            self.pending_outputs.push_back(Output::Discontinuity);
953            self.discontinuity_emitted.insert(msn);
954            return true;
955        }
956        false
957    }
958
959    /// Choose the first segment to play on the first playlist load and mark
960    /// every closed segment before it as already delivered, so the join does
961    /// not replay (or, for a long EVENT/DVR window, download) the history.
962    ///
963    /// - A live playlist (no `EXT-X-ENDLIST`) is not joined closer to its end
964    ///   than the server's hold-back (RFC 8216 §6.3.3, RFC 8216bis §6.3.3):
965    ///   `PART-HOLD-BACK` when the client plays in Low-Latency Mode
966    ///   (`CAN-BLOCK-RELOAD=YES`), else the larger of `HOLD-BACK` and three
967    ///   Target Durations (§4.4.3.8). The parts of the open segment count
968    ///   towards the distance from the end.
969    /// - `EXT-X-START` (§4.4.2.2) is honoured at segment granularity — a
970    ///   positive `TIME-OFFSET` counts from the start, a negative one from the
971    ///   end of the last segment; a live playlist never starts later than the
972    ///   hold-back allows. `PRECISE` does not apply: segments are delivered
973    ///   whole.
974    /// - A VOD playlist without `EXT-X-START` is played from its start.
975    ///
976    /// A segment whose `EXTINF` is zero counts as one Target Duration, so a
977    /// degenerate window still joins near its end. The byte-range cursor is
978    /// advanced over every skipped segment, so a later omitted-offset
979    /// `EXT-X-BYTERANGE` on the same resource continues from the right offset
980    /// (RFC 8216 §4.3.2.2).
981    ///
982    /// # Errors
983    /// [`Error::ByteRangeOverflow`] from the skipped segments' ranges.
984    fn join_start(&mut self, playlist: &MediaPlaylist) -> Result<()> {
985        let segments = &playlist.segments;
986        let target = f64::from(playlist.target_duration.max(1));
987        let duration_of = |seg: &MediaSegment| {
988            let d = seg.duration.get();
989            if d > 0.0 { d } else { target }
990        };
991
992        let from_start_tag = playlist.start.as_ref().map(|start| {
993            let total: f64 = segments.iter().map(duration_of).sum();
994            let offset = start.time_offset.get();
995            let position = if offset >= 0.0 {
996                offset
997            } else {
998                total + offset
999            };
1000            let position = position.clamp(0.0, total);
1001            let mut elapsed = 0.0_f64;
1002            for (i, seg) in segments.iter().enumerate() {
1003                elapsed += duration_of(seg);
1004                if elapsed > position {
1005                    return i;
1006                }
1007            }
1008            // At (or past) the end: the last segment.
1009            segments.len().saturating_sub(1)
1010        });
1011
1012        let from_hold_back = (!playlist.endlist).then(|| {
1013            let ll = playlist.low_latency.as_ref();
1014            let wanted = match ll.filter(|ll| ll.can_block_reload) {
1015                Some(ll) if ll.part_hold_back.is_some() => {
1016                    ll.part_hold_back.map_or(0.0, |p| p.get())
1017                }
1018                _ => {
1019                    let hold_back = ll.and_then(|ll| ll.hold_back).map_or(0.0, |h| h.get());
1020                    (LIVE_EDGE_TARGET_DURATIONS * target).max(hold_back)
1021                }
1022            };
1023            let mut behind: f64 = playlist
1024                .open_segment
1025                .as_ref()
1026                .map_or(0.0, |o| o.parts.iter().map(|p| p.duration.get()).sum());
1027            if behind >= wanted {
1028                return segments.len();
1029            }
1030            for (i, seg) in segments.iter().enumerate().rev() {
1031                behind += duration_of(seg);
1032                if behind >= wanted {
1033                    return i;
1034                }
1035            }
1036            0
1037        });
1038
1039        let start = match (from_start_tag, from_hold_back) {
1040            (Some(s), Some(h)) => s.min(h),
1041            (Some(s), None) => s,
1042            (None, Some(h)) => h,
1043            (None, None) => 0,
1044        };
1045        for (i, seg) in segments.iter().enumerate().take(start) {
1046            let msn = playlist.media_sequence.saturating_add(index_u64(i));
1047            self.skip_segment(msn, seg)?;
1048        }
1049        Ok(())
1050    }
1051
1052    /// Mark a segment skipped at join time as delivered, advancing the
1053    /// byte-range cursor exactly as fetching it would have (a segment with
1054    /// parts is addressed by its parts' ranges, a plain one by its own).
1055    fn skip_segment(&mut self, msn: u64, seg: &MediaSegment) -> Result<()> {
1056        if seg.parts.is_empty() {
1057            let url = url::resolve(&self.playlist_url, &seg.uri);
1058            self.resolve_byte_range(&url, &seg.byte_range)?;
1059        } else {
1060            for part in seg.parts.iter().filter(|p| !p.gap) {
1061                let url = url::resolve(&self.playlist_url, &part.uri);
1062                self.resolve_byte_range(&url, &part.byte_range)?;
1063            }
1064        }
1065        self.delivered_segments.insert(msn);
1066        Ok(())
1067    }
1068
1069    /// Drop every MSN-keyed record below the playlist's first segment (it
1070    /// can never be referenced again, and a long-lived pull would otherwise
1071    /// grow these sets without bound), and — for a full playlist — every
1072    /// byte-range cursor whose URL the playlist no longer references.
1073    /// Resources still in flight (requested, not yet fulfilled) are kept so
1074    /// their delivery is still accepted.
1075    fn prune(&mut self, playlist: &MediaPlaylist) {
1076        let first = playlist.media_sequence;
1077        let below = |id: &ResourceId| resource_msn(id).is_some_and(|m| m < first);
1078        let fulfilled = &self.fulfilled;
1079        self.requested
1080            .retain(|id| !(below(id) && fulfilled.contains(id)));
1081        self.fulfilled.retain(|id| !below(id));
1082        self.delivered_parts.retain(|&(m, _)| m >= first);
1083        self.delivered_segments.retain(|&m| m >= first);
1084        self.discontinuous_msns.retain(|&m| m >= first);
1085        self.discontinuity_emitted.retain(|&m| m >= first);
1086
1087        if playlist.skip.is_none() {
1088            let base = &self.playlist_url;
1089            let mut live: BTreeSet<String> = BTreeSet::new();
1090            for seg in &playlist.segments {
1091                live.insert(url::resolve(base, &seg.uri));
1092                for part in &seg.parts {
1093                    live.insert(url::resolve(base, &part.uri));
1094                }
1095                if let Some(map) = &seg.map {
1096                    live.insert(url::resolve(base, &map.uri));
1097                }
1098            }
1099            if let Some(open) = &playlist.open_segment {
1100                for part in &open.parts {
1101                    live.insert(url::resolve(base, &part.uri));
1102                }
1103                if let Some(map) = &open.map {
1104                    live.insert(url::resolve(base, &map.uri));
1105                }
1106            }
1107            if let Some(hint) = playlist
1108                .low_latency
1109                .as_ref()
1110                .and_then(|ll| ll.preload_hint_part.as_ref())
1111            {
1112                live.insert(url::resolve(base, hint));
1113            }
1114            self.byte_range_cursor.retain(|u, _| live.contains(u));
1115        }
1116    }
1117
1118    /// Decide whether `new` continues the stream the previous full playlist
1119    /// described, is merely an *older* copy of it (a CDN edge a poll — or a
1120    /// whole window — behind another), or is a different stream (an origin
1121    /// restart; audit r09-C3, issue #1031).
1122    ///
1123    /// Signals, strongest first:
1124    ///
1125    /// 1. **Segment identity at a shared number** — a media sequence number
1126    ///    listed by both windows must name the same segment (URI, duration,
1127    ///    byte range) and, when both carry `EXT-X-PROGRAM-DATE-TIME` for it,
1128    ///    the same wall-clock time. A mismatch is a restart *whatever the
1129    ///    direction of the numbers* — an origin that restarts to an equal or
1130    ///    higher number is caught here.
1131    /// 2. **`EXT-X-DISCONTINUITY-SEQUENCE`** may never decrease while the
1132    ///    media sequence number does not (RFC 8216 §6.2.2): a decrease at an
1133    ///    equal or higher number is a restart.
1134    /// 3. When the media sequence number *went backwards* with **no** shared
1135    ///    number (the new window is wholly older, or a restart):
1136    ///    - a URI that the previous window listed under a *different* number
1137    ///      is a restart (names reused under new numbering);
1138    ///    - else, with `PROGRAM-DATE-TIME` on both sides, a new window that
1139    ///      ends before the previous one began is a wholly older copy
1140    ///      ([`Continuity::OlderWindow`], skipped), anything else a restart;
1141    ///    - else nothing distinguishes them and it is treated as a restart.
1142    ///
1143    /// **Residual, undetectable cases** (documented, not guessed at): a CDN
1144    /// edge lagging by a whole window behind an origin that carries *no*
1145    /// `PROGRAM-DATE-TIME` and whose segment names are all new reads as a
1146    /// restart (one spurious `Discontinuity` and a re-join); and an origin
1147    /// restarting to a *higher*, non-overlapping number with consistent
1148    /// discontinuity sequence and no `PROGRAM-DATE-TIME` reads as the client
1149    /// having fallen behind.
1150    fn continuity(&self, new: &MediaPlaylist) -> Continuity {
1151        let Some(prev) = &self.last_full_playlist else {
1152            return if self
1153                .last_media_sequence
1154                .is_some_and(|p| new.media_sequence < p)
1155            {
1156                Continuity::Restart
1157            } else {
1158                Continuity::Continues
1159            };
1160        };
1161        let (p0, n0) = (prev.media_sequence, new.media_sequence);
1162        let regressed = n0 < self.last_media_sequence.unwrap_or(p0).max(p0);
1163
1164        // 1. Identity at every shared number.
1165        let mut overlap = false;
1166        for (i, seg) in new.segments.iter().enumerate() {
1167            let Some(msn) = n0.checked_add(index_u64(i)) else {
1168                return Continuity::Restart;
1169            };
1170            let Some(old) = msn
1171                .checked_sub(p0)
1172                .and_then(|d| usize::try_from(d).ok())
1173                .and_then(|d| prev.segments.get(d))
1174            else {
1175                continue;
1176            };
1177            if old.uri != seg.uri
1178                || old.duration != seg.duration
1179                || old.byte_range != seg.byte_range
1180            {
1181                return Continuity::Restart;
1182            }
1183            if let (Some(a), Some(b)) = (program_date_time(old), program_date_time(seg))
1184                && a != b
1185            {
1186                return Continuity::Restart;
1187            }
1188            overlap = true;
1189        }
1190
1191        // 2. The discontinuity sequence never decreases while the number does
1192        //    not.
1193        if !regressed && new.discontinuity_sequence < prev.discontinuity_sequence {
1194            return Continuity::Restart;
1195        }
1196        if !regressed {
1197            return Continuity::Continues;
1198        }
1199
1200        // 3. Regressed.
1201        if overlap {
1202            return Continuity::Lagging;
1203        }
1204        let old_uris: BTreeSet<&str> = prev.segments.iter().map(|s| s.uri.as_str()).collect();
1205        if new
1206            .segments
1207            .iter()
1208            .any(|s| old_uris.contains(s.uri.as_str()))
1209        {
1210            // A URI the previous window lists under another number.
1211            return Continuity::Restart;
1212        }
1213        let new_last = new.segments.iter().rev().find_map(program_date_time_ms);
1214        let prev_first = prev.segments.iter().find_map(program_date_time_ms);
1215        match (new_last, prev_first) {
1216            (Some(end), Some(start)) if end < start => Continuity::OlderWindow,
1217            _ => Continuity::Restart,
1218        }
1219    }
1220
1221    /// The Media Sequence Number went backwards (RFC 8216 §6.2.2 forbids it;
1222    /// restarting origins do it anyway — ffmpeg without `-start_number`,
1223    /// nginx-rtmp, a replaced origin; audit r09-C3, issue #1031). Every
1224    /// number the new playlist uses may name different media from the same
1225    /// number already delivered, so all MSN-keyed state is dropped (a new
1226    /// segment must not be skipped as "already delivered"), not-yet-polled
1227    /// fetches of the old numbering are withdrawn, the live-edge join is
1228    /// redone, the init segment is re-fetched (the restart may carry a new
1229    /// codec configuration), and [`Output::Discontinuity`] is emitted. A
1230    /// response still in flight for an old id is rejected as
1231    /// [`Error::UnrequestedResource`].
1232    fn restart_after_regression(&mut self) -> Result<()> {
1233        // Held-back TS access units belong to the old timeline: emit them
1234        // before the discontinuity, then start a fresh demuxer.
1235        self.flush_ts_demux(ResourceId::Init)?;
1236        self.ts_demux = None;
1237        self.ts_specs.clear();
1238        self.last_ts_init = None;
1239        self.pending_actions
1240            .retain(|a| !matches!(a, Action::FetchResource { .. }));
1241        self.pending_demux.clear();
1242        self.requested.clear();
1243        self.fulfilled.clear();
1244        self.delivered_parts.clear();
1245        self.delivered_segments.clear();
1246        self.discontinuous_msns.clear();
1247        self.discontinuity_emitted.clear();
1248        self.byte_range_cursor.clear();
1249        self.outstanding_fetches = 0;
1250        self.last_full_playlist = None;
1251        self.init_uri = None;
1252        self.init_bytes = None;
1253        self.init_emitted = false;
1254        self.joined = false;
1255        self.pending_outputs.push_back(Output::Discontinuity);
1256        Ok(())
1257    }
1258
1259    fn maybe_emit_end_of_stream(&mut self) -> Result<()> {
1260        if self.saw_endlist && !self.end_emitted && self.outstanding_fetches == 0 {
1261            // The TS demuxer holds back each stream's last access unit until
1262            // the next one shows its real duration; nothing follows now.
1263            self.flush_ts_demux(ResourceId::Init)?;
1264            self.pending_outputs.push_back(Output::EndOfStream);
1265            self.end_emitted = true;
1266        }
1267        Ok(())
1268    }
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273    use super::*;
1274
1275    // Regression: `on_resource` documents (see `Error::UnrequestedResource`)
1276    // that it rejects a `ResourceId` the client never requested, but
1277    // previously never actually checked — any bytes for any id (a
1278    // caller/driver bug, or a stale/duplicate delivery) were silently
1279    // accepted. Must FAIL if that check is ever removed.
1280    #[test]
1281    fn on_resource_rejects_a_never_requested_id() {
1282        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
1283        let id = ResourceId::Segment { msn: 0 };
1284
1285        let err = client
1286            .on_resource(id, b"some bytes")
1287            .expect_err("an id the client never requested must be rejected");
1288        assert!(
1289            matches!(err, Error::UnrequestedResource { id: got } if got == id),
1290            "wrong error variant: {err:?}"
1291        );
1292
1293        // Init is checked too (tracked via `init_uri` rather than
1294        // `requested`, since it's never inserted into that set).
1295        let err = client
1296            .on_resource(ResourceId::Init, b"init bytes")
1297            .expect_err("an unrequested Init must be rejected");
1298        assert!(
1299            matches!(
1300                err,
1301                Error::UnrequestedResource {
1302                    id: ResourceId::Init
1303                }
1304            ),
1305            "wrong error variant: {err:?}"
1306        );
1307    }
1308
1309    // The flip side of the regression above: a `ResourceId` the client
1310    // actually asked for (via its own internal `request_resource`
1311    // bookkeeping, mirroring what a real `poll()`-driven fetch populates)
1312    // must still be accepted, not spuriously rejected.
1313    #[test]
1314    fn on_resource_accepts_a_previously_requested_id() {
1315        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
1316        let id = ResourceId::Segment { msn: 0 };
1317        client.request_resource(id, "http://example.com/seg0.m4s".to_string(), None);
1318
1319        // No init segment cached yet, so this is buffered rather than
1320        // demuxed — the point here is only that it is *not* rejected as
1321        // unrequested.
1322        let result = client.on_resource(id, b"some bytes");
1323        assert!(
1324            result.is_ok(),
1325            "a requested id must be accepted: {result:?}"
1326        );
1327        assert!(
1328            client.pending_demux.iter().any(|(bid, _)| *bid == id),
1329            "expected the resource to be buffered pending the init segment"
1330        );
1331    }
1332
1333    // Issue #760: classic MPEG-TS-segment HLS routing. `is_ts_segment` must
1334    // say yes to a genuine TS resource (sync byte, no map ever seen)...
1335    #[test]
1336    fn is_ts_segment_true_when_no_map_seen_and_sync_byte_present() {
1337        let client = HlsClient::new("http://example.com/playlist.m3u8");
1338        assert!(client.is_ts_segment(&[TS_SYNC_BYTE, 0x40, 0x11, 0x00]));
1339    }
1340
1341    // ...but say no to an ISOBMFF (fMP4/CMAF) resource even when no map has
1342    // been seen yet — the content itself is never TS, so it must fall
1343    // through to the ordinary init-buffering path rather than being
1344    // misrouted into `TsDemux` (which would reject it as malformed TS).
1345    #[test]
1346    fn is_ts_segment_false_for_an_isobmff_resource_with_no_map_seen() {
1347        let client = HlsClient::new("http://example.com/playlist.m3u8");
1348        let ftyp_box = b"\x00\x00\x00\x18ftypiso5\x00\x00\x02\x00iso5iso6mp41";
1349        assert!(!client.is_ts_segment(ftyp_box));
1350    }
1351
1352    // The playlist signal takes precedence over content-sniffing: once this
1353    // playlist is known to advertise an `EXT-X-MAP` (an init fetch has been
1354    // requested/cached), even a resource whose first byte happens to be
1355    // `0x47` must NOT be misrouted through `TsDemux` -- it is that
1356    // playlist's own fMP4/CMAF init + part/segment concatenation the
1357    // fetched bytes belong with.
1358    #[test]
1359    fn is_ts_segment_false_once_a_map_has_been_requested() {
1360        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
1361        client
1362            .ensure_init_requested(&MapTag {
1363                uri: "init.mp4".to_string(),
1364                byte_range: None,
1365                extra_attrs: Vec::new(),
1366            })
1367            .expect("ensure_init_requested succeeds");
1368        assert!(!client.is_ts_segment(&[TS_SYNC_BYTE, 0x40, 0x11, 0x00]));
1369    }
1370
1371    // Biting test for the u64-overflow defect: a remote origin's
1372    // `#EXT-X-BYTERANGE` (or preload-hint byte range) is untrusted text —
1373    // `broadcast_hls::MediaPlaylist::parse` places no upper bound on either
1374    // the offset or the length (confirmed: bare `str::parse::<u64>()`). A
1375    // playlist advertising `BYTERANGE:18446744073709551615@1` must be
1376    // rejected with `Error::ByteRangeOverflow`, not panic (debug) or
1377    // silently wrap `offset + length` into a bogus cursor (release).
1378    //
1379    // MUTATION VERIFIED: reverting `resolve_byte_range`'s
1380    // `offset.checked_add(br.length).ok_or_else(...)` back to the original
1381    // `offset + br.length` makes this test fail — the debug build panics
1382    // with "attempt to add with overflow" before `expect_err` ever runs
1383    // (confirmed by running it), rather than the release build's silent
1384    // wraparound the issue actually reports. Recompiled and re-ran to
1385    // observe that exact panic, then restored the checked_add.
1386    #[test]
1387    fn resolve_byte_range_rejects_an_offset_plus_length_that_overflows_u64() {
1388        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
1389        let br = Some(ByteRange {
1390            length: u64::MAX,
1391            offset: Some(1),
1392        });
1393        let err = client
1394            .resolve_byte_range("http://example.com/seg0.m4s", &br)
1395            .expect_err("offset 1 + length u64::MAX must overflow and be rejected");
1396        assert!(
1397            matches!(
1398                err,
1399                Error::ByteRangeOverflow {
1400                    offset: 1,
1401                    length: u64::MAX,
1402                    ..
1403                }
1404            ),
1405            "wrong error variant/fields: {err:?}"
1406        );
1407    }
1408
1409    // Same defect, the other trigger named in the issue: repeated
1410    // omitted-offset ranges on the *same* resource URL accumulate via
1411    // `byte_range_cursor` (RFC 8216bis §4.4.4.9's "omitted offset continues
1412    // the previous sub-range" rule) — a long-lived pull can walk that
1413    // cursor arbitrarily close to `u64::MAX` before a single request's
1414    // `offset + length` itself overflows.
1415    #[test]
1416    fn resolve_byte_range_rejects_a_cursor_accumulation_that_overflows_u64() {
1417        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
1418        let url = "http://example.com/seg0.m4s";
1419        // Seed the cursor near the top of the u64 range with an explicit
1420        // offset, then the next omitted-offset range pushes it over.
1421        let seed = Some(ByteRange {
1422            length: u64::MAX - 10,
1423            offset: Some(5),
1424        });
1425        let (offset, length) = client
1426            .resolve_byte_range(url, &seed)
1427            .expect("seed range does not itself overflow")
1428            .expect("Some for a Some(ByteRange)");
1429        assert_eq!((offset, length), (5, u64::MAX - 10));
1430
1431        let next = Some(ByteRange {
1432            length: 100,
1433            offset: None, // continues the cursor left at 5 + (u64::MAX - 10)
1434        });
1435        let err = client
1436            .resolve_byte_range(url, &next)
1437            .expect_err("cursor + next length must overflow and be rejected");
1438        assert!(
1439            matches!(err, Error::ByteRangeOverflow { length: 100, .. }),
1440            "wrong error variant/fields: {err:?}"
1441        );
1442    }
1443
1444    // Biting test for the `merge_delta` defect: an `#EXT-X-SKIP` delta's
1445    // `SKIPPED-SEGMENTS` is untrusted `u64` text with no upper bound
1446    // enforced by `broadcast_hls::MediaPlaylist::parse`. A malicious/corrupt
1447    // origin claiming a `SKIPPED-SEGMENTS` value that overflows
1448    // `prefix_start + skipped_segments` (here: `prefix_start == 10` from a
1449    // 10-segment `media_sequence` advance, plus `skipped_segments ==
1450    // u64::MAX - 5`) must fall through to the existing "can't merge, return
1451    // the delta as-is" fallback, not panic. `prefix_start` is deliberately
1452    // nonzero: `0 + u64::MAX` does not overflow, so a `prefix_start == 0`
1453    // case would pass even with the guard removed, for the wrong reason
1454    // (the subsequent `.get()` bounds check catching it) rather than the
1455    // one this test targets (the addition itself).
1456    //
1457    // MUTATION VERIFIED: reverting the guard back to the original
1458    // `prefix_start + skip.skipped_segments as usize` makes this test fail:
1459    // the debug build panics with "attempt to add with overflow" inside
1460    // `merge_delta` before the `let merged = ...` assertions ever run
1461    // (confirmed by running it), rather than returning the delta unmerged.
1462    // Recompiled and re-ran to observe that exact panic, then restored the
1463    // checked/try_from guard.
1464    #[test]
1465    fn merge_delta_does_not_panic_on_an_overflowing_skipped_segments() {
1466        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
1467        let prev = MediaPlaylist {
1468            media_sequence: 0,
1469            segments: vec![MediaSegment::default(); 2],
1470            ..Default::default()
1471        };
1472        // `last_full_playlist` is set directly (private field, same module)
1473        // rather than driven through a full `on_playlist` round-trip — the
1474        // point under test is purely `merge_delta`'s own arithmetic guard.
1475        client.last_full_playlist = Some(prev);
1476
1477        let delta = MediaPlaylist {
1478            media_sequence: 10, // prefix_start == 10 - 0 == 10
1479            skip: Some(broadcast_hls::SkipInfo {
1480                skipped_segments: u64::MAX - 5,
1481                ..Default::default()
1482            }),
1483            segments: Vec::new(),
1484            ..Default::default()
1485        };
1486
1487        let merged = client.merge_delta(delta.clone());
1488        assert_eq!(
1489            merged, delta,
1490            "an unmergeable skip count must fall back to the delta as-is, not panic"
1491        );
1492    }
1493
1494    fn byte_range_playlist(first_msn: u64, file: &str) -> String {
1495        // Two sub-ranges of one resource; the second omits its offset
1496        // (RFC 8216 §4.4.4.9 / §4.3.2.2), so the per-URL cursor matters.
1497        format!(
1498            "#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:2\n\
1499             #EXT-X-MEDIA-SEQUENCE:{first_msn}\n\
1500             #EXTINF:2.0,\n#EXT-X-BYTERANGE:100@0\n{file}\n\
1501             #EXTINF:2.0,\n#EXT-X-BYTERANGE:50\n{file}\n"
1502        )
1503    }
1504
1505    // r09-W12: every set keyed by MSN (and the per-URL byte-range cursor)
1506    // must shrink back to what the current window references, not grow for
1507    // the life of the pull. Expected contents are literals.
1508    #[test]
1509    fn state_is_pruned_to_the_current_window() {
1510        let mut client = HlsClient::new("http://example.com/p.m3u8");
1511        client
1512            .on_playlist(byte_range_playlist(0, "a.ts").as_bytes())
1513            .unwrap();
1514        assert_eq!(
1515            client.byte_range_cursor.iter().collect::<Vec<_>>(),
1516            vec![(&"http://example.com/a.ts".to_string(), &150u64)]
1517        );
1518        // Deliver msn 0 (buffered: no init), leave msn 1 in flight.
1519        client
1520            .on_resource(ResourceId::Segment { msn: 0 }, b"opaque")
1521            .unwrap();
1522
1523        client
1524            .on_playlist(byte_range_playlist(1000, "b.ts").as_bytes())
1525            .unwrap();
1526
1527        let requested: Vec<ResourceId> = client.requested.iter().copied().collect();
1528        assert_eq!(
1529            requested,
1530            vec![
1531                // in flight when the window moved: kept
1532                ResourceId::Segment { msn: 1 },
1533                ResourceId::Segment { msn: 1000 },
1534                ResourceId::Segment { msn: 1001 },
1535            ]
1536        );
1537        assert!(client.fulfilled.is_empty(), "{:?}", client.fulfilled);
1538        assert_eq!(
1539            client.byte_range_cursor.iter().collect::<Vec<_>>(),
1540            vec![(&"http://example.com/b.ts".to_string(), &150u64)],
1541            "the cursor of the unreferenced a.ts must be dropped"
1542        );
1543    }
1544
1545    // r09-W12/W13 on a discontinuity-bearing window: MSN-keyed discontinuity
1546    // bookkeeping below the window is dropped too.
1547    #[test]
1548    fn discontinuity_bookkeeping_below_the_window_is_dropped() {
1549        let mut client = HlsClient::new("http://example.com/p.m3u8");
1550        let pl = |first: u64| {
1551            format!(
1552                "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n\
1553                 #EXT-X-MEDIA-SEQUENCE:{first}\n#EXT-X-DISCONTINUITY\n#EXTINF:2.0,\ns.ts\n"
1554            )
1555        };
1556        client.on_playlist(pl(5).as_bytes()).unwrap();
1557        client
1558            .on_resource(ResourceId::Segment { msn: 5 }, b"opaque")
1559            .unwrap();
1560        assert_eq!(
1561            client
1562                .discontinuous_msns
1563                .iter()
1564                .copied()
1565                .collect::<Vec<_>>(),
1566            vec![5]
1567        );
1568        client.on_playlist(pl(9).as_bytes()).unwrap();
1569        assert_eq!(
1570            client
1571                .discontinuous_msns
1572                .iter()
1573                .copied()
1574                .collect::<Vec<_>>(),
1575            vec![9]
1576        );
1577        assert!(client.discontinuity_emitted.is_empty());
1578        assert!(client.delivered_segments.is_empty());
1579    }
1580
1581    #[test]
1582    fn next_wait_reports_the_queued_wait_hint_without_consuming_it() {
1583        use core::time::Duration;
1584        let mut c = HlsClient::new("http://h/p.m3u8");
1585        assert_eq!(c.next_wait(), None, "the first queued action is a fetch");
1586        let _ = c.poll();
1587        c.on_playlist(
1588            b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:2.0,\nseg0.ts\n",
1589        )
1590        .unwrap();
1591        while c.next_wait().is_none() {
1592            c.poll()
1593                .expect("a WaitMs must be queued after a non-blocking live playlist");
1594        }
1595        assert_eq!(
1596            c.next_wait(),
1597            Some(Duration::from_millis(1000)),
1598            "half the 2 s target duration"
1599        );
1600        assert_eq!(
1601            c.next_wait(),
1602            Some(Duration::from_millis(1000)),
1603            "peeking does not consume"
1604        );
1605        // The deadline is ABSOLUTE: re-queries at later `now`s (unrelated
1606        // wake-ups) return the same instant instead of re-arming the wait.
1607        let t0 = std::time::Instant::now();
1608        let first = c.poll_timeout(t0).expect("a wait is at the front");
1609        assert_eq!(first, t0 + Duration::from_millis(1000));
1610        for later in [250u64, 600, 999, 5000] {
1611            assert_eq!(
1612                c.poll_timeout(t0 + Duration::from_millis(later)),
1613                Some(first),
1614                "re-query at +{later} ms must not slide the deadline"
1615            );
1616        }
1617        // Draining the wait supersedes it: the next wait is anchored afresh.
1618        assert!(matches!(c.poll(), Some(Action::WaitMs(1000))));
1619        assert_eq!(c.poll_timeout(t0 + Duration::from_secs(9)), None);
1620    }
1621
1622    #[test]
1623    fn rfc3339_accepts_the_spellings_the_old_parser_accepted() {
1624        assert_eq!(
1625            parse_rfc3339_ms("2026-10-02 10:00:00Z"),
1626            Some(1_790_935_200_000),
1627            "space separator"
1628        );
1629        assert_eq!(
1630            parse_rfc3339_ms("2026-10-02t10:00:00z"),
1631            Some(1_790_935_200_000),
1632            "lower-case t and z"
1633        );
1634        assert_eq!(
1635            parse_rfc3339_ms("2026-10-02T10:00:00.123456789Z"),
1636            Some(1_790_935_200_123),
1637            "extra digits truncated"
1638        );
1639        assert_eq!(
1640            parse_rfc3339_ms("1969-12-31T23:59:59Z"),
1641            Some(-1_000),
1642            "before the epoch"
1643        );
1644    }
1645
1646    #[test]
1647    fn rfc3339_offset_spellings_all_name_the_same_instant() {
1648        let want = Some(1_790_935_200_000);
1649        for text in [
1650            "2026-10-02T12:00:00+02:00",
1651            "2026-10-02T12:00:00+0200",
1652            "2026-10-02T05:00:00-05:00",
1653            "2026-10-02T05:00:00-0500",
1654            "2026-10-02T10:00:00+00:00",
1655            "2026-10-02T10:00:00-00:00",
1656        ] {
1657            assert_eq!(parse_rfc3339_ms(text), want, "{text}");
1658        }
1659        // An hour-only offset: whatever `jiff` decides, it must never be a guess.
1660        let hour_only = parse_rfc3339_ms("2026-10-02T12:00:00+02");
1661        assert!(hour_only == want || hour_only.is_none(), "{hour_only:?}");
1662    }
1663
1664    /// A leap second is clamped to :59 (jiff), where the old parser added 60 s to the minute.
1665    #[test]
1666    fn a_leap_second_is_clamped_to_the_last_second_of_the_minute() {
1667        assert_eq!(
1668            parse_rfc3339_ms("2016-12-31T23:59:60Z"),
1669            Some(1_483_228_799_000)
1670        );
1671    }
1672
1673    #[test]
1674    fn rfc3339_parses_to_literal_epoch_milliseconds() {
1675        // 2026-10-02T10:00:00Z = 1_790_935_200 s (independently: `date -u -d`).
1676        assert_eq!(
1677            parse_rfc3339_ms("2026-10-02T10:00:00Z"),
1678            Some(1_790_935_200_000)
1679        );
1680        assert_eq!(
1681            parse_rfc3339_ms("2026-10-02T10:00:00.250Z"),
1682            Some(1_790_935_200_250)
1683        );
1684        assert_eq!(
1685            parse_rfc3339_ms("2026-10-02T10:00:00.5Z"),
1686            Some(1_790_935_200_500)
1687        );
1688        assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00Z"), Some(0));
1689        assert_eq!(
1690            parse_rfc3339_ms("2000-02-29T23:59:59Z"),
1691            Some(951_868_799_000)
1692        );
1693        // The same instant with an offset, both spellings.
1694        assert_eq!(
1695            parse_rfc3339_ms("2026-10-02T12:00:00+02:00"),
1696            Some(1_790_935_200_000)
1697        );
1698        assert_eq!(
1699            parse_rfc3339_ms("2026-10-02T05:00:00-0500"),
1700            Some(1_790_935_200_000)
1701        );
1702        // Anything not exactly RFC 3339 is `None`, never a guess.
1703        for bad in [
1704            "",
1705            "2026-10-02",
1706            "2026-13-02T10:00:00Z",
1707            "2026-10-02T25:00:00Z",
1708            "2026-10-02T10:00:00",
1709            "2026-10-02T10:00:00Zjunk",
1710            "2026-10-02T10:00:00.Z",
1711            "2026-10-02T10:00:00+2:00",
1712            "xxxx-10-02T10:00:00Z",
1713        ] {
1714            assert_eq!(parse_rfc3339_ms(bad), None, "{bad:?}");
1715        }
1716    }
1717}