Skip to content

lite-05: negotiate per-frame compression via SUBSCRIBE_OK (Rust + JS)#1531

Merged
kixelated merged 10 commits into
devfrom
claude/compression-implementation-QIFwj
May 29, 2026
Merged

lite-05: negotiate per-frame compression via SUBSCRIBE_OK (Rust + JS)#1531
kixelated merged 10 commits into
devfrom
claude/compression-implementation-QIFwj

Conversation

@kixelated

@kixelated kixelated commented May 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Opt-in frame compression for the moq-lite-05 (WIP) draft, implemented on both the Rust (moq-net) and browser (@moq/net) sides. The publisher marks a track as worth compressing, the codec is negotiated in SUBSCRIBE_OK, and the subscriber blocks on that message before decoding any frame.

  • Track gains a compress flag (Rust compress: bool / Track::with_compress(..); JS track.compress) — a hint that the track's frames are worth compressing. The hang JSON catalog (Catalog::default_track) is marked compressible, since it's text and re-sent on every change.
  • SUBSCRIBE_OK carries a negotiated Compression codec, gated to lite-05+. The publisher picks Deflate when the track is marked compress and the peer speaks lite-05; older drafts (lite-04 and below) always negotiate None and stream frames verbatim, so the wire stays backwards compatible. Negotiation lives in SUBSCRIBE_OK (not SUBSCRIBE) specifically so the subscriber has to block on it to learn how to decode.
  • Subscribers block on SUBSCRIBE_OK before decoding frames. A group's QUIC stream can be accepted before SUBSCRIBE_OK lands on the subscribe stream, so the negotiated codec is fanned out to each group stream (Rust: a per-subscribe watch channel; JS: a per-subscribe signal) and the group read path waits on it before touching any frame payload.
  • Frames are compressed independently so the codec never carries state across the lossy, out-of-order group boundary. Decompression caps the inflated size at 16 MiB (MAX_FRAME_SIZE) to reject zip bombs.
  • Codec = raw DEFLATE, no zlib/gzip header (QUIC already guarantees integrity). Rust uses flate2 (pure-Rust miniz_oxide backend, no C deps — keeps moq-ffi cross-compilation and Nix builds painless); the browser uses the native CompressionStream/DecompressionStream with "deflate-raw", which produces the same bytes. The Compression enum is the extension point if we want zstd later.

Wire format (lite-05 only)

SUBSCRIBE_OK appends a single Compression (i) varint after the existing fields (0 = none, 1 = deflate). On a compressed track, each FRAME's size prefix becomes the compressed length; the subscriber inflates it back, so the public Frame/Group/Track API is unchanged on both sides.

Scope / known limitations (follow-ups)

  • Hop-by-hop, not end-to-end. A relay learns the codec from its upstream SUBSCRIBE_OK, decompresses on ingest, and caches the plaintext. Its downstream producer track isn't marked compress, so it re-serves uncompressed. Compression therefore applies on a hop where the publisher's track carries the flag (origin → direct subscriber). Propagating the flag through relays is a follow-up.
  • JS publisher pull-model timing. @moq/net creates the served track lazily when a subscribe arrives, so an app must set track.compress before the track is served for the publisher to advertise it. The browser's primary role here is the subscriber (decoding a Rust-published compressed catalog), which is fully wired.
  • lite-05 is unadvertised/WIP, so this is opt-in only (Lite05Wip / DRAFT_05_WIP); nothing negotiates it by default.

Cross-package sync

Table row Action
rs/moq-net wire/API → js/net Done@moq/net implements the same Compression codec, SUBSCRIBE_OK field, blocking, and per-frame (de)compression.
rs/moq-net wire/API → doc/concept Deferred: the wire spec belongs in moq-dev/drafts; doc/concept is a stable-feature overview and shouldn't document an unadvertised draft yet.
rs/hang catalog → js/hang No catalog format change; compress is a transport-layer track hint, not part of the serialized catalog JSON (skip_serializing_if).

Test plan

  • Rust: Compression codec round-trips (none + deflate), rejects garbage, caps inflated size, wire-code round-trip; SUBSCRIBE_OK wire round-trip (compression on lite-05, absent on lite-04). cargo test -p moq-net --lib (327) and -p hang pass; cargo check --all-targets --workspace, clippy -D warnings, cargo doc -D warnings clean (minus ffmpeg/gst crates needing system toolchains locally).
  • JS: codec round-trip (incl. max-size + garbage rejection) and SUBSCRIBE_OK wire format across versions; bun test (148 net tests) and bun run --filter='*' check + Biome clean.
  • End-to-end session test exercising the SUBSCRIBE_OK-blocking + compress/decompress path across a live transport — needs an in-memory transport harness moq-net doesn't have yet; flagging as a follow-up.

(Written by Claude)

claude added 6 commits May 29, 2026 00:46
Add opt-in frame compression to the moq-lite-05 (WIP) draft.

- Track gains a `compress: bool` hint marking a track worth compressing
  (e.g. the JSON catalog, which is now marked compressible).
- SUBSCRIBE_OK carries a negotiated Compression codec, gated to lite-05+.
  The publisher picks Deflate when the resolved track is marked compress
  and the peer speaks lite-05; older drafts always negotiate None and
  stream frames verbatim, so the wire stays backwards compatible.
- Subscribers block on SUBSCRIBE_OK before decoding any frame, since a
  group's QUIC stream can race ahead of it on its own stream. The codec
  is fanned out to group streams via a per-subscribe watch channel.
- Frames are compressed independently (raw DEFLATE via flate2) so the
  codec never carries state across the lossy, out-of-order group
  boundary. Decompression caps the inflated size at MAX_FRAME_SIZE to
  reject zip bombs.

Co-Authored-By: Claude <noreply@anthropic.com>
cargo doc runs with -D warnings in CI; linking a public doc comment to the
pub(crate) MAX_FRAME_SIZE tripped rustdoc::private_intra_doc_links.
The new Track.compress field broke struct literals in moq-audio, moq-ffi,
and libmoq that the first pass missed. Default them via ..Default::default().
…sor API

broadcast.rs's linger_resubscribe_keeps_flowing_moq_lite_03 still awaited
OriginConsumer::announced() as a future, but #1434 made it return an
AnnounceConsumer cursor. The three sibling tests were updated; this one was
missed, leaving the workspace test build broken on dev. Mirror the sibling
pattern: build the cursor up front and poll it with .next().
Mirror the Rust per-frame compression on the browser side.

- New `Compression` codec (None/Deflate) backed by the browser-native
  CompressionStream/DecompressionStream with "deflate-raw", which matches
  the Rust `flate2` raw-DEFLATE bytes on the wire. Decompression caps the
  inflated size at 16 MiB to reject zip bombs.
- `Track` gains a `compress` flag.
- `SubscribeOk` carries the negotiated codec on draft-05+ only; older
  drafts stay byte-compatible (always None).
- Subscriber blocks on SUBSCRIBE_OK before decoding any frame: the codec
  is fanned out to group streams via a per-subscribe signal, since a
  group's stream can arrive before SUBSCRIBE_OK on its own stream.
- Publisher negotiates the codec from the track's compress flag and
  compresses each frame independently.

Tests cover the codec round-trip (incl. max-size and garbage rejection)
and the SUBSCRIBE_OK wire format across versions.
@kixelated kixelated changed the title moq-net(lite-05): negotiate per-frame compression via SUBSCRIBE_OK lite-05: negotiate per-frame compression via SUBSCRIBE_OK (Rust + JS) May 29, 2026
…lementation-QIFwj

# Conflicts:
#	rs/moq-net/src/stats.rs
@kixelated kixelated force-pushed the claude/compression-implementation-QIFwj branch from 9ad465e to db3b66e Compare May 29, 2026 03:02
Comment thread rs/moq-net/src/lite/publisher.rs Outdated
}
Ok(()) = self.track_priority.changed() => {
priority.set_track(*self.track_priority.borrow_and_update());
match self.compression {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any way to split this into separate functions? It's very nested.

claude and others added 3 commits May 29, 2026 03:18
Address review feedback: the compression branch left serve_group deeply
nested. Extract the repeated "await X while servicing priority updates"
pattern into next_frame/read_chunk/read_all/write_chunk helpers, and move
the codec branch into serve_frame. Behavior is unchanged.
Fixes the `cargo sort --check` CI failure: flate2 was appended below the
alphabetically sorted [workspace.dependencies] block.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@kixelated kixelated enabled auto-merge (squash) May 29, 2026 03:56
@kixelated kixelated disabled auto-merge May 29, 2026 04:08
@kixelated kixelated merged commit f3b79d8 into dev May 29, 2026
1 of 2 checks passed
@kixelated kixelated deleted the claude/compression-implementation-QIFwj branch May 29, 2026 04:08
kixelated added a commit that referenced this pull request May 29, 2026
#1531 added `compress: bool` to `moq_net::Track` but two struct literals
in libmoq's lib still listed only `name` and `priority`, so the lib test
build (which is part of `cargo check --all-targets`) errored with
E0063 once #1531 reached this branch via the dev merge.

Swap both to `Track::new(name)` since neither needed a non-default
priority. Same construction the rest of the workspace uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kixelated added a commit that referenced this pull request May 30, 2026
Picked up #1528 (moq-rtc) and #1531 (compress field on Track) by
merging origin/dev. The new moq-rtc binary still passes
publisher.consume() to with_publish, which used to take an
OriginConsumer but now takes an OriginProducer. Drop the .consume().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants