From f32131d073fc5b53cabe2bf1625cb83c060aa417 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 20:53:58 +0000 Subject: [PATCH] =?UTF-8?q?fix(quic):=20raise=20initialMaxStreamsUni=20fro?= =?UTF-8?q?m=20100=20=E2=86=92=2010000=20to=20dodge=20MAX=5FSTREAMS=5FUNI?= =?UTF-8?q?=20cliff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../quic/connection/QuicConnectionConfig.kt | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionConfig.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionConfig.kt index 0e33ee2fc..b170c1b62 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionConfig.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionConfig.kt @@ -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,