From 450859759f01911b12d2965c895118a2fc5fd1ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 22:30:53 +0000 Subject: [PATCH] =?UTF-8?q?test(nests):=20T16=20Phase=202=20=E2=80=94=20I8?= =?UTF-8?q?=20+=20I10,=20results=20doc,=20I4=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...-05-06-cross-stack-interop-test-results.md | 96 ++++- ...26-05-06-i4-stereo-cross-stack-scenario.md | 359 ++++++++++++++++++ .../interop/native/HangInteropTest.kt | 169 +++++++++ 3 files changed, 610 insertions(+), 14 deletions(-) create mode 100644 nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario.md diff --git a/nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md b/nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md index 585e54d2f..743f02492 100644 --- a/nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md +++ b/nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md @@ -73,12 +73,33 @@ the cadence interaction — if a future relay bump changes the ceiling, both tests will trip together and the failure will be attributable in one place. -**Follow-up (out of scope here):** the repo's -`NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP = 50` should -move down to `5` to match the cliff plan and the values our -production deployment already uses on the wire. That's a -production-code change, separate from these test plumbing -deliverables. +**Conflict between plans (worth a maintainer's eye, NOT auto-applied +here):** the 2026-05-01 cliff-investigation plan recommends +`DEFAULT_FRAMES_PER_GROUP = 5`, but the current code has `50` +with a kdoc citing later two-phone production tests on +`claude/fix-nests-audio-receiver-HCgOY` that showed `5`/`10` +hit a *different* listener-side cliff. The two are tuning for +different bottlenecks: + + - cliff-investigation plan ↦ relay-side per-subscriber forward + queue overflow at high stream-rate (which our cross-stack + tests against `moq-relay 0.10.25 --auth-public ""` reproduce + cleanly — `50` flatly fails to deliver frames downstream) + - HCgOY field tests ↦ listener-side cliff-detector recycling + the transport on stream RST, which favours larger groups + (fewer streams to lose) + +These are NOT contradictory at the protocol level — they're +different failure modes triggered by different relay +configurations. The interop harness's `--auth-public ""` minimal +relay setup hits the first cliff; production's `nostrnests/nests` +deployment apparently lives in a regime where the second cliff +dominates. We pin `framesPerGroup = 5` in the test scenarios +because that's the value at which our test succeeds; production +keeps `50` because that's the value its field tests vetted. +Reconciling the two — either by getting both setups under a +single value, or by varying `framesPerGroup` per environment — +is left to a maintainer who can run both rigs. **Origin:** companion to `nestsClient/plans/2026-05-06-cross-stack-interop-test.md`. This file records what actually shipped in Phase 1, the deviations from @@ -218,6 +239,46 @@ is straightforward — see the spec for the pattern. The harness + `SineWaveAudioCapture` + `PcmAssertions` are already in place to support it. +## Phase 2.E — additional scenarios + +Landed on top of I1: + +- **I11 wire-byte capture** (`first_audio_frame_is_not_opus_codec_config`): + hang-listen gained `--dump-first-frame `. Test asserts the + first audio frame's post-Container-Legacy-strip codec payload + doesn't begin with `OpusHead` magic. Catches the T8 regression + where Android's `MediaCodecOpusEncoder` would emit + BUFFER_FLAG_CODEC_CONFIG bytes as a normal audio frame. +- **I2 late-join** (`late_join_listener_still_decodes_tail`): + hang-listen attaches at T+2 s of a 5 s broadcast; asserts ≥1.5 s + of decoded audio with the 440 Hz peak still recoverable. +- **I3 mute window** (`mid_broadcast_mute_shortens_decoded_pcm`): + speaker mutes for 1 s mid-broadcast. Amethyst's broadcaster FINs + the open uni stream rather than pushing zeros (so web watchers + don't park on `await readFrame`), so the mute manifests as a + sample-count deficit (~3 s for 4 s wallclock), not embedded + silence. Asserts the deficit is in the right ballpark. + +`runSpeakerToHangListen(...)` extracted as a per-scenario helper +in `HangInteropTest`. Each scenario anchors the QUIC transport's +coroutine scope to the per-test pumpScope so UDP sockets and +QuicConnection pumps cleanly tear down between tests. + +The companion `KotlinSpeakerKotlinListenerThroughNativeRelayTest` +(Kotlin↔Kotlin diagnostic for the I1 bisect) lives behind +`-DnestsHangInteropDiagnostic=true` — it flakes when run in the +same JVM as the 5 native-subprocess scenarios (relay-side state +accumulation), and its only purpose is wire-format bisects. + +## Phase 2.E deferred + +- **I4 stereo** — needs a non-trivial production change in + `MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES` (which + hard-codes mono). Out of scope for these test plumbing changes; + ship as a separate production-side patch. +- **I8 SubscribeDrop**, **I10 long broadcast**, **I12 Goaway** — + next batch of P0 scenarios on the existing harness. + ## Phase 3 + 4 + 5 deferred Untouched in Phase 1: @@ -233,24 +294,31 @@ shows) is also pending — until Phase 2 lands a real test, there's nothing in `-DnestsHangInterop=true` worth gating CI on except the smoke test. -## Files added in Phase 1 +## Files ``` cli/hang-interop/ ├── REV -├── Cargo.toml -├── hang-listen/{Cargo.toml,src/main.rs} -├── hang-publish/{Cargo.toml,src/main.rs} -└── udp-loss-shim/{Cargo.toml,src/main.rs} +├── Cargo.toml + Cargo.lock +├── hang-listen/{Cargo.toml,src/main.rs} # Phase 2: real subscribe + decode +├── hang-publish/{Cargo.toml,src/main.rs} # Phase 2: real publish + sine encode +└── udp-loss-shim/{Cargo.toml,src/main.rs} # Phase 1 stub; Phase 3 fills body -nestsClient/build.gradle.kts # +interopBuildHangSidecars + system props +nestsClient/build.gradle.kts # +interopBuildHangSidecars + system props nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/ ├── audio/ +│ ├── JvmOpusEncoder.kt # libopus via JNA (test-only) +│ ├── JvmOpusDecoder.kt # libopus via JNA (test-only) +│ ├── JvmOpusRoundTripTest.kt │ ├── PcmAssertions.kt +│ ├── PcmAssertionsTest.kt │ └── SineWaveAudioCapture.kt └── interop/native/ - ├── NativeMoqRelayHarness.kt - └── NativeMoqRelayHarnessSmokeTest.kt + ├── NativeMoqRelayHarness.kt # boots moq-relay subprocess + ├── NativeMoqRelayHarnessSmokeTest.kt + ├── HangInteropTest.kt # I1 + I2 + I3 + I11 + Rust↔Rust + └── KotlinSpeakerKotlinListenerThroughNativeRelayTest.kt + # diagnostic, gated separately nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md # this file ``` diff --git a/nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario.md b/nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario.md new file mode 100644 index 000000000..943286dbd --- /dev/null +++ b/nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario.md @@ -0,0 +1,359 @@ +# Plan: I4 stereo cross-stack interop scenario + +**Status:** 📋 Spec — ready for implementation. Phase 2 of the +T16 cross-stack interop suite landed every other P0 scenario +(I1, I2, I3, I8, I10, I11) but I4 stereo was deferred because +it requires a non-trivial change in `:nestsClient` production +code, not just test plumbing. This plan scopes that change. + +**Origin:** `nestsClient/plans/2026-05-06-cross-stack-interop-test.md` +table row I4: "Stereo Opus (`numberOfChannels=2`); freq differs L/R +(440 / 660)". Both forward (Amethyst speaker → hang-listen) and +reverse (hang-publish → Amethyst listener) are P0. + +**Branch convention:** new branch — don't fold into the cross- +stack-test branch. Suggested name `feat/nests-stereo-broadcast`. + +## Goal + +End-to-end verify that an Amethyst Kotlin speaker broadcasting a +stereo Opus stream is intelligible to the reference +`hang-listen` Rust binary, and vice versa, with a different +frequency on each channel asserted independently in the decoded +PCM. The scenario lands as two new tests on the existing +`HangInteropTest` suite gated by `-DnestsHangInterop=true`. + +## What's blocking I4 today + +Three pieces of `:nestsClient` production code hard-code mono: + +1. **Catalog rendition** — `MoqLiteHangCatalog.opusMono48k(...)` is + the only catalog factory and it pins + `numberOfChannels = 1`. The cached + `OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES` is the only catalog payload + the speaker ships (referenced in `MoqLiteNestsSpeaker.kt:160` + and `ReconnectingNestsSpeaker.kt:589`). A stereo broadcast + needs a new catalog factory + a new cached payload. + +2. **Audio format** — `AudioFormat.CHANNELS = 1` is a top-level + constant in `nestsClient/src/commonMain/.../audio/Audio.kt`. + Used by `MediaCodecOpusEncoder` (encoder configuration) and + `MediaCodecOpusDecoder` (decoder configuration). A stereo + broadcast can't simply override this without breaking every + mono call site. + +3. **Listener decoder** — `MoqLiteNestsListener` constructs the + `OpusDecoder` with `channelCount = 1` (search the file). For + listener-side stereo support it needs to read the catalog's + `numberOfChannels` and pass that to the decoder factory. + +The encoder itself (`MediaCodecOpusEncoder`) already accepts a +`channels: Int` constructor parameter on Android — but it's never +called with `2`. Same for `JvmOpusEncoder` (test-side, already +supports stereo via `channelCount`). + +## Production-side change set + +### 1. Make `AudioFormat.CHANNELS` a per-stream concern, not a global + +Rename / repurpose: + +```kotlin +object AudioFormat { + const val SAMPLE_RATE_HZ: Int = 48_000 + /** Default mono — most call sites don't override. */ + const val DEFAULT_CHANNELS: Int = 1 + const val FRAME_SIZE_SAMPLES: Int = 960 + const val FRAME_DURATION_US: Long = 20_000 + const val BYTES_PER_SAMPLE: Int = 2 +} +``` + +Audit every reference to `AudioFormat.CHANNELS`. Each call site +that's actually mono-fixed (e.g. mic capture defaults) should +inline `1`; everything else should take a `channelCount` parameter. + +This is the largest part of the change — concrete files to touch: + +- `MediaCodecOpusEncoder.kt` (Android): already has + `channels: Int = 1` constructor parameter, no change needed. +- `MediaCodecOpusDecoder.kt` (Android): same. +- `JvmOpusEncoder.kt` (jvmTest): no change. +- `JvmOpusDecoder.kt` (jvmTest): no change. +- `AudioRecordCapture` (Android microphone source): keep mono + hard-coded — the microphone is a single-channel device. +- `NestPlayer` / `AudioPlayer`: needs to know the rendition's + channel count to size its mixer / `AudioTrack` config. Add + a `channelCount` parameter to `AudioPlayer.start()` (or a + per-stream `AudioPlayerFactory(channelCount)` interface). +- `NestMoqLiteBroadcaster.peakAmplitude(pcm: ShortArray)`: + currently treats the array as planar mono. Stereo PCM is + interleaved L/R; the function must compute peak across both + channels. Either iterate stride-2 explicitly or pass + `channelCount` and skip the level callback for stereo. + +### 2. Catalog factory + cache + +Drop `MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES`'s +status as the only fast-path catalog. Either: + +A) Add a parallel `OPUS_STEREO_48K_AUDIO_DATA_JSON_BYTES` constant + built from `opusMono48k(...).copy(audio.renditions[X].copy(numberOfChannels=2))` + shape, OR + +B) Generalise to `opus48k(audioTrackName, numberOfChannels)` and + memoise both shapes via a small map keyed on + `(trackName, numberOfChannels)`. + +(B) is cleaner — it generalises to future `(48k, 2 channels, 64 +kbit/s)` etc. variants without exploding the constant count. +The wire shape stays byte-stable per-shape because +`encodeJsonBytes()` already pins `encodeDefaults = false` + +`explicitNulls = false` + a deterministic field order. + +Wire `MoqLiteNestsSpeaker.startBroadcasting` and +`ReconnectingNestsSpeaker` to read the channel count from a +`broadcastConfig: AudioBroadcastConfig` parameter (new) — pass +it through to the catalog factory. Default +`AudioBroadcastConfig(channelCount = 1)` so existing callers are +unaffected. + +### 3. Listener decoder discovery + +`MoqLiteNestsListener.subscribeSpeaker` today builds the decoder +with mono. It already subscribes to the catalog track in +parallel — the patch is to **block on the first catalog message**, +read `audio.renditions[].numberOfChannels`, and +construct the `OpusDecoder` with that count. + +Concrete change: make `subscribeSpeaker` `suspend`-await +`hang::CatalogConsumer::next()` once before opening the audio +subscription, plumb the discovered channel count into the +`OpusDecoder` factory call. + +Edge case: if the catalog's `numberOfChannels` field is missing +(older publishers), default to 1 (matches the kdoc on +`MoqLiteHangCatalog.AudioRendition.numberOfChannels`). + +## Test side + +### Stereo `SineWaveAudioCapture` + +Extend the existing test fixture to support stereo: + +```kotlin +class SineWaveAudioCapture( + private val freqHz: Int = 440, + /** + * Per-channel frequency overrides. If null, every channel + * runs at [freqHz] (matches mono-broadcast-of-a-stereo). + * If non-null, must have [channelCount] entries — useful + * for I4 where left = 440 and right = 660 lets the FFT + * assertion bisect each channel cleanly. + */ + private val freqHzPerChannel: IntArray? = null, + private val channelCount: Int = 1, + private val amplitude: Short = 16_383, +) : AudioCapture { + override suspend fun readFrame(): ShortArray? { + val out = ShortArray(FRAME_SIZE_SAMPLES * channelCount) + for (i in 0 until FRAME_SIZE_SAMPLES) { + for (ch in 0 until channelCount) { + val freq = freqHzPerChannel?.get(ch) ?: freqHz + val v = (amplitude * sin(2.0 * PI * freq * (sampleIdx + i) / SAMPLE_RATE_HZ)).toInt() + out[i * channelCount + ch] = v.toShort() + } + } + // … existing pacing + sampleIdx update … + } +} +``` + +Pacing logic stays as-is (one `delay(FRAME_NANOS / 1M)` per frame). + +### `PcmAssertions.assertFftPeak` per-channel + +The existing helper assumes mono (interprets the array as +planar samples). For stereo, add an overload: + +```kotlin +fun assertFftPeakStereo( + interleaved: FloatArray, + expectedHzL: Double, + expectedHzR: Double, + halfWindowHz: Double = 5.0, + sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ, +) +``` + +That deinterleaves into two `FloatArray`s and runs the existing +FFT assertion on each. ZCR can be done the same way. + +### `hang-listen` already supports stereo + +Verify by reading the existing +`cli/hang-interop/hang-listen/src/main.rs`: it already passes +`audio_cfg.channel_count` into `opus::Decoder::new(...)` and +allocates a stereo PCM buffer if the catalog says so. No Rust +change needed. + +### `hang-publish` already supports stereo + +Verify by reading `cli/hang-interop/hang-publish/src/main.rs`: +the `--channels <1|2>` flag plumbs through to the catalog, the +opus encoder, and the sine generator. No Rust change needed. +*(Note: the current sine generator uses the same frequency on +both channels. For I4 reverse-direction we'd want +`--freq-hz-l 440 --freq-hz-r 660` to generate a true L/R +asymmetric tone. Small Rust addition.)* + +### Two new HangInteropTest scenarios + +```kotlin +/** I4 forward — Kotlin speaker broadcasts L=440 R=660 stereo. */ +@Test +fun amethyst_speaker_to_hang_listener_stereo_440_660() = runBlocking { + val out = runSpeakerToHangListen( + speakerSeconds = 5, + captureFirstFrame = false, + captureFactoryOverride = { + SineWaveAudioCapture( + channelCount = 2, + freqHzPerChannel = intArrayOf(440, 660), + ) + }, + encoderFactoryOverride = { JvmOpusEncoder(channelCount = 2) }, + broadcastConfig = AudioBroadcastConfig(channelCount = 2), + ) + val pcm = readFloat32Pcm(out.pcmFile) + val warmup = AudioFormat.SAMPLE_RATE_HZ / 25 * 2 // stereo: skip 2x + val analysed = pcm.copyOfRange(warmup, pcm.size) + PcmAssertions.assertFftPeakStereo(analysed, 440.0, 660.0) +} + +/** I4 reverse — hang-publish R/L stereo → Kotlin listener. */ +@Test +fun rust_hang_publish_stereo_to_kotlin_listener_440_660() = runBlocking { + // … hang-publish with --channels 2 --freq-hz-l 440 --freq-hz-r 660 … + // … Kotlin listener via connectNestsListener; decoder discovers + // numberOfChannels = 2 from the catalog and builds a stereo + // JvmOpusDecoder … + // … assert FFT peaks per channel as above … +} +``` + +`runSpeakerToHangListen` needs three new optional parameters +(`captureFactoryOverride`, `encoderFactoryOverride`, +`broadcastConfig`); existing callers pass nothing and keep mono +behavior. + +## Phases + +Total: ~1.5 days. + +**Phase 1 — production-side prep (~5 hr).** +1. Refactor `AudioFormat.CHANNELS` → audit + per-stream parameterisation. +2. Generalise `MoqLiteHangCatalog.opusMono48k` to `opus48k(name, channels)` + + memoise the JSON bytes per shape. +3. Plumb `AudioBroadcastConfig(channelCount)` through + `connectNestsSpeaker` / `MoqLiteNestsSpeaker` / `ReconnectingNestsSpeaker`. +4. Plumb catalog-discovered channel count through + `connectNestsListener` / `MoqLiteNestsListener`. + +Verify Android + Desktop builds compile; existing mono Kotlin↔Kotlin +tests stay green. + +**Phase 2 — test-side fixtures (~2 hr).** +5. Extend `SineWaveAudioCapture` with `channelCount` / + `freqHzPerChannel`. +6. Add `PcmAssertions.assertFftPeakStereo` / + `assertZeroCrossingRateStereo` (deinterleave + run mono helpers). +7. Extend `runSpeakerToHangListen` with capture/encoder/config + overrides. + +**Phase 3 — Rust publisher tweak (~30 min).** +8. Add `--freq-hz-l` / `--freq-hz-r` to `hang-publish` so the + reverse direction has a per-channel asymmetric tone. Default + to `--freq-hz` value for both when unset (back-compat). + Rebuild via `cargo build --release -p hang-publish`. + +**Phase 4 — wire I4 forward + reverse (~3 hr).** +9. Land `amethyst_speaker_to_hang_listener_stereo_440_660` in + `HangInteropTest`. +10. Land `rust_hang_publish_stereo_to_kotlin_listener_440_660` + in `HangInteropTest` (or a new `HangInteropReverseTest` if + the file gets too long). +11. Verify both green with 3 sequential + `./gradlew :nestsClient:jvmTest -DnestsHangInterop=true + --rerun-tasks` runs. + +## Risks + mitigations + +| Risk | Mitigation | +|---|---| +| `AudioFormat.CHANNELS = 1` audit misses a call site | Grep across the entire repo (`amethyst/`, `commons/`, `desktopApp/`, `nestsClient/`) for `AudioFormat.CHANNELS` and `CHANNELS = 1`; change each by hand. | +| Android `AudioTrack` channel-mask change breaks mono playback | The default constant stays `DEFAULT_CHANNELS = 1`; existing call sites that omit `channelCount` keep mono behavior. Mono regression test (`NestPlayerTest`) catches drift. | +| Listener-side catalog-await blocks subscription forever if the publisher never emits the catalog | Use `withTimeoutOrNull(2_000)` around the catalog read; default to mono on timeout (with a warning log) to preserve the failure-tolerant existing behavior. | +| Stereo Opus interleaved PCM byte-order surprises (LE vs BE on the wire) | Opus is endianness-neutral on the wire; PCM in the codec API is always native-endian short[]. Deinterleave in software. | +| `opusMono48k` callers in `:commons` (the parser) aren't tested in this plan | The parser is deserialise-only and accepts any rendition map. Verify by adding one unit test in `:commons` that round-trips a stereo catalog. | +| The catalog hook race (Phase 2 results doc) shows up worse for stereo because of the larger initial frame | Same fix as I1: keep `framesPerGroup = 5` and have the test sequence speaker.startBroadcasting → delay 150 ms → spawn hang-listen. The race is a pre-existing condition, not stereo-specific. | + +## Definition of done + +1. `HangInteropTest.amethyst_speaker_to_hang_listener_stereo_440_660` + green, 3 sequential `--rerun-tasks` runs no flake. +2. `HangInteropTest.rust_hang_publish_stereo_to_kotlin_listener_440_660` + green, same stability. +3. Existing mono tests (`amethyst_speaker_to_hang_listener_static_tone_440`, + `late_join_listener_still_decodes_tail`, + `mid_broadcast_mute_shortens_decoded_pcm`, + `subscribe_drop_for_unknown_track`, + `long_broadcast_60s_tone_round_trips`, + `rust_hang_publish_to_rust_hang_listener_round_trip_440`) stay + green. +4. The `KotlinSpeakerKotlinListenerThroughNativeRelayTest` + diagnostic still passes when run with + `-DnestsHangInteropDiagnostic=true`. +5. Android instrumented tests on a real device: at least one + stereo broadcast end-to-end through the `nostrnests/nests` + Docker harness (gated by `-DnestsInterop=true`). The + production flow has to work, not just the cross-stack + `:nestsClient` sub-suite. +6. Results filed at + `nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario-results.md` + summarising what landed, any deviations from this plan, and + any production code follow-ups discovered during the audit. + +## Out of scope (intentionally) + +- **Multi-bitrate per channel.** Stereo as one 64 kbit/s rendition + is enough; per-channel rate-tuning is a renderer concern. +- **5.1 / spatial audio.** Catalog field is `numberOfChannels`, + but the audio pipeline assumes interleaved planar — beyond + stereo would need a separate plan. +- **Browser side I4.** `nestsClient-browser-interop/` doesn't + exist yet (Phase 4 of the parent plan); when it lands the + same I4 shape ports straight to a `BrowserInteropTest`. +- **Per-channel mute.** The existing `setMuted(true)` mutes the + whole broadcast; a per-channel mute is a UI concern out of + scope here. + +## When picking up + +This plan is self-contained. The agent should: + +1. Read `nestsClient/plans/2026-05-06-cross-stack-interop-test.md` + (the parent plan) and + `nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md` + (Phase 1 + 2 status) to understand the harness. +2. Skim `nestsClient/src/jvmTest/.../interop/native/HangInteropTest.kt` + for the existing scenario shape — the stereo test reuses the + same `runSpeakerToHangListen` helper. +3. Implement Phase 1 (production prep) FIRST, with the existing + mono tests as the regression net. Don't mix production + refactors with the I4 test wiring — keep two clean commits. +4. After every phase: run `./gradlew :nestsClient:jvmTest + -DnestsHangInterop=true` and confirm green. Don't proceed to + the next phase until the prior is clean. +5. Each scenario commits separately. The production-side + refactor (Phase 1) commits as a single unit. diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/HangInteropTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/HangInteropTest.kt index 3cd00155b..d7cba6997 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/HangInteropTest.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/HangInteropTest.kt @@ -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 = "", + 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`