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:
+9
@@ -906,6 +906,15 @@ private fun NestsListenerState.toUiState(previous: ConnectionUiState): Connectio
|
||||
ConnectionUiState.Connected
|
||||
}
|
||||
|
||||
is NestsListenerState.Reconnecting -> {
|
||||
// Reconnect is conceptually a "we're connecting again";
|
||||
// surface it under the same UI bucket as the initial
|
||||
// OpeningTransport step so the existing chip/spinner
|
||||
// works without UI changes. A future commit can add a
|
||||
// dedicated UI state for "Attempt N in Mms".
|
||||
ConnectionUiState.Connecting(step = ConnectionUiState.Step.OpeningTransport)
|
||||
}
|
||||
|
||||
is NestsListenerState.Failed -> {
|
||||
ConnectionUiState.Failed(reason)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+71
@@ -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,
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NestsReconnectPolicyTest {
|
||||
@Test
|
||||
fun firstAttemptUsesInitialDelay() {
|
||||
val p = NestsReconnectPolicy(initialDelayMs = 1_000, multiplier = 2.0, maxDelayMs = 30_000)
|
||||
assertEquals(1_000, p.delayForAttempt(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delayDoublesPerAttemptUntilMax() {
|
||||
val p = NestsReconnectPolicy(initialDelayMs = 1_000, multiplier = 2.0, maxDelayMs = 30_000)
|
||||
assertEquals(2_000, p.delayForAttempt(2))
|
||||
assertEquals(4_000, p.delayForAttempt(3))
|
||||
assertEquals(8_000, p.delayForAttempt(4))
|
||||
assertEquals(16_000, p.delayForAttempt(5))
|
||||
// Step 6 doubles to 32_000 — clamps at maxDelayMs.
|
||||
assertEquals(30_000, p.delayForAttempt(6))
|
||||
// Beyond ceiling: still clamped.
|
||||
assertEquals(30_000, p.delayForAttempt(20))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonStandardMultiplierStillRespectsCeiling() {
|
||||
val p = NestsReconnectPolicy(initialDelayMs = 500, multiplier = 3.0, maxDelayMs = 5_000)
|
||||
assertEquals(500, p.delayForAttempt(1))
|
||||
assertEquals(1_500, p.delayForAttempt(2))
|
||||
assertEquals(4_500, p.delayForAttempt(3))
|
||||
// 4_500 × 3 = 13_500 → clamps at 5_000.
|
||||
assertEquals(5_000, p.delayForAttempt(4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroOrNegativeAttemptIsZeroDelay() {
|
||||
val p = NestsReconnectPolicy()
|
||||
assertEquals(0L, p.delayForAttempt(0))
|
||||
assertEquals(0L, p.delayForAttempt(-1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isExhaustedHonoursMaxAttempts() {
|
||||
val p = NestsReconnectPolicy(maxAttempts = 3)
|
||||
assertFalse(p.isExhausted(0))
|
||||
assertFalse(p.isExhausted(1))
|
||||
assertFalse(p.isExhausted(2))
|
||||
assertTrue(p.isExhausted(3))
|
||||
assertTrue(p.isExhausted(4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noRetryPolicyExhaustsImmediatelyAfterFirstAttempt() {
|
||||
// The "first-shot-or-fail" policy used by tests / single-shot demos.
|
||||
val p = NestsReconnectPolicy.NoRetry
|
||||
assertFalse(p.isExhausted(0))
|
||||
assertTrue(p.isExhausted(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsInvalidConstructorInputs() {
|
||||
assertFailsWith<IllegalArgumentException> { NestsReconnectPolicy(initialDelayMs = 0) }
|
||||
assertFailsWith<IllegalArgumentException> { NestsReconnectPolicy(multiplier = 1.0) }
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
NestsReconnectPolicy(initialDelayMs = 5_000, maxDelayMs = 1_000)
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> { NestsReconnectPolicy(maxAttempts = 0) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user