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:
+48
@@ -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() {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user