fix(nestsClient): perf + robustness — jitter, frame buffer, defensive bounds, send-error guard

- NestsReconnectPolicy gains a `jitter` parameter (default 0.3, AWS-
  style equal jitter). Without it, every client reconnecting after
  a relay restart retries on the identical 1s/2s/4s/… schedule and
  thunders the relay during recovery. delayForAttempt also accepts
  an explicit Random source for deterministic tests.
- MoqLiteFrameBuffer decouples capacity from size so power-of-two
  growth actually amortises (the old shape allocated a fresh
  ByteArray then truncated capacity back to `needed` per chunk,
  defeating doubling). Adds a guard against varint reads past the
  live region into uninitialised slack capacity.
- prefixWithType inlines the size-prefix wrap so a publisher
  SubscribeOk/Drop reply doesn't allocate three ByteArrays.
- MoqLiteSubscribeOk gains the same `init { require(priority in 0..255) }`
  bounds check its sibling MoqLiteSubscribe has — a buggy caller
  building a malformed Ok reply now fails loudly instead of writing
  a truncated byte on the wire.
- NestBroadcaster + NestMoqLiteBroadcaster track consecutive
  publisher.send exception count and bail after 250 (~5 s at 50 fps)
  instead of holding the mic open forever on a permanently dead
  transport. publisher.send returning `false` (no inbound
  subscriber) is NOT counted — empty rooms are a normal state.

Adds 8 regression tests:
  - 5 in MoqLiteFrameBufferTest (multi-chunk reads, back-to-back
    payloads, growth amortisation, compact, varint past-live guard)
  - 3 in NestsReconnectPolicyTest (jitter spread band, jitter=0
    determinism, jitter=1 collapses to 0..base)

223 tests pass, 0 failures.
This commit is contained in:
Claude
2026-05-01 18:11:56 +00:00
parent f3a942dbd0
commit 9729f3a20c
8 changed files with 307 additions and 44 deletions
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.nestsclient
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@@ -29,13 +30,13 @@ import kotlin.test.assertTrue
class NestsReconnectPolicyTest {
@Test
fun firstAttemptUsesInitialDelay() {
val p = NestsReconnectPolicy(initialDelayMs = 1_000, multiplier = 2.0, maxDelayMs = 30_000)
val p = NestsReconnectPolicy(initialDelayMs = 1_000, multiplier = 2.0, maxDelayMs = 30_000, jitter = 0.0)
assertEquals(1_000, p.delayForAttempt(1))
}
@Test
fun delayDoublesPerAttemptUntilMax() {
val p = NestsReconnectPolicy(initialDelayMs = 1_000, multiplier = 2.0, maxDelayMs = 30_000)
val p = NestsReconnectPolicy(initialDelayMs = 1_000, multiplier = 2.0, maxDelayMs = 30_000, jitter = 0.0)
assertEquals(2_000, p.delayForAttempt(2))
assertEquals(4_000, p.delayForAttempt(3))
assertEquals(8_000, p.delayForAttempt(4))
@@ -48,7 +49,7 @@ class NestsReconnectPolicyTest {
@Test
fun nonStandardMultiplierStillRespectsCeiling() {
val p = NestsReconnectPolicy(initialDelayMs = 500, multiplier = 3.0, maxDelayMs = 5_000)
val p = NestsReconnectPolicy(initialDelayMs = 500, multiplier = 3.0, maxDelayMs = 5_000, jitter = 0.0)
assertEquals(500, p.delayForAttempt(1))
assertEquals(1_500, p.delayForAttempt(2))
assertEquals(4_500, p.delayForAttempt(3))
@@ -63,6 +64,40 @@ class NestsReconnectPolicyTest {
assertEquals(0L, p.delayForAttempt(-1))
}
@Test
fun jitter_spreads_delay_into_expected_band() {
// Equal-jitter contract: delay is uniformly distributed on
// [(1 - jitter) * base, base]. With jitter=0.5 and base=1000,
// values must land in [500, 1000]. Sample a few times with a
// seeded RNG so the test is deterministic.
val p = NestsReconnectPolicy(initialDelayMs = 1_000, jitter = 0.5)
val rng = Random(0xC0FFEE)
repeat(50) {
val d = p.delayForAttempt(1, rng)
assertTrue(d in 500..1_000, "expected jittered delay in [500, 1000], got $d")
}
}
@Test
fun jitter_zero_yields_deterministic_base() {
val p = NestsReconnectPolicy(initialDelayMs = 1_000, jitter = 0.0)
// Random source is consulted but ignored when jitter == 0.
repeat(10) {
assertEquals(1_000, p.delayForAttempt(1, Random(it.toLong())))
}
}
@Test
fun jitter_one_can_collapse_to_zero_or_full_base() {
val p = NestsReconnectPolicy(initialDelayMs = 1_000, jitter = 1.0)
// jitter=1.0 → low bound is 0 → delay can be anywhere in [0, 1000].
val rng = Random(42)
repeat(50) {
val d = p.delayForAttempt(1, rng)
assertTrue(d in 0..1_000, "expected delay in [0, 1000], got $d")
}
}
@Test
fun isExhaustedHonoursMaxAttempts() {
val p = NestsReconnectPolicy(maxAttempts = 3)
@@ -89,5 +124,7 @@ class NestsReconnectPolicyTest {
NestsReconnectPolicy(initialDelayMs = 5_000, maxDelayMs = 1_000)
}
assertFailsWith<IllegalArgumentException> { NestsReconnectPolicy(maxAttempts = 0) }
assertFailsWith<IllegalArgumentException> { NestsReconnectPolicy(jitter = -0.1) }
assertFailsWith<IllegalArgumentException> { NestsReconnectPolicy(jitter = 1.1) }
}
}
@@ -0,0 +1,111 @@
/*
* 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.moq.lite
import com.vitorpamplona.nestsclient.moq.MoqWriter
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNull
class MoqLiteFrameBufferTest {
@Test
fun reads_size_prefixed_payload_assembled_from_multiple_chunks() {
val payload = ByteArray(7) { (0x10 + it).toByte() }
val full =
MoqWriter()
.also { it.writeLengthPrefixedBytes(payload) }
.toByteArray()
val buf = MoqLiteFrameBuffer()
// Drip-feed the payload one byte at a time so the buffer has to
// hold partial state across many push() calls — the "needs more
// bytes" branch must NOT advance pos when the payload is short.
for (i in full.indices) {
assertNull(buf.readSizePrefixed(), "must wait until payload complete")
buf.push(byteArrayOf(full[i]))
}
val readBack = buf.readSizePrefixed()
assertContentEquals(payload, readBack)
}
@Test
fun back_to_back_payloads_share_one_buffer() {
val w = MoqWriter()
w.writeLengthPrefixedBytes(byteArrayOf(0x01, 0x02))
w.writeLengthPrefixedBytes(byteArrayOf(0x03, 0x04, 0x05))
w.writeLengthPrefixedBytes(byteArrayOf(0x06))
val buf = MoqLiteFrameBuffer()
buf.push(w.toByteArray())
assertContentEquals(byteArrayOf(0x01, 0x02), buf.readSizePrefixed())
assertContentEquals(byteArrayOf(0x03, 0x04, 0x05), buf.readSizePrefixed())
assertContentEquals(byteArrayOf(0x06), buf.readSizePrefixed())
assertNull(buf.readSizePrefixed(), "no more frames")
}
@Test
fun growth_capacity_amortises_under_many_small_pushes() {
// Push 10 KB one byte at a time. The earlier shape allocated a
// fresh ByteArray per push; the size-tracking shape should
// double-and-keep, so total allocations are O(log N) — and the
// resulting buffer correctly reads back as one giant payload.
val payload = ByteArray(10_000) { (it and 0xFF).toByte() }
val framed =
MoqWriter()
.also { it.writeLengthPrefixedBytes(payload) }
.toByteArray()
val buf = MoqLiteFrameBuffer()
for (b in framed) buf.push(byteArrayOf(b))
assertContentEquals(payload, buf.readSizePrefixed())
}
@Test
fun compact_runs_after_consuming_half_then_pushing_more() {
// 1) Push frame A and read it (advances pos, leaves 0 live bytes).
// 2) Push frame B — compact() should fire, dropping the dead
// prefix without copying garbage.
val w = MoqWriter()
w.writeLengthPrefixedBytes(ByteArray(100) { 0xAA.toByte() })
val buf = MoqLiteFrameBuffer()
buf.push(w.toByteArray())
assertContentEquals(ByteArray(100) { 0xAA.toByte() }, buf.readSizePrefixed())
val w2 = MoqWriter()
w2.writeLengthPrefixedBytes(byteArrayOf(0x55, 0x66))
buf.push(w2.toByteArray())
assertContentEquals(byteArrayOf(0x55, 0x66), buf.readSizePrefixed())
assertEquals(0, buf.remaining)
}
@Test
fun varint_must_not_read_into_uninitialised_capacity() {
// Push 1 byte that's a 2-byte-varint prefix. The buffer's
// underlying capacity is grown to 64+, but only 1 byte is live.
// readVarint must NOT consume the second byte of slack capacity
// as if it were data.
val buf = MoqLiteFrameBuffer()
// 0x40 → top 2 bits "01" → varint is 2 bytes long.
buf.push(byteArrayOf(0x40))
assertNull(buf.readVarint(), "incomplete varint must return null")
}
}