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