feat(audio-rooms): NestsReconnectPolicy + Reconnecting state (T4 #2 foundation)

Lays the foundation for the moq-lite reconnect-with-backoff path:

  NestsReconnectPolicy — exponential backoff settings.
                         (initial=1s, multiplier=2, max=30s) by
                         default, mirroring kixelated/moq's JS
                         reference (`delay: { initial: 1000,
                         multiplier: 2, max: 30000 }`).
                         maxAttempts defaults to Int.MAX_VALUE
                         since a long-running room should keep
                         trying as long as the user hasn't left;
                         the Composable's onDispose is the cancel
                         signal.
                         NoRetry sentinel for first-shot-or-fail
                         tests / single-room demos.

  delayForAttempt(n)   — 1-indexed; doubles per attempt; clamps at
                         maxDelayMs. n<1 returns 0.
  isExhausted(n)       — n >= maxAttempts (next retry forbidden).
  init { require(...) } — ctor guards reject zero/negative initial,
                          multiplier <= 1, max < initial, attempts < 1.

  NestsListenerState.Reconnecting / NestsSpeakerState.Reconnecting
  — new state variants with (attempt, delayMs). UI consumes via
  the existing NestsListenerState → ConnectionUiState mapper which
  surfaces Reconnecting under OpeningTransport for the v1 chip;
  a future commit can add a dedicated "Attempt N in Mms" UI.

Tests:
  * First attempt = initialDelayMs
  * Doubling via attempt index until maxDelayMs ceiling
  * Non-standard multiplier still respects ceiling
  * Zero/negative attempt → 0 delay
  * isExhausted at the boundary
  * NoRetry exhausts after attempt 1
  * Constructor rejects invalid inputs

The orchestration layer (mint-fresh-JWT → reopen WT → re-issue
SubscribeHandles + the speaker-side equivalent) is the heavier
follow-up. Deliberately split because:
  1. The state + policy can ship and be consumed by callers ready
     to handle Reconnecting today — no behaviour change for those
     who don't.
  2. The full session-resurrection path needs `MutableSharedFlow`
     buffering per SubscribeHandle so app code's `Flow<MoqObject>`
     doesn't notice the swap. Substantial refactor; better as its
     own commit with its own tests.
This commit is contained in:
Claude
2026-04-26 23:05:19 +00:00
parent 440fc61104
commit 2e38aa6c77
5 changed files with 199 additions and 0 deletions
@@ -89,6 +89,21 @@ sealed class NestsListenerState {
val negotiatedMoqVersion: Long,
) : NestsListenerState()
/**
* The previous session dropped and the reconnect orchestrator
* is waiting [delayMs] before trying again. [attempt] is
* 1-indexed (1 = first retry after the original session
* failed). UI shows a "Reconnecting…" chip; the session does
* NOT retry while the listener stays in this state — the
* orchestrator transitions through [Connecting] for the next
* attempt and back to [Failed] / [Connected] once the open
* call resolves.
*/
data class Reconnecting(
val attempt: Int,
val delayMs: Long,
) : NestsListenerState()
/** A connect attempt or live session failed. UI shows [reason] to the user. */
data class Failed(
val reason: String,
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient
/**
* Exponential-backoff settings for the reconnect path that
* [connectNestsListener] / [connectNestsSpeaker] consult when the
* underlying WebTransport session drops mid-room. Mirrors the
* `delay: { initial, multiplier, max }` shape kixelated/moq's JS
* reference uses.
*
* Defaults match the JS reference (1 s → 30 s ceiling, doubling).
* `maxAttempts` defaults to unbounded — a long-running room should
* keep trying as long as the user hasn't left the screen; the
* Composable's `DisposableEffect.onDispose` is the cancel signal.
*/
data class NestsReconnectPolicy(
val initialDelayMs: Long = 1_000,
val multiplier: Double = 2.0,
val maxDelayMs: Long = 30_000,
val maxAttempts: Int = Int.MAX_VALUE,
) {
init {
require(initialDelayMs > 0) { "initialDelayMs must be > 0, got $initialDelayMs" }
require(multiplier > 1.0) { "multiplier must be > 1.0 to grow, got $multiplier" }
require(maxDelayMs >= initialDelayMs) {
"maxDelayMs ($maxDelayMs) must be >= initialDelayMs ($initialDelayMs)"
}
require(maxAttempts >= 1) { "maxAttempts must be >= 1, got $maxAttempts" }
}
/**
* Delay for the [attempt]-th retry (1-indexed). attempt=1 →
* [initialDelayMs]; subsequent attempts multiply by [multiplier]
* and clamp at [maxDelayMs]. attempt < 1 returns 0.
*/
fun delayForAttempt(attempt: Int): Long {
if (attempt < 1) return 0L
// Compute attempt-1 doublings so attempt=1 returns initial.
var d = initialDelayMs.toDouble()
repeat(attempt - 1) { d *= multiplier }
val clamped = d.coerceAtMost(maxDelayMs.toDouble())
return clamped.toLong()
}
/** True when [attempt] has hit [maxAttempts] (the next retry is forbidden). */
fun isExhausted(attempt: Int): Boolean = attempt >= maxAttempts
companion object {
/** Off-switch for callers that want first-shot-or-fail (tests, single-room demos). */
val NoRetry = NestsReconnectPolicy(maxAttempts = 1)
}
}
@@ -106,6 +106,17 @@ sealed class NestsSpeakerState {
val isMuted: Boolean,
) : NestsSpeakerState()
/**
* The previous session dropped and the reconnect orchestrator
* is waiting [delayMs] before trying again. Mirror of
* [NestsListenerState.Reconnecting]; see that class's KDoc for
* the state-machine contract.
*/
data class Reconnecting(
val attempt: Int,
val delayMs: Long,
) : NestsSpeakerState()
data class Failed(
val reason: String,
val cause: Throwable? = null,