From 3349270171debaad1d6373d73c0198885a92fad8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 14:40:30 +0000 Subject: [PATCH] docs(nestsclient): record M2/M3 closures in moq-lite Lite-03 audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Fix #5 (M2 + M3 β€” STOP_SENDING for single-group cancel + RESET_STREAM with typed code on Drop replies) to the compliance audit doc. The two items shipped together because the plumbing is shared β€” extending `:quic`'s StrippedWtStream with reset/stopSending closures, extending the WebTransport interfaces, and routing through all adapters was the prerequisite for both. Updates the gap matrix (M2 + M3 β†’ βœ…), the TL;DR (now reads "six shipped fixes + M6 closed; no 🟑 items remain open"), and the deferred-items list (only L1 / L2 / L3 remain, all 🟦 with explicit rationale). Updates the audio-rooms completion-plan pointer with the new shipped count. https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq --- .../2026-04-26-audio-rooms-completion.md | 16 +-- .../2026-05-09-moq-lite-rfc-compliance.md | 115 +++++++++++++----- 2 files changed, 93 insertions(+), 38 deletions(-) diff --git a/nestsClient/plans/2026-04-26-audio-rooms-completion.md b/nestsClient/plans/2026-04-26-audio-rooms-completion.md index 51367e5aa..ec03c5f9a 100644 --- a/nestsClient/plans/2026-04-26-audio-rooms-completion.md +++ b/nestsClient/plans/2026-04-26-audio-rooms-completion.md @@ -63,13 +63,15 @@ - moq-lite wire spec + IETF gap: `nestsClient/plans/2026-04-26-moq-lite-gap.md` - moq-lite Lite-03 compliance audit (2026-05-09): `nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md` β€” - no πŸ”΄ wire-incompatibilities found; four publisher-side spec - tightenings shipped (AnnouncePlease prefix-mismatch, Subscribe - broadcast field validation, trackPriority bit-pack matching - kixelated's `PriorityHandle`, publishers-list freshness on - inbound bidi dispatch); M6 (Goaway body) closed via spec - verification (no body in Lite-03); five 🟑 / 🟦 items deferred - with explicit rationale. + no πŸ”΄ wire-incompatibilities found; six spec tightenings shipped + (AnnouncePlease prefix-mismatch, Subscribe broadcast validation, + trackPriority bit-pack matching kixelated's `PriorityHandle`, + publishers-list freshness on inbound bidi dispatch, RESET_STREAM + with typed code on Drop replies, STOP_SENDING(SUBSCRIPTION_GONE) + on dead group uni); M6 (Goaway body) closed via spec + verification (no body in Lite-03); three 🟦 items deferred with + explicit rationale (Lite-04 codec, SubscribeOk narrowing, + subscriber-driven Probe). No 🟑 items remain open. - Nostrnests integration audit (gaps + roadmap): see most recent doc in `nestsClient/plans/` - QUIC stack status: `quic/plans/2026-04-26-quic-stack-status.md` - Audio-rooms NIP draft (needs refresh after the moq-lite findings): diff --git a/nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md b/nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md index 9ab0ac233..a0cc6014c 100644 --- a/nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md +++ b/nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md @@ -20,15 +20,19 @@ Probe β€” match the reference Rust impl byte-for-byte. The known gaps are all spec-loose / future-fragile (🟑 / 🟦), not wire-incompatible (πŸ”΄). -**This audit shipped four fixes** (M1, M4, M5, L5) and **closed M6** -(verified via WebFetch that Goaway has no body in the spec, so our -existing handler is canonical). **L4** is subsumed by the M1 fix. -The remaining 🟑 / 🟦 items (M2, M3, L1, L2, L3) are deferred with -rationale below β€” each is a deliberate non-fix because either the -QUIC layer is locked (M2/M3 need WebTransport interface extensions -that touch `:quic`), the change is significant scope (L1 Lite-04 -codec rewrite), or there's no consumer for the proposed API -(L2/L3). **No πŸ”΄ wire-incompatibilities remain.** +**This audit shipped six fixes** (M1, M2, M3, M4, M5, L5) and +**closed M6** (verified via WebFetch that Goaway has no body in +the spec, so our existing handler is canonical). **L4** is subsumed +by the M1 fix. M2/M3 originally deferred under the "`:quic` is +locked" constraint; after a merge from `main` brought in the full +RFC-compliant `:quic` layer, the user lifted the constraint and +M2/M3 landed end-to-end (interface extension, all adapters, plus +production use sites in `drainOneGroup` and the inbound-bidi +dispatcher's Drop reply paths). The remaining items (L1 Lite-04 +codec, L2 SubscribeOk narrowing, L3 subscriber Probe) are +deliberate non-fixes β€” significant scope (L1) or no consumer for +the proposed API (L2/L3). **No πŸ”΄ wire-incompatibilities remain; +no 🟑 items remain open.** ## Methodology @@ -112,8 +116,8 @@ Severity legend (matches the prior QUIC audit): | -- | --------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------ | | H0 | (none β€” no wire-incompatible items found) | πŸ”΄ | β€” | β€” | n/a | | M1 | `Publisher::serve_group` priority parity | 🟑 β†’ βœ… | We assigned each new group `priority = sequence (i32 cast)`, ignoring `track.priority` and never re-prioritising in flight. kixelated computes `let priority = priority.insert(track.priority, sequence); stream.set_priority(priority.current())` (per `rs/moq-lite/src/lite/publisher.rs::serve_group`). For our single Opus track at 1 group/sec the difference was unobservable (newer-first ordering still held). | `MoqLiteSession.kt:openGroupStream` (pre-fix) | **fixed in this audit** β€” see `Fix #3` | -| M2 | `STOP_SENDING` for single-group cancel | 🟑 | The Lite-03 spec lets a receiver cancel a specific group via `STOP_SENDING` on its uni stream. We close the consumer-facing frames channel only β€” the publisher's uni stream stays open until natural FIN or transport drop. Practical impact: the publisher wastes a tiny amount of bandwidth on frames the listener will discard; not user-visible. The `:quic` `QuicStream.stopSending(errorCode)` API exists, but isn't exposed through `WebTransportReadStream`. | `MoqLiteSession.kt:670-675` (no stopSending), `WebTransportSession.kt:103-107` (read interface lacks stopSending) | open (deferred β€” needs `:quic` interface extension; user prompt locked `:quic`) | -| M3 | `RESET_STREAM` with `Error::to_code()` | 🟑 | All error / cancel paths today FIN gracefully via `runCatching { bidi.finish() }`. The Lite-03 spec says errors on any stream are conveyed by `RESET_STREAM(application_error_code = Error::to_code() u32)`. Practical impact: the peer can't tell "I'm done with this stream" apart from "this stream errored" β€” the wrapper falls back to flow-end heuristics. No moq-lite-session error path actually wants to send a coded reset today (Drop replies use FIN per spec; transport drops surface as flow-end). | `MoqLiteSession.kt` everywhere `runCatching { …finish() }` is used; no `reset(code)` calls anywhere | open (deferred β€” interface extension without consumer = dead code) | +| M2 | `STOP_SENDING` for single-group cancel | 🟑 β†’ βœ… | The Lite-03 spec lets a receiver cancel a specific group via `STOP_SENDING` on its uni stream. Pre-fix we closed the consumer-facing frames channel only β€” the publisher's uni stream stayed open until natural FIN, wasting bandwidth on frames the listener would discard plus relay queue pressure on the per-subscriber forward pipeline. The `:quic` `QuicStream.stopSending(errorCode)` API existed but wasn't exposed through `WebTransportReadStream`. Now plumbed end-to-end: extended `WebTransportReadStream` with `stopSending(code)`, plumbed through `:quic`'s `StrippedWtStream`, and `MoqLiteSession.drainOneGroup` fires `stopSending(SUBSCRIPTION_GONE)` once on the first frame that observes `sub == null`. | `MoqLiteSession.kt:drainOneGroup` (pre-fix), `WebTransportSession.kt` (read interface) | **fixed in this audit** β€” see `Fix #5` | +| M3 | `RESET_STREAM` with `Error::to_code()` | 🟑 β†’ βœ… | Pre-fix all error / cancel paths FIN'd gracefully via `runCatching { bidi.finish() }`. Lite-03 spec says errors on any stream are conveyed by `RESET_STREAM(application_error_code u32)`. The `SubscribeDrop` reply paths now write the body and then `RESET_STREAM(errorCode)` instead of `finish()`, so a peer watching only the QUIC layer (no body decode) can distinguish typed rejection from graceful "publisher gone" FIN. Plumbing extended `WebTransportWriteStream` with `reset(code)` and routed through both QUIC-backed adapters + `:quic`'s `StrippedWtStream`. | `MoqLiteSession.kt:handleInboundBidi` Drop replies (pre-fix) | **fixed in this audit** β€” see `Fix #5` | | M4 | AnnouncePlease prefix-mismatch falls back to full suffix | 🟑 β†’ βœ… | When the relay opened an Announce bidi with `prefix="X"`, our publisher emitted `Active(suffix=ourFullSuffix)` even when our suffix didn't start with `X`. The relay would observe an Active update for a broadcast it didn't ask about. In production the relay always asks for `prefix=""`, so this never bit empirically β€” but it's a spec violation. | `MoqLiteSession.kt:841-852` (pre-fix) | **fixed in this audit** β€” see `Fix #1` | | M5 | Inbound Subscribe doesn't validate broadcast field | 🟑 β†’ βœ… | When the relay opened a Subscribe bidi, we matched on `track` only, never checking `sub.broadcast == publisher.suffix`. A relay (or peer) could subscribe to broadcast `"otherPubkey"` on our connection and we'd happily route OUR audio to them. The production relay routes correctly, so this never bit empirically β€” but it's a spec violation. | `MoqLiteSession.kt:861-898` (pre-fix) | **fixed in this audit** β€” see `Fix #2` | | M6 | Goaway body decoding + migration handler | 🟑 β†’ βœ… | We recognise `ControlType::Goaway = 5` and FIN cleanly. WebFetched the kixelated reference (`rs/moq-lite/src/lite/{stream,client}.rs`): **Goaway has no body schema in moq-lite Lite-03** β€” it's a single ControlType byte with no payload, not even a migration URL. Our existing handler (recognise, log, FIN) is the canonical implementation; "no body decode" was never a gap. | `MoqLiteSession.kt:973-990`, `MoqLiteControlCodes.kt:50-58` | **closed in this audit** β€” see `M6 closure` | @@ -171,32 +175,14 @@ Severity legend (matches the prior QUIC audit): ## What's deliberately deferred -1. **STOP_SENDING + RESET_STREAM (M2 + M3).** The QUIC layer exposes - `QuicStream.stopSending(errorCode)` and `resetStream(errorCode)`, - but neither is surfaced through the WebTransport read/write - interfaces in `WebTransportSession.kt`. Adding either means: - extending `WebTransportReadStream` / `WebTransportWriteStream` - with `stopSending(code)` / `reset(code)`, extending `:quic`'s - `StrippedWtStream` with closures (touches the locked QUIC - layer), plumbing through the QUIC adapter, and wiring error-code - constants in moq-lite. Since the production relay tolerates - graceful FIN as "I'm done" without complaining, **and no - moq-lite-session error path actually wants to send a coded reset - today** (Drop replies use FIN per spec; transport drops surface - as flow-end), the interface extension would land as dead code. - Defer until either the relay starts caring or we ship a - listener-driven group-cancel feature. The user's audit prompt - explicitly locked `:quic`, which forecloses M2/M3 in this - session anyway. - -2. **Lite-04 codec (L1).** Tracked in `MoqLiteAlpn.kt:50-56`. +1. **Lite-04 codec (L1).** Tracked in `MoqLiteAlpn.kt:50-56`. Lite-04 reshapes `Announce.hops` (varint count β†’ `OriginList`), adds `AnnounceInterest.exclude_hop`, and adds `Probe.rtt`. None of the Lite-04 features are required by the production nostrnests relay. Defer until either the relay phases out Lite-03 or we need Lite-04-only features. -3. **Optional SubscribeOk narrowing (L2) + subscriber-driven Probe +2. **Optional SubscribeOk narrowing (L2) + subscriber-driven Probe (L3).** Won't fix β€” fixed-rate Opus + live-only audio rooms have no use case for either. The publisher MAY narrow `startGroup`/`endGroup` per spec but we have no group history @@ -310,6 +296,73 @@ peer-side read stream rather than peeking into private state. Regression test: `MoqLiteSessionTest.publisher_packs_trackPriority_and_sequence_into_setPriority_value`. +### Fix #5 β€” Plumb RESET_STREAM + STOP_SENDING through WebTransport (M2 + M3) + +The two items shipped together because the plumbing is shared. After +the merge from `main` brought in the full RFC-compliant `:quic` +layer (commits `9f7f6a9e..6f32975c`), the user lifted the +"`:quic` is locked" constraint that originally deferred M2/M3. + +**Plumbing** (new commit `feat(quic,nestsclient): plumb RESET_STREAM ++ STOP_SENDING through WebTransport`): + - `:quic`'s `StrippedWtStream` gains optional `reset` / + `stopSending` closures, wired in `WtPeerStreamDemux.emitStripped` + through `QuicStream.resetStream(code)` / + `QuicStream.stopSending(code)` + `driver.wakeup()`. + - `WebTransportReadStream.stopSending(code)` and + `WebTransportWriteStream.reset(code)` added to the public + interface β€” both suspending, both first-call-wins. + - All adapters (`QuicBidiStreamAdapter`, + `QuicUniWriteStreamAdapter`, `StrippedWtBidiStreamAdapter`, + `StrippedWtReadStreamAdapter`) route directly to the underlying + QUIC stream API. + - `FakeWebTransport`'s `FakeBidiStream`, `FakeReadStream`, + `ChannelWriteStream` (now public for test access) record reset + / stopSending codes in shared `AtomicLong` cells with sentinel + `NO_CODE = Long.MIN_VALUE`. New properties `lastResetCode`, + `lastStopSendingCode`, `lastPeerResetCode`, + `lastPeerStopSendingCode`, `peerStopSendingCode` give tests + typed-error introspection without mock magic. + +**M3 (publisher Drop reply uses RESET_STREAM)** (`fix(nestsclient): +RESET_STREAM with typed code after SubscribeDrop body`): + - In `handleInboundBidi`, both Drop reply paths + (`BROADCAST_DOES_NOT_EXIST`, `TRACK_DOES_NOT_EXIST`) now write + the Drop body and then `bidi.reset(errorCode)` instead of + `bidi.finish()`. + - The errorCode matches the body's `errorCode` field, so a peer + that decodes the Drop sees the same number as one that only + sees the RESET_STREAM frame at the QUIC layer. + - Strengthened the existing M5 + Drop tests to additionally + assert `subBidi.lastPeerResetCode == errorCode` so the + typed-error contract is locked. + +**M2 (listener stopSending on dead group)** (`fix(nestsclient): +STOP_SENDING on group uni when subscription already canceled`): + - In `drainOneGroup`, the first frame that observes `sub == + null` (subscription already canceled) fires + `stream.stopSending(MoqLiteStreamCancelCode.SUBSCRIPTION_GONE)` + once, latched, so the publisher abandons in-flight retransmits + instead of wasting bandwidth. + - New error-code constant + `MoqLiteStreamCancelCode.SUBSCRIPTION_GONE = 0x10L` lives in + `MoqLiteMessages.kt` next to `MoqLiteSubscribeDropCode`. + - Practical impact in production: the relay's per-subscriber + forward pipeline (already documented in the stream-cliff + investigation) keeps queueing groups for slow / canceled + subscribers; an early `stopSending` at the listener side + relieves the relay's queue earlier, complementing the + `framesPerGroup` mitigation. + +Regression tests: + - `MoqLiteSessionTest.publisher_replies_subscribeDrop_when_broadcast_does_not_match` + β€” additional `lastPeerResetCode` assertion (M3). + - `MoqLiteSessionTest.publisher_replies_subscribeDrop_when_track_is_not_published` + β€” additional `lastPeerResetCode` assertion (M3). + - `MoqLiteSessionTest.listener_stopSending_group_uni_when_subscription_already_canceled` + β€” new test; verifies `peerStopSendingCode` lands the correct + cancel code (M2). + ### Fix #4 β€” Refresh publisher list per-dispatch on inbound bidi (L5) `handleInboundBidi` previously snapshotted the publisher list at the