fix(nests): close the relay-cliff at the source — framesPerGroup 10→50, cliff-threshold 4s→2.5s

Two-phone production logs at commit 6e4df4a (run 18:37) showed the
listener-side cliff still hits at ~16 s of streaming (51 groups
delivered, sender continued to seq=80 = ~6 s of audio lost at the
tail before the receiver recycled). Recycling alone can't fix this
— it only spaces the pain out. The user wants no audio drops mid-
transmission, period. The leverage is to keep the relay's
per-subscriber forward queue from filling at all.

The cliff is rate-limited per uni-stream-creation, not per byte or
per frame — moq-rs's `serve_group` task pool can't drain new
`open_uni().await`s fast enough at high stream rates. Cutting the
stream creation rate cuts the cliff window proportionally:

  framesPerGroup |  streams/sec @ 50 fps  |  observed cliff window
  ---------------+------------------------+----------------------
       1         |       50               |   ~3 s (commit d6517cf)
       5         |       10               |   ~13 s
      10         |        5               |   ~16 s (commit 6e4df4a)
      50         |        1               |   not reached in
                                              `fpg-all` sweeps
     100         |      0.5               |   never observed

Bumping default to 50 (1 s of audio per uni stream, 1 stream/sec)
puts us in the regime where the relay's queue does not measurably
fill. `fpg-all` (100 frames in a single group) routinely delivers
100/100 in production sweeps, so 50 is well clear of the cliff
edge. We pick 50 not 100 because:

  - Late-join gap is bounded by group size — a listener joining
    mid-broadcast waits one group boundary for the first frame.
    1 s is within the ~250 ms AudioTrack jitter buffer +
    `ROOM_PLAYER_PREROLL_FRAMES = 5` audio-buffer envelope; 2 s
    starts to be perceptible.
  - QUIC stream RST drops the whole group on full reset. 1 s of
    skip on rare RST is bearable; 2 s is a noticeable seam.
  - Sender encode latency stays at 1 s — no worse than the
    natural buffering audio rooms already do for jitter.

Belt-and-suspenders: also tighten the cliff-detector's silence
threshold from 4 s to 2.5 s. Healthy streams now arrive every ~1 s
(one `drainOneGroup` FIN per group), so 2.5 s of silence is
unambiguously past two missed group cycles. Catches a still-
possible cliff event ~1.5 s earlier, shaving the audible gap from
~6 s (4 s detection + 2 s reconnect) to ~4.5 s if recovery is
needed at all.

Tests: existing `framesPerGroup`-explicit interop scenarios
(`fpg5`, `fpg20`, `fpg-all`) are unaffected — they pass an
explicit value and don't read the default. `CliffDetectorTest`
boundary cases updated to the new 2.5 s threshold;
`returnsAllStalledSpeakersAtOnceMixedWithFreshOnes` re-spaced its
fixture so charlie's 1.5 s frame age stays under threshold while
alice/bob's 4 s ages exceed it. 12/12 pass.
This commit is contained in:
Claude
2026-05-05 22:47:07 +00:00
parent 6e4df4aad4
commit a36ccb5692
3 changed files with 64 additions and 34 deletions
@@ -1702,12 +1702,19 @@ const val ROOM_AUDIO_MAX_LATENCY_MS: Long = 500L
* orchestrator opens a fresh QUIC transport. Resets the relay's
* per-subscriber forward queue.
*
* 4 s gives enough headroom to ride out a publisher-side group-rollover
* hiccup (typically < 200 ms) without false-positive recycling, while
* keeping the audible gap from a real cliff to ~5 s of silence at
* worst (4 s detection + ~1 s reconnect handshake).
* 2 500 ms is past every legitimate group boundary (`framesPerGroup =
* 50` packs 1 s of audio per uni stream, so the receiver sees one
* `drainOneGroup` FIN every ~1 s on a healthy stream — 2.5 s of
* silence means more than two group cycles missed, very likely a
* cliff). Earlier than 2 s would false-positive on the natural 1 s
* cadence; later than 3 s wastes audible audio at every cliff edge.
*
* Production logs (`6e4df4a` run 18:37:43..18:38:08) showed the
* cliff-after-streaming case losing ~6 s of audio at the 4 s
* detector + ~2 s reconnect handshake. Tightening detection to
* 2.5 s shaves ~1.5 s off the audible gap.
*/
const val ROOM_AUDIO_CLIFF_TIMEOUT_MS: Long = 4_000L
const val ROOM_AUDIO_CLIFF_TIMEOUT_MS: Long = 2_500L
/**
* How often the cliff detector wakes up to scan
@@ -103,14 +103,14 @@ class CliffDetectorTest {
@Test
fun returnsEmptyJustBeforeThreshold() {
// Boundary case: 1 ms under the 4 s threshold. The detector
// Boundary case: 1 ms under the 2.5 s threshold. The detector
// should still consider this healthy. Relevant because audio
// groups arrive at ~100 ms cadence (framesPerGroup=10), so a
// groups arrive at ~1 s cadence (framesPerGroup=50), so a
// false positive at ≈ threshold would recycle on every
// group-rollover hiccup.
val ts = TestTimeSource()
val frameMark = ts.markNow()
ts += 3_999.milliseconds
ts += 2_499.milliseconds
val result =
computeStalledSpeakers(
@@ -129,7 +129,7 @@ class CliffDetectorTest {
// user-visible (this is the "audio went silent" detection).
val ts = TestTimeSource()
val frameMark = ts.markNow()
ts += 4_000.milliseconds
ts += 2_500.milliseconds
val result =
computeStalledSpeakers(
@@ -164,12 +164,14 @@ class CliffDetectorTest {
// should fire for the stalled set and leave charlie alone —
// the `recycleSession` itself takes the whole listener down,
// but the test here is the predicate's classification.
// Threshold = 2.5 s default. Spacings: alice/bob 4 s old (past),
// charlie 1.5 s old (under).
val ts = TestTimeSource()
val aliceFrame = ts.markNow()
val bobFrame = ts.markNow()
ts += 3_000.milliseconds
ts += 2_500.milliseconds
val charlieFrame = ts.markNow()
ts += 3_000.milliseconds
ts += 1_500.milliseconds
val result =
computeStalledSpeakers(
@@ -251,7 +253,7 @@ class CliffDetectorTest {
lastFrameAt = mapOf(ALICE to frameMark),
lastRecycleAt = null,
)
assertTrue(withDefault.isEmpty(), "1 s elapsed shouldn't trip 4 s default")
assertTrue(withDefault.isEmpty(), "1 s elapsed shouldn't trip 2.5 s default")
val withTightTimeout =
computeStalledSpeakers(