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}