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) } + } +}