From 2e38aa6c7762a87b8c93893e9ca0e9f77419fef5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 23:05:19 +0000 Subject: [PATCH] feat(audio-rooms): NestsReconnectPolicy + Reconnecting state (T4 #2 foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` doesn't notice the swap. Substantial refactor; better as its own commit with its own tests. --- .../commons/viewmodels/AudioRoomViewModel.kt | 9 ++ .../nestsclient/NestsListener.kt | 15 +++ .../nestsclient/NestsReconnectPolicy.kt | 71 ++++++++++++++ .../vitorpamplona/nestsclient/NestsSpeaker.kt | 11 +++ .../nestsclient/NestsReconnectPolicyTest.kt | 93 +++++++++++++++++++ 5 files changed, 199 insertions(+) create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsReconnectPolicy.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsReconnectPolicyTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt index d062c14fd..b65f2edc6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt @@ -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) } diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt index 1c4221c8b..5754cec78 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt @@ -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, diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsReconnectPolicy.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsReconnectPolicy.kt new file mode 100644 index 000000000..8947ffe46 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsReconnectPolicy.kt @@ -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) + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt index 34acca471..0e59cb7cd 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt @@ -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, diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsReconnectPolicyTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsReconnectPolicyTest.kt new file mode 100644 index 000000000..b1fc3e07d --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsReconnectPolicyTest.kt @@ -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 { NestsReconnectPolicy(initialDelayMs = 0) } + assertFailsWith { NestsReconnectPolicy(multiplier = 1.0) } + assertFailsWith { + NestsReconnectPolicy(initialDelayMs = 5_000, maxDelayMs = 1_000) + } + assertFailsWith { NestsReconnectPolicy(maxAttempts = 0) } + } +}