Sustained-cadence test: dump partial arrivals + gap pattern

Previously test 4 used .take(N).toList() inside withTimeoutOrNull, so a
timeout discarded every received frame and the only failure signal was
"only got partial audio". When the production run flagged frame loss,
we couldn't tell whether the stream stalled mid-flight, never started
fanning out, or dropped scattered groups.

Drain into an external CopyOnWriteArrayList via .onEach { } so the
partial contents survive the timeout, then on failure surface:
  - received N/100 with first/last arrival timestamps
  - missingGroups summary as run-length ranges (e.g. [3-7,42,90-99])
  - first/last 20 arrivals so the front and tail of the sequence are
    visible without flooding stdout

The gap pattern alone narrows the diagnosis: front-loaded losses point
to subscribe-settle being too short, tail-loaded losses point to a
broadcaster shutdown race, scattered drops point to per-subscription
buffer overflow / datagram loss.
This commit is contained in:
Claude
2026-05-01 02:05:40 +00:00
parent 9f1b32f40d
commit 7e3d622399
@@ -33,6 +33,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
@@ -425,10 +426,45 @@ class NostrnestsProdAudioTransmissionTest {
val subscription = listener.subscribeSpeaker(hostSigner.pubKey)
val received =
// Drain into a thread-safe list as frames arrive so we
// can dump partial contents on timeout — the previous
// .take(N).toList() variant discarded everything on the
// way to the deadline.
data class Arrival(
val wallMs: Long,
val groupId: Long,
val objectId: Long,
val firstByte: Int,
)
val received = java.util.concurrent.CopyOnWriteArrayList<Arrival>()
val firstArrivalAtomic =
java.util.concurrent.atomic
.AtomicLong(-1L)
val collectStart = System.currentTimeMillis()
// onEach records each arrival into the external list
// BEFORE take() trims the flow — so even if the
// surrounding withTimeoutOrNull aborts, the partial
// contents are preserved for diagnostic dump.
val collected =
async(pumpScope.coroutineContext) {
withTimeoutOrNull(REAL_TIME_RECEIVE_TIMEOUT_MS) {
subscription.objects.take(REAL_TIME_FRAMES).toList()
subscription.objects
.onEach { obj ->
val now = System.currentTimeMillis()
firstArrivalAtomic.compareAndSet(-1L, now)
received +=
Arrival(
wallMs = now - collectStart,
groupId = obj.groupId,
objectId = obj.objectId,
firstByte =
obj.payload
.firstOrNull()
?.toInt()
?.and(0xFF) ?: -1,
)
}.take(REAL_TIME_FRAMES)
.toList()
}
}
delay(SUBSCRIBE_SETTLE_MS)
@@ -440,34 +476,85 @@ class NostrnestsProdAudioTransmissionTest {
}
val pumpDurationMs = System.currentTimeMillis() - started
val frames = received.await()
if (frames == null) {
fail(
"[$scope] only got partial audio within " +
"${REAL_TIME_RECEIVE_TIMEOUT_MS}ms — speaker=" +
InteropDebug.describe(speaker.state.value) +
", listener=" + InteropDebug.describe(listener.state.value) +
", pumpDuration=${pumpDurationMs}ms. If two_users round-trip " +
"passes but this drops frames, suspect flow control / " +
"datagram drops / per-subscription buffer.",
collected.await()
val frames = received.toList()
val firstArrivalMs = firstArrivalAtomic.get().let { if (it < 0) -1 else it - collectStart }
val lastArrivalMs = frames.lastOrNull()?.wallMs ?: -1L
// Identify gaps in groupId. moq-lite is dense per
// broadcast — a gap at idx N means the relay dropped
// (or never delivered) the uni stream for that group.
val seenGroups = frames.map { it.groupId }.toSortedSet()
val expectedGroups = (0L until REAL_TIME_FRAMES.toLong()).toList()
val missing = expectedGroups - seenGroups
val gapRuns =
buildString {
var run: Pair<Long, Long>? = null
for (g in missing) {
run =
when {
run == null -> {
g to g
}
g == run.second + 1 -> {
run.first to g
}
else -> {
if (isNotEmpty()) append(",")
append(if (run.first == run.second) "${run.first}" else "${run.first}-${run.second}")
g to g
}
}
}
if (run != null) {
if (isNotEmpty()) append(",")
append(if (run.first == run.second) "${run.first}" else "${run.first}-${run.second}")
}
}
InteropDebug.checkpoint(
scope,
"received=${frames.size}/$REAL_TIME_FRAMES " +
"firstArrival=${firstArrivalMs}ms lastArrival=${lastArrivalMs}ms " +
"pumpDuration=${pumpDurationMs}ms missingGroups=[$gapRuns]",
)
// Print first 20 + last 20 arrivals so the diagnostic
// captures both the front and tail of the sequence
// without flooding stdout.
val sample =
if (frames.size <= 40) frames else frames.take(20) + frames.takeLast(20)
sample.forEach { a ->
InteropDebug.checkpoint(
scope,
" arrival t=${a.wallMs}ms groupId=${a.groupId} objectId=${a.objectId} firstByte=0x${a.firstByte.toString(16)}",
)
}
// Expect every frame: the broadcaster opens one moq-lite
// group per frame, so there's no group-level dropping
// for late attach inside the 100-frame window.
assertEquals(
REAL_TIME_FRAMES,
frames.size,
"expected all $REAL_TIME_FRAMES sustained-cadence frames; received ${frames.size}",
)
// groupId must be monotonic and dense (no gaps) — gap
// detection is the cheapest signal that the relay
// dropped a uni stream mid-flight.
frames.forEachIndexed { idx, obj ->
if (frames.size < REAL_TIME_FRAMES) {
fail(
"[$scope] received ${frames.size}/$REAL_TIME_FRAMES frames " +
"within ${REAL_TIME_RECEIVE_TIMEOUT_MS}ms — " +
"speaker=" + InteropDebug.describe(speaker.state.value) +
", listener=" + InteropDebug.describe(listener.state.value) +
", pumpDuration=${pumpDurationMs}ms" +
", firstArrival=${firstArrivalMs}ms" +
", lastArrival=${lastArrivalMs}ms" +
", missingGroups=[$gapRuns]. " +
"Pattern hints: firstArrival=-1 → SUBSCRIBE never fanned out; " +
"lastArrival≪pumpDuration with frames<<N → stream stalled mid-flight; " +
"scattered missingGroups → datagram drops or per-subscription buffer overflow.",
)
}
// Order check on success: groupId must be monotonic and
// dense; gaps would mean a uni stream was dropped.
frames.forEachIndexed { idx, a ->
assertEquals(
idx.toLong(),
obj.groupId,
"groupId gap at index $idx — relay dropped a uni stream",
a.groupId,
"groupId gap at index $idx",
)
}