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
@@ -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 <path>`. 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
```
@@ -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[<audioTrack>].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.