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
@@ -111,6 +111,45 @@ class NestBroadcasterTest {
broadcaster.stop()
}
@Test
fun onTerminalFailure_fires_once_after_consecutive_send_failures() =
runTest {
// Drive MAX_CONSECUTIVE_SEND_ERRORS + 1 frames through a
// publisher that always throws. The broadcaster must:
// - keep going past one failure (we already cover that)
// - bail eventually
// - fire onTerminalFailure exactly once
// - stop pulling from capture
val frameCount = NestBroadcaster.MAX_CONSECUTIVE_SEND_ERRORS + 50
val frames = List(frameCount) { shortArrayOf(it.toShort()) }
val capture = ScriptedCapture(frames)
val encoder = ScriptedEncoder(prefix = byteArrayOf(0xFF.toByte()))
val publisher = ThrowingPublisher()
val broadcaster = NestBroadcaster(capture, encoder, publisher, backgroundScope)
val errors = mutableListOf<AudioException>()
var terminalFailureCount = 0
broadcaster.start(
onTerminalFailure = { terminalFailureCount += 1 },
onError = { errors.add(it) },
)
// Wait until the broadcaster has bailed. The bail closes the
// launched job, but capture.awaitDrained only fires on EOF —
// so poll on the terminal-failure counter instead.
while (terminalFailureCount == 0) kotlinx.coroutines.yield()
assertEquals(1, terminalFailureCount, "onTerminalFailure should fire exactly once")
// We saw at least MAX_CONSECUTIVE_SEND_ERRORS failures before
// the bail, plus one "gave up" message. ScriptedEncoder
// doesn't fail, so all errors are publisher.send failures.
assertTrue(
errors.size >= NestBroadcaster.MAX_CONSECUTIVE_SEND_ERRORS,
"should have seen ≥ ${NestBroadcaster.MAX_CONSECUTIVE_SEND_ERRORS} errors, got ${errors.size}",
)
broadcaster.stop()
}
@Test
fun stop_is_idempotent() =
runTest {
@@ -207,4 +246,13 @@ class NestBroadcasterTest {
closed = true
}
}
/** Publisher whose send() always throws — used to drive the bail path. */
private class ThrowingPublisher : MoqSession.TrackPublisher {
override val name: ByteArray = "throwing".encodeToByteArray()
override suspend fun send(payload: ByteArray): Boolean = error("publisher.send failure")
override suspend fun close() {}
}
}