hls-runtime 0.5.0

Sans-IO Low-Latency HLS (RFC 8216bis) client + server engines in one crate (blocking reload, part prefetch, rolling-window origin), with an optional tokio+reqwest IO adapter.
Documentation
# Changelog

All notable changes to `hls-runtime` (formerly `ll-hls-runtime`) are
documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.5.0] - 2026-08-05

### Changed
- Requires `transmux` 0.23 (epoch-pure caret bump from ^0.22).
- Requires `media-plane` 0.3 (epoch-pure caret bump from ^0.2).

## [0.4.0] - 2026-08-02

### Changed
- `HlsOrigin`'s `_HLS_msn` abuse bound tightened from +4 to the spec's +2
  (RFC 8216bis §6.2.5.2 SHOULD: "greater than the Media Sequence Number of
  the last Media Segment in the current Playlist plus two").

### Added
- `server::Container` (`Fmp4` / `MpegTs`, `#[non_exhaustive]`) and
  `HlsOrigin::builder(trunk)` -> `HlsOriginBuilder` (issue #873, closes #865):
  `HlsOrigin`'s origin can now serve classic HLS-of-TS (`.ts` segments, no
  `#EXT-X-MAP` by default) as well as fMP4, and container is orthogonal to
  low latency — `.container(Container::MpegTs)` and `.low_latency(part_target_ms)`
  are independent builder calls, so classic HLS (no low-latency tags at all)
  is finally expressible. `HlsOriginBuildError` (`#[non_exhaustive]`,
  `thiserror`) surfaces a missing `target_duration_secs`/`window_segments` as
  an error rather than a silent default.
  - RFC 8216bis §3.1.1's `EXT-X-MAP` rule for MPEG-2 TS is a disjunction
    (PAT+PMT in-band, *or* `EXT-X-MAP`), not a container restriction —
    `Container::MpegTs` omits the tag by default (matching `transmux`'s
    self-initialising TS segments) without forbidding it structurally.
  - `HlsOrigin::set_init` stays callable under `MpegTs` — a documented no-op
    (bytes stored, never advertised or served), so a caller sharing one code
    path across both containers need not branch.

### Changed (Breaking)
- **`HlsOrigin::new` deleted**, replaced by `HlsOrigin::builder(trunk)`
  (issue #873): the old four-positional constructor made `part_target_ms`
  mandatory, so classic (non-low-latency) HLS was inexpressible. No
  compatibility shim — this crate is at 0.4.0, unpublished at the time of
  this change. `multimux`'s `HlsOrigin::new` call site is updated to the
  builder in the same release.

### Fixed
- The LL-HLS origin (`server::engine`) no longer supplies a hardcoded
  `EXT-X-VERSION:9` (`LL_HLS_VERSION`, deleted). `broadcast_hls::MediaPlaylist::to_m3u8`
  now computes the version from the content actually emitted (RFC 8216bis
  §8) — the true minimum for this origin's fMP4 playlist is 6, since none
  of the LL-HLS directives it renders (`EXT-X-PART`/`EXT-X-PART-INF`/
  `EXT-X-PRELOAD-HINT`/`EXT-X-SERVER-CONTROL`) carry any version requirement
  at all. The old hardcoded 9 over-declared and would have caused every
  client on protocol version 6, 7, or 8 to refuse a stream it could
  otherwise have played (issue #871).

### Changed
- The client/origin engines parse and render HLS playlists via `broadcast-hls`
  directly instead of reaching through `transmux::hls` for it (issue #878).
  `Error::PlaylistParse` now wraps `broadcast_hls::Error` instead of
  `transmux::Error` — a source-type change to a `#[from]`-wrapped variant,
  not a shape change to `Error` itself. Playlist docs moved from this
  crate's `docs/` to `broadcast-hls/docs/`, since they document the tags
  that crate now implements.

## [0.4.0] - 2026-08-01

### Changed (Breaking)
- **Crate renamed** `ll-hls-runtime` -> `hls-runtime` (issue #868): the
  crate is gaining a non-low-latency mode, so the `ll-` name became wrong.
  A clean break — no deprecated alias, no re-export shim. `ll-hls-runtime`'s
  already-published versions stay live on crates.io, untouched.
- Public types dropped their `Ll` prefix: `LlHlsOrigin` -> `HlsOrigin`,
  `LlHlsRequest` -> `HlsRequest`, `LlHlsBody` -> `HlsBody`, `LlHlsClient` ->
  `HlsClient`. No other behaviour change.

## [0.3.1] - 2026-07-30

### Fixed
- Floor `media-plane` to `0.1.1`. The `^0.1` bucket also contains 0.1.0,
  which is built against `transmux` 0.20, so a consumer could resolve two
  `transmux` minors into one graph and hit trait-resolution errors pointing
  at this crate's internals (#858).

## [0.3.0] - 2026-07-30

### Changed (Breaking)
- `LlHlsRequest`, `LlHlsBody` (`server::engine`) now carry
  `#[non_exhaustive]` (issue #806's non_exhaustive drift-guard audit). A
  downstream `match` on either of these now needs a wildcard arm.
- The client's part-prefetch now ignores (rather than exhaustively assumes
  only `PART`/`MAP`) a future `PreloadHintType` variant from a newer
  `transmux`, consistent with that enum's own new `#[non_exhaustive]`.

### Added
- `tests/non_exhaustive_coverage.rs` drift guard (issue #806).

## [0.2.0] - 2026-07-28

### Fixed
- `server::engine`'s own test helpers and the `client_stepping`/
  `origin_playlist` examples called `Trunk::writer()` (the samples+events
  writer) instead of `Trunk::segment_writer()` to publish segments/parts — a
  stale call from before the `SegmentWriter` split that never actually
  compiled since (found via multimux plan step 5b's first successful
  `cargo test --workspace`/`--all-targets` across the whole workspace since
  then). No behaviour change to any shipped API — test/example-only.


- **Cleared this crate's share of the latest-stable clippy canary** (issue
  #770 — the non-blocking `clippy (latest stable)` CI job, which had been
  failing on `main` unnoticed across many merges): the `golden_gate`
  integration test builds its single-track init segment with
  `std::slice::from_ref(&spec)` instead of `&[spec.clone()]`
  (`clippy::cloned_ref_to_slice_refs`). Test-only, behaviour-preserving.

### Changed
- **BREAKING: `server::MediaStore`/`HealthState`/`SegmentWindowEntry`/
  `PlaylistOutcome`/`ResourceOutcome` are gone.** Media-plane implementation
  plan step 4: the LL-HLS origin is now [`server::LlHlsOrigin`], a
  `media_plane::egress::ServedEgress` that renders playlists and resolves
  blocking-reload/part-availability requests directly from a shared
  `media_plane::Trunk`, instead of a second, push-fed rolling-window store
  that duplicated exactly what the `Trunk` now holds. `server/store.rs`
  (732 lines of `MediaStore`) is deleted outright — live parts, whether a
  segment has closed, and the just-closed-segment-final-part-still-serves
  guarantee (multimux 0.2.1/0.2.2's hard-won bug fixes) all now fall out of
  the `Trunk`'s own live-part log with no cache of any kind on this crate's
  side. The one thing that genuinely cannot come from a `&Trunk` call alone —
  the rolling window of currently-advertised *closed* segments (bytes,
  duration, discontinuity bit), the lifetime-max segment duration, and the
  cumulative `#EXT-X-DISCONTINUITY-SEQUENCE` count — is assembled by
  `LlHlsOrigin` draining exactly **one** `Trunk` segment cursor, per this
  crate's own `media_plane::egress` module doc ("a `ServedEgress`
  implementation... keeps its own resolvable window in sync by draining
  [cursors]"), not a second `MediaStore`. See `docs/superpowers/plans/
  2026-07-26-media-plane-implementation.md` Step 4.
- The engine-level `BlockingQuery`/`DEFAULT_TRACK_ID`/`master_playlist_m3u8`
  are unchanged; the local `CachePolicy` duplicate is gone in favour of
  `media_plane::egress::CachePolicy`, re-exported via `LlHlsOrigin`'s
  `EgressResponse`.
- Dropped the `event-listener` direct dependency (now reached transitively
  through `media-plane`, whose `Trunk::listen` supersedes `MediaStore::listen`)
  and added `media-plane`/`bytes` (both gated by this crate's `std` feature,
  same as `event-listener` was).

### Removed
- **`multimux` no longer builds against this crate.** `multimux::store`
  re-exported `server::MediaStore`/`HealthState` directly, and
  `multimux::output::{llhls,dash,ll_dash}`/`origin::resource` called the
  deleted `resolve_playlist`/`resolve_resource`/`media_playlist_m3u8(&MediaStore,
  _)` shapes — none of that is a cheap adapter-level fix (it is `multimux`'s
  full `ServedEgress` port, scoped to plan Step 5, which this step
  deliberately does not half-port). `ll-hls-runtime`'s own `tests/
  golden_gate.rs`/`tests/glass_to_glass.rs` (both `tokio`-feature-gated,
  both depending on `multimux` as a dev-dependency) do not build until
  Step 5 lands.

## [0.1.1] - 2026-07-26

### Added
- **`client::LlHlsClient` now ingests classic MPEG-TS-segment HLS** (issue
  #760): a Media Playlist that never advertises an `EXT-X-MAP` (HLS v3, RFC
  8216 — the dominant legacy/IPTV form: self-contained `.ts` segments
  carrying their own PAT/PMT/PES, no separate init resource) routes each
  fetched Part/Segment through `transmux::TsDemux` instead of the fMP4/CMAF
  `transmux::Fmp4Demux` path, content-sniffed by the MPEG-TS sync byte
  (`0x47`) once the playlist itself is known to carry no map. The first
  successfully demuxed segment's recovered `TrackSpec`s synthesize the one
  `Output::Init` the crate's output contract requires (via
  `transmux::build_init_segment`), so downstream callers built against the
  fMP4 path (e.g. `multimux`'s `HlsPull`) need no TS-specific handling of
  their own. The fMP4/CMAF + LL (parts/preload-hint) path is entirely
  unchanged; the two never overlap for a single playlist.

## [0.1.0] - 2026-07-21

### Fixed
- **`MediaStore::window_segments`/`last_closed_segment_seq` no longer use a
  bare `.lock().unwrap()`.** Pre-release audit finding: these two lock sites
  (the bare-`_HLS_msn` reload path and the DASH `SegmentTemplate` window
  accessor) panicked on a poisoned `Mutex`, unlike the store's other 11 lock
  sites, which already tolerate poisoning via
  `.unwrap_or_else(std::sync::PoisonError::into_inner)`. Both now follow the
  same poison-tolerant pattern, so one panicking holder of the lock can no
  longer cascade into panics on ordinary requests.
- **`client::LlHlsClient::on_resource` now actually enforces
  `Error::UnrequestedResource`.** Pre-release audit finding: the variant's
  docs already claimed a `ResourceId` the client never requested was
  rejected, but `on_resource` never checked — any bytes for any id (a
  caller/driver bug, or a stale/duplicate delivery) were silently accepted.
  Now checked against the client's internal `requested` bookkeeping (`Init`
  against whether an init fetch is outstanding/cached), returning
  `Error::UnrequestedResource` instead.

### Changed
- **`server::master_playlist_m3u8` now takes a `media_playlist_name: &str`
  argument** (issue #663 "shared output auth + configurable playlist_name"):
  the master playlist's `#EXT-X-STREAM-INF` reference is the caller's
  configured media-playlist filename instead of the hardcoded `"media.m3u8"`
  literal, so a server (e.g. multimux's `Config::playlist_name`) can serve
  its media playlist under any `*.m3u8` name. Breaking: pass the intended
  filename explicitly (`master_playlist_m3u8("media.m3u8")` reproduces the
  old behaviour).
- **`client::tokio_client::TokioClient` now authenticates via `broadcast-auth`**
  (issue #663 P3c): `TokioClientConfig::auth` takes a
  `broadcast_auth::Credentials` (Basic/Digest/Bearer) instead of the ad hoc
  `Auth` enum (Basic/Bearer only) — fulfilling the TODO the field's doc
  comment carried since P3a. Basic/Bearer are still pre-applied on every
  request via reqwest's own helpers; Digest now works end-to-end: on a `401`,
  `TokioClient` reads `WWW-Authenticate`, computes the response via
  `broadcast_auth::Authenticator`, resends once, and caches the resulting
  authenticator (applied preemptively, advancing `nc`, on later requests).
  New `TokioError::Auth` variant for a challenge/response failure. Breaking:
  `tokio_client::Auth` is removed; construct `broadcast_auth::Credentials`
  instead (added as a `tokio`-feature-gated optional dependency).

### Added

#### Server (issue #663/#717 Stage 2)

- **`server` — the sans-IO LL-HLS origin engine** (Stage 2 of the
  ll-hls-runtime unification, issue #663/#717 —
  `docs/superpowers/specs/2026-07-18-multimux-hub-design.md`, "ll-hls-runtime
  — client + server in one crate"), moved out of `multimux` behind the new
  `std` feature (needs `std::sync::Mutex`, unlike the no_std-capable
  `client`):
  - **`server::MediaStore`** — the protocol-neutral rolling in-RAM window
    (init/segments/live parts/`recent_parts`/health/max-segment-duration),
    moved verbatim from `multimux::store::MediaStore` **including the
    part-404-boundary fix** (`recent_parts`, so an in-flight preload-hint
    request for a segment's final part still resolves after the segment
    closes). The `tokio::sync::watch<u64>` progress signal is replaced with a
    runtime-agnostic wakeup: `MediaStore::progress_version()` (a monotonic
    counter) and `MediaStore::listen()` (an `event_listener::EventListener`    a plain `Future<Output = ()>` any executor, or none via its blocking
    `.wait()`, can drive), via the new `event-listener` dependency.
  - **`server::MediaStore::resolve_playlist`/`resolve_resource`** — the
    blocking-reload (RFC 8216bis §6.2.5.2) and part-availability decision
    logic as synchronous poll methods returning `PlaylistOutcome`
    (`Ready`/`WouldBlock`/`BadRequest`) / `ResourceOutcome`
    (`Ready`/`WouldBlock`/`NotFound`) — never blocking, never touching a
    clock. An async adapter (e.g. `multimux`) turns `WouldBlock` into an
    actual bounded wait via `MediaStore::listen()` + its own
    `tokio::time::timeout`; see the `server` module docs for the exact
    caller-driven wait-loop shape.
  - **`server::media_playlist_m3u8`/`master_playlist_m3u8`** — the LL-HLS
    playlist renderers, moved verbatim from
    `multimux::output::llhls::media_playlist_m3u8` **including the
    reentrant-lock deadlock fix** (`max_segment_duration()`/
    `target_duration_secs()` read *before* `MediaStore::with_segments_and_parts`'s
    lock).
  - **`server::CachePolicy`** (`Immutable`/`NoCache`) — the cache-control
    policy a resolved `ResourceOutcome::Ready` carries, for an adapter to
    apply as HTTP `Cache-Control`.

### Changed

- **Renamed `ll-hls-client``ll-hls-runtime`** (Stage 1 of the ll-hls-runtime
  unification; never published, so a free rename — no `0.1.0` behaviour
  change). The client engine moved under a `client` module
  (`ll_hls_runtime::client::LlHlsClient` etc., mirroring `rtsp-runtime`'s
  client+server split); an empty `server` module is reserved for the LL-HLS
  origin engine currently in `multimux`, to be folded in as Stage 2.

#### Client (issue #717)

- **`LlHlsClient` — sans-IO Low-Latency HLS playback client engine** (issue
  #717, slices 2-4). A caller-driven state machine in the same sans-IO shape
  as `srt-runtime` (#565): `poll()`/`next_output()` drain queued `Action`s /
  `Output`s; `on_playlist()`/`on_resource()`/`on_error()` feed responses back
  in. No socket, no clock, no `tokio`/`reqwest` dependency in the core.
  - **Reload scheduler** (slice 2): Blocking Playlist Reload
    (`_HLS_msn`/`_HLS_part`, RFC 8216bis §6.2.5.2) once a playlist advertises
    Low-Latency support, correctly distinguishing a bare `_HLS_msn` (waits for
    a closed segment) from `_HLS_part=0`; non-blocking-reload backoff derived
    from `#EXT-X-TARGETDURATION` for non-LL origins; best-effort `EXT-X-SKIP`/
    `CAN-SKIP-UNTIL` Playlist Delta Update merge.
  - **Fetch pipeline** (slice 3): `EXT-X-PRELOAD-HINT` part prefetch ahead of
    its own numbered appearance; `BYTERANGE` part/segment/map support
    (including the "omitted offset continues the previous sub-range" rule);
    the init segment (`EXT-X-MAP`) fetched once.
  - **Output adapter** (slice 4): ordered `Output::Init` then `Output::Samples`
    (real access units via `transmux::Fmp4Demux`, not opaque container bytes);
    `EXT-X-DISCONTINUITY` forwarded as `Output::Discontinuity`; parts already
    individually fetched are never double-counted when their parent segment
    later closes (dedup/coalescing); a non-LL playlist (no parts at all) plays
    via the full-segment fallback path; resources arriving before the init
    segment are buffered and replayed once it arrives.
  - Reuses `transmux::hls::MediaPlaylist::parse` (issue #717 slice 1) for the
    playlist model — this crate defines no playlist types of its own.
  - `tests/origin_loop.rs`: an in-process origin↔client loop against a real
    `transmux::ll_hls::LlHlsSegmenter`, asserting the exact blocking-reload
    `_HLS_msn`/`_HLS_part` requested, the preload-hint prefetch actually
    issued, ordered/deduped/byte-identical sample reconstruction, and the
    non-LL full-segment fallback path.
  - `CAN-BLOCK-RELOAD` (issue #717 slice 1 follow-up, fixed alongside slice
    5): reload scheduling now honours `transmux::hls::LowLatencyConfig::can_block_reload`
    rather than inferring blocking-reload support from `low_latency` being
    `Some` — an origin advertising `CAN-BLOCK-RELOAD=NO` (while still
    carrying `PART-INF`/`PART` tags) now correctly gets a plain, non-blocking
    reload paced by `Action::WaitMs`, never a blocking `_HLS_msn`/`_HLS_part`
    request. Covered by `tests/origin_loop.rs`'s
    `can_block_reload_no_yields_non_blocking_reload_with_backoff`.
- **`TokioClient` — tokio + reqwest (rustls) IO adapter** (issue #717 slice
  5), behind a new, non-default `tokio` cargo feature. Drives `LlHlsClient`
  over real HTTP: performs the blocking `_HLS_msn`/`_HLS_part` reload and
  plain playlist GETs, resource fetches (including `Range` byte-ranges),
  retries resource fetches with capped backoff before falling back to
  `on_error` (letting the next reload naturally re-request them), and
  retries a failing playlist reload indefinitely with capped backoff (the
  sans-IO core has no other recovery path for a playlist fetch failure).
  Optional HTTP Basic/Bearer auth via `TokioClientConfig::auth`, with a
  documented TODO to swap in the workspace's planned shared multi-scheme
  auth crate once it exists. Exposes `TokioClientStats` (playlist fetches,
  blocking reloads, resource fetches, preload-hint-triggered resource
  fetches) so blocking-reload/prefetch behaviour is externally observable,
  not just internally exercised. The sans-IO core (`client.rs`) gained no
  new dependency from this — `tokio`/`reqwest` are entirely behind the new
  feature.
  - `tests/glass_to_glass.rs` (gated on the `tokio` feature; epic #717's
    done-bar): drives `TokioClient` against a **real** `multimux`-served
    LL-HLS origin over real loopback HTTP, fed by a real-time-paced
    `transmux::ll_hls::LlHlsSegmenter` producer (live-shaped, ~30fps/120ms
    parts) — measures glass-to-glass latency (wall-clock push-to-emit,
    embedded per-sample) and asserts it is **sub-second**, asserts at least
    one Blocking Playlist Reload and one preload-hint-triggered resource
    fetch actually occurred (`TokioClientStats`), and asserts a genuinely
    non-LL playlist (no `PART` tags, served from a minimal hand-built axum
    origin) still plays via the full-segment fallback with zero blocking
    reloads.
  - `tests/golden_gate.rs` (gated on the `tokio` feature, `ffprobe`-gated,
    non-blocking CI lane — closes issue #717's last acceptance box,
    "Integrated into #569's golden-gate harness as the reference client"):
    `TokioClient` is now the **reference client** in the #569 player-validated
    golden gate. `transmux/tests/golden_gate.rs` (#569) validates only the
    origin half — transmux's own muxer output handed to an independent
    decoder (`ffprobe`). This closes the other half: demuxes the workspace's
    real `fixtures/ts/h264_aac.ts` capture (Main profile, 320x240, 25fps, 75
    real video frames) via `TsDemux`, live-paces those real samples through
    the same `LlHlsSegmenter`/`MediaStore`/`LlHlsOutput` origin stack
    `glass_to_glass.rs` uses, drives a real `TokioClient` against it over
    loopback HTTP, then muxes the **client's own** reconstructed init +
    samples (not the origin's) into a real fMP4 and hands that to `ffprobe`:
    asserts it decodes as H.264 at the source's resolution, and that
    `ffprobe -count_frames`'s own decoded frame count exactly matches the
    frames fed in — catching a drop/duplicate/reorder that corrupts the
    bitstream even when the container alone still looks well-formed. Also
    covers the non-LL/full-segment fallback path decoding correctly, and a
    self-test (`dropped_sample_changes_the_decoded_frame_count`) proving the
    frame-count oracle isn't vacuous. `.github/workflows/ci.yml`'s existing
    non-blocking `golden-gate` job now also runs this suite alongside
    transmux's.