fix(quic): raise initialMaxStreamsUni from 100 → 10000 to dodge MAX_STREAMS_UNI cliff

Production trace pinned the audio-cliff to the exact moment our
writer emits its first MAX_STREAMS_UNI extension (frame 0x13).
With the default cap of 100 and the half-window threshold check at
count=50, the listener emits MAX_STREAMS_UNI(150) at count=50, then:

    16:47:18.092 MAX_STREAMS_UNI emit oldCap=100 → newCap=150
    16:47:18.210 transport delivered uni stream #50 ← LAST stream
    16:47:20.204 udpRecvDatagrams=431
    16:47:25.209 udpRecvDatagrams=443  (+12)
    16:47:30.213 udpRecvDatagrams=443  ← FROZEN forever

`udpRecvDatagrams` (kernel-level UDP receive counter) freezes:
the relay's UDP packets stop reaching the OS. Our QUIC state still
believes the connection is alive (sendCredit=2^62-1, pendingBytes=0,
peerInitMaxStreamsUni=10000) but `peerInitiatedUniCount` is stuck at
51 forever. The relay, meanwhile, FINs both inbound SUBSCRIBE bidis
on the publisher side at +10s, telling the publisher "this listener
went away" — split-brain.

The cliff is repeatable but not at a fixed stream count: prior runs
saw cliffs at 124 (where the *second* bump at count=100 would have
fired) and 61 (where some intermediate condition tripped). The
common thread is the rolling-extension path, not the absolute count.

Without a packet capture from the relay we can't pin whether our
MAX_STREAMS_UNI frame is malformed, mis-sequenced, ill-timed against
moq-rs's flow-control state machine, or something subtler. But moq-rs
itself advertises `max_concurrent_uni_streams = 10000` for exactly
the audio-rooms workload — every Opus group is a fresh peer-initiated
uni stream — and matching that takes the listener to ~5 000 groups
(at framesPerGroup=5, Opus 20ms, that's ~8 minutes of audio) before
the half-window threshold trips at all. For any realistic Nest
duration we never need to extend.

The extension code path stays in QuicConnectionWriter.kt:395 — it's
correct per RFC 9000 §19.11, the bug is in moq-rs (or its Quinn
config) and we're working around it for now.

Side effect: we also bump the bidi cap implicitly — but that's
controlled by initialMaxStreamsBidi (still 100), unaffected by this
change. Bidi streams are control-stream-only in moq-lite (Subscribe
+ Announce), well below 100.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
This commit is contained in:
Claude
2026-05-04 20:53:58 +00:00
parent 36c707f98a
commit f32131d073
@@ -36,7 +36,32 @@ data class QuicConnectionConfig(
val initialMaxStreamDataBidiRemote: Long = 1L * 1024 * 1024,
val initialMaxStreamDataUni: Long = 1L * 1024 * 1024,
val initialMaxStreamsBidi: Long = 100L,
val initialMaxStreamsUni: Long = 100L,
/**
* Initial peer-initiated unidirectional stream limit. moq-rs's Quinn
* stack advertises `max_concurrent_uni_streams = 10000` for the same
* reason we now do: every Opus group is a fresh peer-initiated uni
* stream, so a long broadcast accumulates many lifetime stream IDs.
*
* Production tracing on a listener phone (Phone B receiving a Phone-A
* broadcast against `moq.nostrnests.com`) showed the cliff lands at
* exactly the moment our writer emits its first `MAX_STREAMS_UNI`
* extension at the half-window threshold (count=50 with cap=100):
* the listener receives one more stream after the bump, then UDP
* goes silent at the kernel level (`udpRecvDatagrams` frozen) while
* our QUIC state still believes the connection is alive. The relay
* propagates the listener's disconnect to the publisher (publisher
* sees `inbound SUBSCRIBE FIN'd`), but our QUIC stack never observes
* it — split-brain. The trace is reproducible across runs.
*
* The rolling-extension code path remains in place for correctness on
* other peers (and so 100×fpg=5 broadcasts of audio-rooms longer than
* ~50 s × 5 ≈ 250 s still extend cleanly), but with this cap raised
* to 10 000 the listener won't actually need to extend until 5 000+
* groups have flowed — well past any realistic Nest duration. That
* sidesteps the malformed-or-mis-sequenced extension that's tripping
* the relay today.
*/
val initialMaxStreamsUni: Long = 10_000L,
val maxIdleTimeoutMillis: Long = 30_000L,
val maxUdpPayloadSize: Long = 1452L,
val activeConnectionIdLimit: Long = 4L,