fix(nestsClient): token-parse hardening, mint timeout, IPv6 [], broadcaster-bail signal

- NestsTokenResponse.parse catch list now includes IllegalStateException
  so a malformed escape from a misbehaving auth server surfaces as
  NestsException instead of crashing the listener.
- OkHttpNestsClient gains a configurable callTimeoutMs (default 90 s)
  enforcing an upper bound on the entire mintToken round-trip including
  retries. Without it a stalled server suspends the reconnect
  orchestrator indefinitely (the orchestrator's openOnce step parks on
  mintToken). 90 s leaves headroom over the worst-case 63 s 429
  retry chain documented in MAX_RATE_LIMIT_RETRIES.
- parseEndpoint tightens IPv6 bracket check from `closeBracket > 0` to
  `> 1`, rejecting the empty `[]` literal.
- NestBroadcaster + NestMoqLiteBroadcaster gain an `onTerminalFailure`
  callback that fires once when the consecutive-send-error guard bails.
  MoqLiteNestsSpeaker wires this to flip the speaker state to Failed,
  giving ReconnectingNestsSpeaker the signal it needs to recycle the
  session — without this hook the broadcaster bailed silently and the
  outward speaker state stayed on Broadcasting forever.

Adds regression test:
  - onTerminalFailure_fires_once_after_consecutive_send_failures

225 tests pass, 0 failures. Android target compiles clean.
This commit is contained in:
Claude
2026-05-01 18:41:13 +00:00
parent 702885f4cb
commit 8b7785a7e8
6 changed files with 149 additions and 9 deletions
@@ -92,7 +92,20 @@ class MoqLiteNestsSpeaker internal constructor(
publisher = publisher,
scope = scope,
framesPerGroup = framesPerGroup,
).also { it.start() }
).also {
it.start(
onTerminalFailure = {
// Broadcaster bailed after sustained
// publisher.send failures. Flip to
// Failed so the reconnect orchestrator
// sees a terminal state and recycles
// the session — without this signal the
// outward state stays on Broadcasting
// and the room is silently mute.
onBroadcastTerminalFailure()
},
)
}
} catch (t: Throwable) {
runCatching { publisher.close() }
throw t
@@ -129,6 +142,22 @@ class MoqLiteNestsSpeaker internal constructor(
}
}
/**
* Called from the broadcaster's `onTerminalFailure` callback (off
* the speaker's coroutine). Transitions the speaker to `Failed` so
* the reconnect orchestrator (`ReconnectingNestsSpeaker`) observes
* a terminal state and recycles the session. No-op if the speaker
* is already in a terminal state.
*/
private fun onBroadcastTerminalFailure() {
val current = mutableState.value
if (current is NestsSpeakerState.Failed || current is NestsSpeakerState.Closed) return
mutableState.value =
NestsSpeakerState.Failed(
reason = "broadcast pipeline gave up — likely transport loss",
)
}
internal fun reportMuteState(muted: Boolean) {
val current = mutableState.value
if (current is NestsSpeakerState.Broadcasting) {
@@ -372,7 +372,10 @@ internal fun parseEndpoint(endpoint: String): Pair<String, String> {
val (hostPart, portStr) =
if (authorityRaw.startsWith('[')) {
val closeBracket = authorityRaw.indexOf(']')
require(closeBracket > 0) { "malformed IPv6 authority: '$authorityRaw'" }
// closeBracket==1 would mean the literal "[]" — empty IPv6
// address. Reject so callers can't accidentally pass an
// unconfigured placeholder.
require(closeBracket > 1) { "malformed IPv6 authority: '$authorityRaw'" }
val host = authorityRaw.substring(0, closeBracket + 1)
val tail = authorityRaw.substring(closeBracket + 1)
when {
@@ -65,7 +65,16 @@ class NestBroadcaster(
* a stopped state and the exception propagates so the caller can
* surface it to the user.
*/
fun start(onError: (AudioException) -> Unit = { /* swallow */ }) {
fun start(
/**
* See [NestMoqLiteBroadcaster.start]'s `onTerminalFailure` kdoc —
* fires once when the loop bails after MAX_CONSECUTIVE_SEND_ERRORS
* so the speaker can transition to Failed and the orchestrator
* can recycle.
*/
onTerminalFailure: () -> Unit = { /* swallow */ },
onError: (AudioException) -> Unit = { /* swallow */ },
) {
check(!stopped) { "NestBroadcaster already stopped" }
check(job == null) { "NestBroadcaster.start already called" }
@@ -132,6 +141,7 @@ class NestBroadcaster(
t,
),
)
runCatching { onTerminalFailure() }
return@launch
}
}
@@ -103,8 +103,20 @@ class NestMoqLiteBroadcaster(
* Returns immediately. Calling twice is an error. If
* [AudioCapture.start] throws, the broadcaster is left stopped and
* the exception propagates.
*
* [onTerminalFailure] fires exactly once when the loop bails after
* [MAX_CONSECUTIVE_SEND_ERRORS] consecutive `publisher.send`
* failures. The broadcaster has stopped capturing by the time this
* callback runs; the caller (typically [MoqLiteNestsSpeaker]) is
* expected to mark the speaker `Failed` so the reconnect
* orchestrator can recycle the session — without this signal the
* outward speaker state stays stuck on `Broadcasting` and the
* orchestrator never knows to act.
*/
fun start(onError: (AudioException) -> Unit = { /* swallow */ }) {
fun start(
onTerminalFailure: () -> Unit = { /* swallow */ },
onError: (AudioException) -> Unit = { /* swallow */ },
) {
check(!stopped) { "NestMoqLiteBroadcaster already stopped" }
check(job == null) { "NestMoqLiteBroadcaster.start already called" }
@@ -187,9 +199,14 @@ class NestMoqLiteBroadcaster(
t,
),
)
// Falls through to the same post-loop flush path
// capture-EOF takes — caller still owns [stop],
// but the mic-burn loop is over.
// Surface the bail so the speaker
// can flip to Failed and the
// reconnect orchestrator recycles
// the session. Caller still owns
// [stop] — we don't release the mic
// ourselves to avoid double-stop
// races with a concurrent caller.
runCatching { onTerminalFailure() }
return@launch
}
}