feat(nests): proactive JWT refresh + reconnect for speaker path

Mirror the listener's ReconnectingNestsListener for the publish side.
moq-auth issues 600 s bearer tokens; without proactive refresh, a user
holding the stage past 10 minutes silently drops when the relay tears
down the WebTransport session and stays off the air until they manually
re-tap Talk. The new wrapper recycles the session at 540 s so the relay
never sees an expired token, and re-issues publishing onto each fresh
session with the user's mute intent replayed on the new handle.

VM swap is a one-line change to DefaultNestsSpeakerConnector. The
caller-owned BroadcastHandle is now the wrapper's stable handle that
survives every refresh.

Six unit tests cover happy path, refresh-without-failure-state, mute
replay across recycle, close idempotence, first-attempt-failure
exception propagation, and post-close startBroadcasting guard.

https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
This commit is contained in:
Claude
2026-04-28 03:17:57 +00:00
parent 2027f6ad37
commit d37eb10b8c
3 changed files with 890 additions and 2 deletions
@@ -0,0 +1,474 @@
/*
* 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 com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.nestsclient.transport.WebTransportSession
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ReconnectingNestsSpeakerTest {
private val room =
NestsRoomConfig(
authBaseUrl = "https://relay.example.com/api/v1/nests",
endpoint = "https://relay.example.com/moq",
hostPubkey = "0".repeat(64),
roomId = "abc",
)
private val signer = NostrSignerInternal(KeyPair())
// The custom `connector` lambda below short-circuits the real
// mintToken / WebTransport handshake, so these two sentinels
// only satisfy the function signature — they're never invoked.
private val httpClient =
object : NestsClient {
override suspend fun mintToken(
room: NestsRoomConfig,
publish: Boolean,
signer: NostrSigner,
): String = error("not invoked — connector overrides")
}
private val transport =
object : WebTransportFactory {
override suspend fun connect(
authority: String,
path: String,
bearerToken: String?,
): WebTransportSession = error("not invoked — connector overrides")
}
/**
* Scripted [BroadcastHandle] paired with a [ScriptedSpeaker].
* Tracks `setMuted` / `close` calls and exposes the latest
* desired-mute value so tests can assert that the wrapper
* replayed mute intent on a fresh handle after a recycle.
*/
private class ScriptedBroadcastHandle : BroadcastHandle {
// Atomic plumbing — the broadcast pump on Dispatchers.Default
// races against the runBlocking-thread assertions, same
// reasoning as ReconnectingNestsListenerTest's ScriptedListener.
// Backing-property names match the public projection so
// ktlint's `standard:backing-property-naming` rule passes.
private val muted = AtomicBoolean(false)
private val closed = AtomicBoolean(false)
private val setMutedCount = AtomicInteger(0)
private val closeCount = AtomicInteger(0)
override val isMuted: Boolean get() = muted.get()
val isClosed: Boolean get() = closed.get()
val setMutedCalls: Int get() = setMutedCount.get()
val closeCalls: Int get() = closeCount.get()
override suspend fun setMuted(muted: Boolean) {
this.muted.set(muted)
setMutedCount.incrementAndGet()
}
override suspend fun close() {
closed.set(true)
closeCount.incrementAndGet()
}
}
/**
* Scripted [NestsSpeaker] that opens in
* [NestsSpeakerState.Connected] by default. Each call to
* [startBroadcasting] returns a fresh [ScriptedBroadcastHandle]
* the test can introspect.
*/
private class ScriptedSpeaker(
connectedRoom: NestsRoomConfig,
moqVersion: Long = 1,
) : NestsSpeaker {
private val mutableState =
MutableStateFlow<NestsSpeakerState>(
NestsSpeakerState.Connected(connectedRoom, moqVersion),
)
override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow()
private val _startCount = AtomicInteger(0)
val startCount: Int get() = _startCount.get()
val handles = mutableListOf<ScriptedBroadcastHandle>()
override suspend fun startBroadcasting(): BroadcastHandle {
_startCount.incrementAndGet()
val handle = ScriptedBroadcastHandle()
handles += handle
// Mirror the production speaker's contract: transition
// Connected → Broadcasting on startBroadcasting.
val current = mutableState.value
if (current is NestsSpeakerState.Connected) {
mutableState.value =
NestsSpeakerState.Broadcasting(
room = current.room,
negotiatedMoqVersion = current.negotiatedMoqVersion,
isMuted = false,
)
}
return handle
}
override suspend fun close() {
mutableState.value = NestsSpeakerState.Closed
}
fun fail(reason: String) {
mutableState.value = NestsSpeakerState.Failed(reason)
}
}
/**
* Pre-fail the connector for the first N attempts so the
* orchestrator's failure path is exercised.
*/
private class ScriptedFailure : NestsSpeaker {
override val state: StateFlow<NestsSpeakerState> =
MutableStateFlow(NestsSpeakerState.Failed("scripted-fail")).asStateFlow()
override suspend fun startBroadcasting(): BroadcastHandle = error("never connected")
override suspend fun close() {}
}
@Test
fun startBroadcasting_returns_handle_against_first_session() =
runBlocking {
val scope = CoroutineScope(SupervisorJob())
try {
val first = ScriptedSpeaker(room)
val reconnecting =
connectReconnectingNestsSpeaker(
httpClient = httpClient,
transport = transport,
scope = scope,
room = room,
signer = signer,
speakerPubkeyHex = "speaker-pubkey",
captureFactory = { error("not invoked — connector overrides") },
encoderFactory = { error("not invoked — connector overrides") },
policy = NestsReconnectPolicy(initialDelayMs = 1L),
connector = { first },
)
val handle = reconnecting.startBroadcasting()
// Wait for the pump to actually open the underlying
// broadcast against the first session.
withTimeout(5_000L) {
while (first.startCount == 0) delay(5)
}
assertEquals(1, first.startCount)
assertEquals(1, first.handles.size)
assertFalse(handle.isMuted, "fresh handle should not be muted")
reconnecting.close()
} finally {
scope.cancel()
}
}
@Test
fun proactive_token_refresh_recycles_speaker_without_failure_state() =
runBlocking {
val scope = CoroutineScope(SupervisorJob())
try {
val first = ScriptedSpeaker(room)
val second = ScriptedSpeaker(room)
val third = ScriptedSpeaker(room)
val speakersInOrder = mutableListOf(first, second, third)
// Synchronized list — the watcher coroutine appends
// concurrently with the assertion's `.any { ... }`,
// and a plain ArrayList raises CME on iteration. The
// listener test gets away with a plain list because
// its watcher is cancelled by `reconnecting.close()`
// before the assertion runs in some timing windows;
// we don't rely on that here.
val seenStates: MutableList<NestsSpeakerState> =
java.util.Collections.synchronizedList(mutableListOf())
val reconnecting =
connectReconnectingNestsSpeaker(
httpClient = httpClient,
transport = transport,
scope = scope,
room = room,
signer = signer,
speakerPubkeyHex = "speaker-pubkey",
captureFactory = { error("unused") },
encoderFactory = { error("unused") },
// 50 ms refresh — small enough to fire
// quickly; large enough that openOnce
// resolves Connected first.
tokenRefreshAfterMs = 50L,
connector = { speakersInOrder.removeAt(0) },
)
val watcher =
scope.launch {
reconnecting.state.collect { seenStates += it }
}
val handle = reconnecting.startBroadcasting()
// Wait for at least 2 underlying speakers to have
// been consumed (initial + first refresh).
withTimeout(5_000L) {
while (speakersInOrder.size > 1) delay(10)
}
// Wait for the broadcast pump to have started a
// broadcast on the second session.
withTimeout(5_000L) {
while (second.startCount == 0) delay(5)
}
// Critical postcondition: the wrapper's outward
// state must NEVER show Reconnecting or Failed
// during a clean refresh — the user-visible UI
// stays Broadcasting (or briefly Connected during
// the cutover) throughout.
//
// Cancel the watcher BEFORE the assertion so we can
// iterate seenStates without racing against further
// appends. (synchronizedList only guards individual
// ops, not iteration.)
watcher.cancel()
watcher.join()
val snapshot: List<NestsSpeakerState> = synchronized(seenStates) { seenStates.toList() }
val sawReconnecting = snapshot.any { it is NestsSpeakerState.Reconnecting }
val sawFailed = snapshot.any { it is NestsSpeakerState.Failed }
assertTrue(
!sawReconnecting && !sawFailed,
"proactive refresh must not surface Reconnecting/Failed; saw=$snapshot",
)
// Both speakers should have seen exactly one
// startBroadcasting — the pump re-issues against
// the new session, doesn't double-broadcast on
// the old one.
assertEquals(1, first.startCount)
assertEquals(1, second.startCount)
handle.close()
reconnecting.close()
} finally {
scope.cancel()
}
}
@Test
fun setMuted_intent_replays_on_new_session_after_refresh() =
runBlocking {
val scope = CoroutineScope(SupervisorJob())
try {
val first = ScriptedSpeaker(room)
val second = ScriptedSpeaker(room)
val speakersInOrder = mutableListOf(first, second)
val reconnecting =
connectReconnectingNestsSpeaker(
httpClient = httpClient,
transport = transport,
scope = scope,
room = room,
signer = signer,
speakerPubkeyHex = "speaker-pubkey",
captureFactory = { error("unused") },
encoderFactory = { error("unused") },
tokenRefreshAfterMs = 80L,
connector = { speakersInOrder.removeAt(0) },
)
val handle = reconnecting.startBroadcasting()
withTimeout(5_000L) {
while (first.startCount == 0) delay(5)
}
// User mutes mid-broadcast on the first session.
handle.setMuted(true)
withTimeout(5_000L) {
while (first.handles[0].setMutedCalls == 0) delay(5)
}
assertTrue(first.handles[0].isMuted, "first handle should be muted by user toggle")
// Wait for refresh to swap to the second session.
withTimeout(5_000L) {
while (second.startCount == 0) delay(5)
}
// Critical postcondition: the new underlying handle
// must have inherited the muted=true intent without
// the user calling setMuted again.
assertTrue(
second.handles[0].isMuted,
"post-refresh handle must inherit muted=true; setMutedCalls=${second.handles[0].setMutedCalls}",
)
handle.close()
reconnecting.close()
} finally {
scope.cancel()
}
}
@Test
fun close_cancels_pump_and_closes_live_handle() =
runBlocking {
val scope = CoroutineScope(SupervisorJob())
try {
val only = ScriptedSpeaker(room)
val reconnecting =
connectReconnectingNestsSpeaker(
httpClient = httpClient,
transport = transport,
scope = scope,
room = room,
signer = signer,
speakerPubkeyHex = "speaker-pubkey",
captureFactory = { error("unused") },
encoderFactory = { error("unused") },
connector = { only },
)
val handle = reconnecting.startBroadcasting()
withTimeout(5_000L) {
while (only.startCount == 0) delay(5)
}
val live = only.handles[0]
handle.close()
// Live handle should be closed exactly once after
// close(). The pump-side close+the user-side close
// race; the handle's own idempotence guarantees
// exactly one effective close.
withTimeout(5_000L) {
while (!live.isClosed) delay(5)
}
assertTrue(live.isClosed)
reconnecting.close()
} finally {
scope.cancel()
}
}
@Test
fun first_attempt_failure_throws_from_connect() =
runBlocking {
val scope = CoroutineScope(SupervisorJob())
try {
// Connector returns a speaker already in Failed
// state — connectReconnectingNestsSpeaker should
// surface this as a thrown NestsException rather
// than returning a wrapper in Failed state, so the
// VM's `try/catch` around the connect call lights
// up.
var threw: Throwable? = null
try {
connectReconnectingNestsSpeaker(
httpClient = httpClient,
transport = transport,
scope = scope,
room = room,
signer = signer,
speakerPubkeyHex = "speaker-pubkey",
captureFactory = { error("unused") },
encoderFactory = { error("unused") },
policy = NestsReconnectPolicy(maxAttempts = 1),
connector = { ScriptedFailure() },
)
} catch (t: Throwable) {
threw = t
}
assertTrue(
threw is NestsException,
"first-attempt failure should throw NestsException; got $threw",
)
assertTrue(
threw.message?.contains("scripted-fail") == true,
"exception should preserve underlying reason; got ${threw.message}",
)
} finally {
scope.cancel()
}
}
@Test
fun startBroadcasting_after_close_throws() =
runBlocking {
val scope = CoroutineScope(SupervisorJob())
try {
val only = ScriptedSpeaker(room)
val reconnecting =
connectReconnectingNestsSpeaker(
httpClient = httpClient,
transport = transport,
scope = scope,
room = room,
signer = signer,
speakerPubkeyHex = "speaker-pubkey",
captureFactory = { error("unused") },
encoderFactory = { error("unused") },
connector = { only },
)
reconnecting.close()
withTimeout(5_000L) {
reconnecting.state.first { it is NestsSpeakerState.Closed }
}
var threw: Throwable? = null
try {
reconnecting.startBroadcasting()
} catch (t: Throwable) {
threw = t
}
assertTrue(threw is IllegalStateException, "must reject startBroadcasting on closed wrapper; got $threw")
} finally {
scope.cancel()
}
}
}