test(nests): T16 Phase 2 — I8 + I10, results doc, I4 plan

Two more cross-stack scenarios + a follow-up plan:

- **I8** (`subscribe_drop_for_unknown_track`): SUBSCRIBE to a
  track the publisher hasn't claimed; assert the bidi closes
  empty within 2 s. moq-relay 0.10.x sends an optimistic
  SubscribeOk to the listener while forwarding the SUBSCRIBE
  to the publisher, so the publisher's SubscribeDrop reaches
  us as a stream-FIN rather than a Kotlin-side
  MoqLiteSubscribeException — the test handles both paths.
- **I10** (`long_broadcast_60s_tone_round_trips`): sustained
  60 s 440 Hz Kotlin speaker → hang-listen, asserts ≥ 95 % of
  expected sample count in the decoded PCM and a tail-window
  FFT peak at 440 Hz. Catches relay-side queue overflow and
  listener-side `MAX_STREAMS_UNI` cliff regressions.

Results doc updated with Phase 2.E status (I1 + I2 + I3 + I8
+ I10 + I11 + Rust↔Rust round-trip green) and the
`DEFAULT_FRAMES_PER_GROUP` conflict between the cliff plan
(recommends 5) and the production code (50, with field-test
data citing a different cliff). Not auto-applied; a maintainer
needs to reconcile the two failure modes.

I12 (Goaway) deferred — moq-relay 0.10.x has no admin port to
trigger Goaway, and even on shutdown it doesn't emit one. The
parent plan acknowledges this gap.

I4 (stereo) plan filed at
`nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario.md`
— a non-trivial production-side change (AudioFormat.CHANNELS
parameterisation, MoqLiteHangCatalog generalisation, listener
catalog-discovery) so it gets its own branch and PR.

https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
This commit is contained in:
Claude
2026-05-06 22:30:53 +00:00
parent e9e0e787a0
commit 450859759f
3 changed files with 610 additions and 14 deletions
@@ -26,7 +26,10 @@ import com.vitorpamplona.nestsclient.audio.AudioFormat
import com.vitorpamplona.nestsclient.audio.JvmOpusEncoder
import com.vitorpamplona.nestsclient.audio.PcmAssertions
import com.vitorpamplona.nestsclient.audio.SineWaveAudioCapture
import com.vitorpamplona.nestsclient.buildRelayConnectTarget
import com.vitorpamplona.nestsclient.connectNestsSpeaker
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSubscribeException
import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -37,7 +40,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -215,6 +221,169 @@ class HangInteropTest {
PcmAssertions.assertFftPeak(analysed, expectedHz = 440.0, halfWindowHz = 5.0)
}
/**
* I8 — SubscribeDrop on unknown track. Subscribes to a track
* the publisher hasn't claimed (`audio/data-not-here`); the
* publisher's `MoqLiteSession` replies with a SubscribeDrop
* (`TRACK_DOES_NOT_EXIST`).
*
* `moq-relay 0.10.x` forwards the SUBSCRIBE to the publisher
* but ALSO acknowledges the listener's bidi with `SubscribeOk`
* upfront — relay-level optimistic ack — so the listener-side
* `subscribe()` call returns a handle rather than throwing.
* The publisher's Drop arrives on the same bidi shortly after,
* the relay forwards a stream-FIN, and the handle's `frames`
* flow completes empty.
*
* The test's assertion: subscribing returns a handle (relay
* ack), the bidi closes within a short timeout (publisher's
* Drop reaches us), and **no frames** are emitted on the
* subscription. A regression that returned an "OK" but kept
* the bidi open forever would hang at `take(1)` past the
* deadline.
*/
@Test
fun subscribe_drop_for_unknown_track() =
runBlocking {
val harness = NativeMoqRelayHarness.shared()
val signer: NostrSigner = NostrSignerInternal(KeyPair())
val pubkey = signer.pubKey
val room =
NestsRoomConfig(
authBaseUrl = "<unused-public-relay>",
endpoint = harness.relayUrl,
hostPubkey = pubkey,
roomId = "rt-${UUID.randomUUID()}",
)
val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val transport =
QuicWebTransportFactory(
parentScope = pumpScope,
certificateValidator = PermissiveCertificateValidator(),
)
try {
// Speaker side: claim "audio/data" + "catalog.json".
val speaker =
connectNestsSpeaker(
httpClient = StaticTokenNestsClient,
transport = transport,
scope = pumpScope,
room = room,
signer = signer,
speakerPubkeyHex = pubkey,
captureFactory = { SineWaveAudioCapture(freqHz = 440) },
encoderFactory = { JvmOpusEncoder() },
framesPerGroup = 5,
)
val handle = speaker.startBroadcasting()
delay(150)
// Listener side: open a raw moq-lite session and
// SUBSCRIBE to a track the publisher never claimed.
val (authority, path) =
buildRelayConnectTarget(
endpoint = harness.relayUrl,
namespace = room.moqNamespace(),
token = "",
)
val listenerWt =
transport.connect(
authority = authority,
path = path,
bearerToken = null,
)
val listenerSession = MoqLiteSession.client(listenerWt, pumpScope)
try {
val sub =
try {
listenerSession.subscribe(
broadcast = pubkey,
track = "audio/data-not-here",
)
} catch (t: MoqLiteSubscribeException) {
// Tolerate either path: relay sends Drop
// pre-Ok (test passes here), or ack-then-
// Drop (test handles below).
null
}
if (sub != null) {
val frames =
withTimeoutOrNull(2_000L) {
sub.frames.take(1).toList()
}
// Either:
// - withTimeoutOrNull returned null (flow
// stayed open with no frames for 2 s):
// publisher Drop arrived but didn't
// surface — regression.
// - Empty list (flow closed empty before
// timeout): expected — Drop closed the
// bidi, no frames.
assertTrue(
frames != null && frames.isEmpty(),
"subscribe to a non-existent track should close empty " +
"(SubscribeDrop FINs the bidi), but received frames=$frames",
)
}
} finally {
listenerSession.close()
}
handle.close()
speaker.close()
} finally {
pumpScope.coroutineContext[Job]?.cancel()
}
}
/**
* I10 — long broadcast. Sustained 60 s 440 Hz tone Kotlin
* speaker → hang-listen, asserts the decoded PCM has ≥ 95 %
* of the expected sample count.
*
* Catches relay-side queue overflow and listener-side stream-
* count cliff (`MAX_STREAMS_UNI` extension) regressions over
* minute-scale broadcasts. With `framesPerGroup = 5` the speaker
* opens ~10 uni streams/s = 600 streams across the run, well
* past the default 100-stream initial cap that the
* `:quic` `MaxStreamsFrame` fix in commit `d391ae1d` widened.
*
* Tagged so `gradle --tests` filters can include / exclude it
* (the 60 s wallclock is significant compared to the rest of
* the suite).
*/
@Test
fun long_broadcast_60s_tone_round_trips() =
runBlocking {
val out =
runSpeakerToHangListen(
speakerSeconds = 60,
captureFirstFrame = false,
)
val pcm = readFloat32Pcm(out.pcmFile)
// ≥ 95 % of 60 s × 48 kHz, with the same skip-warmup
// window as the I1 scenario so an Opus look-ahead
// gap doesn't trip the threshold.
val expectedSamples = 60.0 * AudioFormat.SAMPLE_RATE_HZ
assertTrue(
pcm.size >= expectedSamples * 0.95,
"expected ≥ 95% of $expectedSamples samples in a 60 s broadcast, " +
"got ${pcm.size} (${"%.1f".format(pcm.size / expectedSamples * 100)} %)",
)
// Spectral content still matches the tone at the tail —
// catches a silent regression where the relay forwards
// gibberish bytes after a stream-count limit hit.
val tailWindow =
pcm.copyOfRange(
(pcm.size - AudioFormat.SAMPLE_RATE_HZ * 5).coerceAtLeast(0),
pcm.size,
)
PcmAssertions.assertFftPeak(tailWindow, expectedHz = 440.0, halfWindowHz = 5.0)
}
/**
* Rust↔Rust round-trip: pure-Rust through our harness.
* Validates the cargo workspace + relay config + `moq-lite-03`