feat(moq)!: migrate MoQ stack to moq-native 0.19 / hang 0.20 / moq-net + @moq/* majors - #675
Conversation
…oq-net 0.2 Replace moq-lite with moq-net module-scoped APIs (origin/broadcast/track/ group/frame/announce), async track subscription, announcement-based runtime discovery, hang 0.20 container frame encode/decode, and the new moq-native certificates()/fingerprints() TLS API. Signed-off-by: streamkit-devin <devin@streamkit.dev>
Signed-off-by: streamkit-devin <devin@streamkit.dev>
…sed) Signed-off-by: streamkit-devin <devin@streamkit.dev>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #675 +/- ##
==========================================
+ Coverage 85.28% 85.32% +0.04%
==========================================
Files 249 249
Lines 75982 76190 +208
Branches 2444 2329 -115
==========================================
+ Hits 64799 65010 +211
+ Misses 11177 11174 -3
Partials 6 6
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…ontrol messages Also drop the unused direct @moq/hang dependency (kept pinned via a Bun override for transitive dedupe) to satisfy knip. Signed-off-by: streamkit-devin <devin@streamkit.dev>
Resolves RUSTSEC-2026-0258 (h2 unbounded empty DATA frames) and RUSTSEC-2026-0222 / RUSTSEC-2026-0223 (wasmtime) flagged by cargo deny check advisories in CI. Signed-off-by: streamkit-devin <devin@streamkit.dev>
Raises patch coverage on the new streamStoreHelpers handle classes introduced by the MoQ stack migration. Signed-off-by: streamkit-devin <devin@streamkit.dev>
The announcement future is recreated after each non-shutdown control message; moq-net's AnnounceConsumer replays the currently active broadcast set as initial announcements, so a broadcast announced before the restart is still observed. Pin that behavior with a test. Signed-off-by: streamkit-devin <devin@streamkit.dev>
Each catalog snapshot is written to its own single-frame group; a consumer keeping pace must observe every successive update. Signed-off-by: streamkit-devin <devin@streamkit.dev>
…fails Signed-off-by: streamkit-devin <devin@streamkit.dev>
Signed-off-by: streamkit-devin <devin@streamkit.dev>
Signed-off-by: streamkit-devin <devin@streamkit.dev>
moq-native now accepts the WebTransport session before the gateway validates the ?jwt= parameter, so an auth-enabled server closes tokenless connections after establishment instead of rejecting the handshake. The stream tests previously relied on that pre-establishment rejection to skip; now they mint a MoQ token via the admin API and fill the Stream view token input so auth-mode runs exercise the full authenticated path. Also make the 'Disconnected' status locator exact to avoid strict-mode collisions with the disconnect error banner. Signed-off-by: streamkit-devin <devin@streamkit.dev>
A biased select ensures an unread catalog snapshot in the held group cannot be displaced when the next group is already available. Signed-off-by: streamkit-devin <devin@streamkit.dev>
| /// Publish one catalog snapshot as its own single-frame group. | ||
| pub(super) fn write_catalog_json( | ||
| producer: &mut moq_net::track::Producer, | ||
| json: impl Into<bytes::Bytes>, | ||
| ) -> Result<(), moq_net::Error> { | ||
| producer.write_frame(moq_net::Timestamp::now(), json.into()) | ||
| } |
There was a problem hiding this comment.
📝 Info: Catalog updates depend on one-group-per-frame semantics
write_catalog_json writes a single frame directly on the producer rather than the old append-group/finish sequence. Observing each republished catalog depends on write_frame opening a fresh group per call, since CatalogConsumer reads one frame per group and discards the rest. New tests exercise this against the real library; note the coupling for future moq_net bumps.
Was this helpful? React with 👍 or 👎 to provide feedback.
Debug
There was a problem hiding this comment.
Correct — the vendored consumer reads one frame per group, so each catalog snapshot must land in its own group. moq_net::track::Producer::write_frame documents exactly that (each call appends a fresh group), and this is also the semantic upstream hang's own catalog publishing relies on. The back-to-back republish tests run against the real library, so a future moq_net bump that changed this would fail them immediately.
There was a problem hiding this comment.
Correct — moq_net::track::Producer::write_frame is documented as "Create a group with a single frame", so each catalog republish opens a fresh group, matching CatalogConsumer's one-frame-per-group read. The catalog republish tests run against the real library and would catch a semantic change on future moq_net bumps. Noting the coupling here for reviewers; no change needed.
Signed-off-by: streamkit-devin <devin@streamkit.dev>
|
Rebase reminder: inherit #678's advertised-tag fetch path, then bump |
Signed-off-by: streamkit-devin <devin@streamkit.dev>
| function createPublishEncoders( | ||
| broadcast: Publish.Broadcast, | ||
| capture: Publish.Video.Capture | null, | ||
| microphone: MicrophoneHandle | null, | ||
| audioEnabledInitially: boolean, | ||
| encoderConfig: Publish.Video.EncoderProps['config'] | ||
| ): { | ||
| audio: { enabled: Signal<boolean>; encoder: Publish.Audio.Encoder } | null; | ||
| video: Publish.Video.Encoder | null; | ||
| } { | ||
| let audio: { enabled: Signal<boolean>; encoder: Publish.Audio.Encoder } | null = null; | ||
| try { | ||
| if (microphone) { | ||
| const audioEnabled = new Signal(audioEnabledInitially); | ||
| audio = { | ||
| enabled: audioEnabled, | ||
| encoder: new Publish.Audio.Encoder(AUDIO_TRACK_NAME, { | ||
| broadcast, | ||
| enabled: audioEnabled, | ||
| source: microphone.source, | ||
| }), | ||
| }; | ||
| } | ||
|
|
||
| const video = capture | ||
| ? new Publish.Video.Encoder(VIDEO_TRACK_NAME, { | ||
| broadcast, | ||
| capture, | ||
| enabled: true, | ||
| config: encoderConfig, | ||
| }) | ||
| : null; | ||
|
|
||
| return { audio, video }; | ||
| } catch (e) { | ||
| audio?.encoder.close(); | ||
| capture?.close(); | ||
| broadcast.close(); | ||
| throw e; | ||
| } | ||
| } |
There was a problem hiding this comment.
🔍 Publish teardown ordering across new handle types
The rewrite splits the old monolithic Publish.Broadcast into PublishHandle owning broadcast/capture/encoders, plus renderer/emitter handles wrapping signals. createPublishEncoders closes capture and broadcast when an encoder constructor throws, and setupPublishPath closes again on its outer catch. Worth confirming these overlapping failure paths never double-close or leak a resource.
Was this helpful? React with 👍 or 👎 to provide feedback.
Debug
There was a problem hiding this comment.
Verified — the failure paths are mutually exclusive, so no double-close or leak:
- If the
Publish.Broadcastctor throws, onlycaptureexists; the inner guard closes it andcreatePublishEncodersnever runs. - If an encoder ctor throws,
createPublishEncoders's catch closesaudio.encoder/capture/broadcastexactly once;publishis stillundefined, so the outer catch'spublish?.close()is a no-op. - Once
PublishHandleis constructed (e.g. the catalog wait throws), the encoders' catch can no longer fire, and the outer catch closes everything once viapublish.close().
The outer catch's shutdownMediaSource(microphone/camera/screen) targets the source handles, which are distinct objects from the encoder/capture wrappers closed above, so there's no overlap there either. Leaving the thread open in case you want a different ownership structure.
Summary
moq-native 0.19.12/hang 0.20.6/moq-net 0.2.13— upstream split the oldmoq-litetransport intomoq-net+qmux+web-transport 0.12. UI moves to the coordinated@moq/*majors (@moq/hang 0.4,@moq/publish 0.4,@moq/watch 0.5,@moq/net 0.3,@moq/signals 0.2).origin.Consumer.announced_broadcast) instead of directget_broadcast; track subscription is async; frames carry their timestamp inhang::container::Frame(the removedTimestamp::decodepath); catalogs useCatalog::default_track()/to_json()/from_slice; server TLS fingerprints come fromserver.certificates().fingerprints()(tls_info()removed). Pull/peer nodes gained announcement-timeout + reconnect handling to preserve the previous late-publisher semantics.@moq/*API (streamStoreHelpers.ts): sharedPublish.Broadcastwith named rendition encoders (video/hd,audio/data),Watch.Broadcast/sources/decoders constructed from signals, and thin compatibility handles (VideoRendererHandle,AudioEmitterHandle) so views keep thevideoRenderer.canvas.set(el)contract. Canvas aspect ratio remains derived from decoded dimensions (verified 1:1 below, no hardcoded 4:3/16:9). Pipeline/runtime state stays WebSocket-driven via the session store — no REST snapshots added. Bunoverridespin a single@moq/net/@moq/hanginstance (duplicate nominal types otherwise break typechecking).@moq/*clients and relays but not with pre-migration (v0.5.x moq-lite generation) peers. No StreamKit config changes: env vars,skit.tomlkeys, YAML node configs, and sample pipelines are unchanged.Review & Validation
pull.rs/peer/mod.rspreserves the old "wait for late publisher, reconnect on loss" behavior (timeout → default pin; closed announcement channel → reconnect).streamStoreHelpers.tsmatches upstream@moq/publish 0.4expectations (audio gated until video catalog present).StreamView.tsx/OutputPreviewPanel.tsx(no view changes needed).Validation performed:
just lint-ui,just test-ui(84 files / 1359 tests pass; the twouseCompositorLayers*.perfunhandled-WS warnings reproduce identically onmain), Rust fmt/clippy clean, targeted MoQ tests (199 pass), fulljust testRust suite green, REUSE compliant.@moq/*console errors.Session connected + LIVE
Liveness (later frame, advanced timecode)
Benchmarks (
crates/engine/benches/— none touch MoQ; programmatic pipelines checked for node-interface changes, none needed; run to confirm no engine regression):Notes
Oneshot sample: not applicable — MoQ push/pull/peer are dynamic-only transport nodes (live QUIC sessions); they are not registered for oneshot mode, so no valid oneshot pipeline can exercise them. Existing dynamic samples (
samples/pipelines/dynamic/moq*.yml,video_moq_*.yml) exercise the changed nodes unchanged.Relationship to chore(deps): compatible dependency refresh for v0.6.0 #674: based directly on
main; independent of chore(deps): compatible dependency refresh for v0.6.0 #674. If chore(deps): compatible dependency refresh for v0.6.0 #674 merges first, expect a trivialCargo.lock/bun.lockconflict only.The
justfilelicense-check recipe's--metadata-pathplacement is rejected by currentcargo-deny 0.20.xglobal-arg parsing (pre-existing; licenses pass when invoked ascargo deny --metadata-path … check licenses). Left untouched as out of scope.Auth-mode E2E adaptation: upstream
moq-nativenow accepts the WebTransport session before the gateway validates?jwt=, so an auth-enabled server closes tokenless connections after establishment instead of rejecting the handshake (the old behavior the stream E2E tests relied on to skip). The auth-mode CI run therefore saw a transient "connected" state and failed. The stream tests now mint a MoQ token via the admin API and fill the Stream view token input, soE2E_AUTH=1runs exercise the full authenticated MoQ path end-to-end (verified locally: 4/4 pass in both auth and no-auth modes).Link to Devin session: https://staging.itsdev.in/sessions/c880623ccc5e4c3fb25fe20a785832bc
Open in Devin Desktop: https://staging.itsdev.in/desktop/session/c880623ccc5e4c3fb25fe20a785832bc?variant=devin-insiders
Requested by: @streamer45
Devin Review
52647e1