From 1f2532d6118da9ac21974d8acfc2525bbcf3de28 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 19:25:38 +0000 Subject: [PATCH] docs(nests): plan T16 cross-stack interop test (no Docker) Spec for an end-to-end interop suite that verifies Amethyst is intelligible to and can hear the canonical NostrNests stack without Docker. Two P0 reference stacks: - Rust path via the kixelated/moq `hang` crate (catches wire-shape regressions cheaply) - Browser path via @moq/watch + @moq/publish in headless Chromium driven by Playwright (catches Chromium QUIC, WebCodecs, and AudioWorklet quirks the Rust path can't see) Architecture: native moq-relay subprocess (cargo build), in-process ES256 JWT minter (replaces moq-auth Node sidecar), self-signed P-256 TLS cert, native cargo binaries for hang-listen / hang-publish / udp-loss-shim, bun static server hosting the browser harness. Test matrix I1-I15 covers every audit-branch wire fix (T1-T14) plus the framesPerGroup=50 + WebCodecs warmup interactions only the browser stack exercises. Phases 1-2 + 4 are the 3-day P0 deliverable; Phases 3 + 5 are the P1 hot-swap + transport-robustness follow-up. Self-contained: another agent should be able to execute Phases 1-2 + 4 end-to-end from this spec alone. https://claude.ai/code/session_01FZPQiniPmb88pwY9i78eqA --- .../2026-05-06-cross-stack-interop-test.md | 722 ++++++++++++++++++ 1 file changed, 722 insertions(+) create mode 100644 nestsClient/plans/2026-05-06-cross-stack-interop-test.md diff --git a/nestsClient/plans/2026-05-06-cross-stack-interop-test.md b/nestsClient/plans/2026-05-06-cross-stack-interop-test.md new file mode 100644 index 000000000..6176c757e --- /dev/null +++ b/nestsClient/plans/2026-05-06-cross-stack-interop-test.md @@ -0,0 +1,722 @@ +# Plan: cross-stack interop test (T16) + +**Status:** πŸ“‹ Spec β€” ready to implement. + +**Origin:** audit of `claude/debug-audio-dropout-n0g6Z` against the audio +path verified all wire fixes (T1–T14) by inspection, but the existing +interop tests are Kotlin↔Kotlin only. They pass even when both ends +share a wire-format bug. This plan adds a real cross-stack test against +two reference listener/publisher stacks: the kixelated/moq Rust `hang` +crate and a Chromium browser running `@moq/watch`. + +**Branch convention:** implement on a fresh branch (e.g. +`feat/nests-interop-cross-stack`). Don't fold into the audit branch. + +## Goal + +End-to-end verify that an Amethyst Kotlin speaker is intelligible to a +`@moq/hang`-based listener (the canonical NostrNests web stack), and +vice versa, against a real `kixelated/moq-rs` `moq-relay`. No Docker. +Catches every audio-path wire regression (T1–T14) with at least one +asserting scenario. + +## Decision: two P0 stacks, both required + +- **Rust path** uses the `kixelated/moq` Rust `hang` crate as the + listener / publisher. Validates **wire format** (catalog JSON, + legacy container envelope, Subscribe routing, group framing). Fast, + deterministic, no headless deps. +- **Browser path** uses `@moq/watch` and `@moq/publish` running in + headless Chromium via Playwright. Validates the **actual production + stack**: Chromium QUIC, WebCodecs `AudioDecoder`, AudioWorklet + rendering, `WT-Available-Protocols` sub-protocol negotiation. + +Browser is NOT optional. NostrNests is a browser, and the Rust path +can't see WebCodecs warmup quirks, AudioWorklet timing, or Chromium's +QUIC stack behaviour. + +## Why no Docker + +The previous interop tests use `nostrnests/nests` Docker compose for +relay + auth. Building those native instead is strictly simpler: + +- `moq-relay`: cargo binary, no infra. +- `moq-auth`: removed from the loop. JWT minting moves into Kotlin (we + already have ES256 primitives via `:quartz`). The relay's JWT + validator is the security-relevant half; issuance is implementation + detail. +- TLS cert: self-signed P-256 generated at test setup. + +Result: no Docker daemon, no Node sidecar, native `moq-relay` +subprocess + native cargo binaries + Chromium-via-Playwright. Runs in +any CI runner with rustup + bun + Chromium installed. + +`moq-auth`'s NIP-98 path is exercised separately by the existing +`NostrNestsAuthInteropTest` (which keeps the Docker'd auth sidecar). +Don't conflate. + +## Architecture + +``` + Test runner (Gradle :nestsClient:jvmTest) + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ moq-relay subprocess β”‚ β”‚ Kotlin in-proc β”‚ β”‚ Reference sidecars β”‚ + β”‚ (native cargo build) β”‚ β”‚ JWT minter ES256 β”‚ β”‚ β”‚ + β”‚ relay.toml + cert β”‚ β”‚ JWKS public key β”‚ β”‚ Path A β€” RUST (cargo) β”‚ + β”‚ 127.0.0.1: β”‚ β”‚ matches relay's β”‚ β”‚ - hang-listen β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ - hang-publish β”‚ + β”‚ β”‚ β”‚ + β”‚ β”‚ Path B β€” BROWSER (bun) β”‚ + β”‚ WebTransport over UDP loopback β”‚ - listen.html via @moq/watchβ”‚ + └────────────────────────────────────────────── - publish.html via @moq/publishβ”‚ + β”‚ - PCM tap worklet β”‚ + Kotlin speaker / listener β”‚ - WebSocket back-channel β”‚ + (in-process, real :quic stack) β”‚ - driven by Playwright β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Components + +### Native moq-relay subprocess + +Built once via cargo from the `kixelated/moq` workspace at a pinned +git rev. Cached. + +- Source: `https://github.com/kixelated/moq` pinned to a known-good + rev. Use the same rev as the production `nostrnests/nests` Docker + image's `moq-relay` build, if discoverable; otherwise pin to the + latest commit on `main` at implementation time and document it in a + `cli/hang-interop/REV` file. +- Build: `cargo build --release -p moq-relay --manifest-path /Cargo.toml`. +- Cache key: pinned rev sha. First run: ~30 s cold build. Warm: < 1 s. +- Config (rendered to a temp file at test setup): + ```toml + # relay.toml + [server] + listen = "127.0.0.1:" + + [tls] + cert = "/cert.pem" + key = "/key.pem" + + [auth] + jwks_path = "/test.jwks" + ``` + Exact field names depend on the pinned moq-relay version. Verify at + implementation time by reading `moq-relay/src/config.rs` at the + pinned rev. If the relay version requires different fields, adapt. + +### In-process JWT minter (Kotlin) + +ES256 keypair generated once per test class. Public half written to +`test.jwks` for the relay; private half held in-memory. Mint per-test +JWTs with claims: +``` +{ + "root": "", + "put": [""] // for publisher tokens + "get": [""] // for listener tokens (read all under root) + "iat": , + "exp": +} +``` +ES256 signing via `java.security.Signature` with `SHA256withECDSA` +plus DERβ†’raw conversion (or pull `nimbus-jose-jwt` if simpler β€” same +group of dependencies that may already be on the classpath via OkHttp). + +### Self-signed TLS cert + +Generated at suite setup via Java's `KeyPairGenerator` + Bouncy Castle +(`X509v3CertificateBuilder`) or by shelling out to `openssl req -x509`. +Output `cert.pem` + `key.pem` in PEM format for moq-relay; the +in-process Kotlin client uses a `PermissiveCertValidator` (already +available β€” see `nestsClient/src/jvmAndroid/.../transport/QuicWebTransportFactory.kt`'s +`certificateValidator` constructor parameter) so it accepts the +self-signed cert without populating the system trust store. + +### Rust hang sidecar binaries + +New cargo workspace at `cli/hang-interop/` in this repo. + +```toml +# cli/hang-interop/Cargo.toml +[workspace] +members = ["hang-listen", "hang-publish", "udp-loss-shim"] + +[workspace.dependencies] +hang = { git = "https://github.com/kixelated/moq", rev = "" } +moq-lite = { git = "https://github.com/kixelated/moq", rev = "" } +web-transport-quinn = "0.11" +tokio = { version = "1", features = ["full"] } +anyhow = "1" +``` + +Three binaries: + +#### `hang-listen` β€” `cli/hang-interop/hang-listen/src/main.rs` +Args: `--relay-url ` `--jwt ` `--broadcast ` `--duration ` `--output-pcm `. +Behaviour: +1. Connect to relay via `web-transport-quinn` with WT-Available-Protocols + = `["moq-lite-04", "moq-lite-03"]` (matches Amethyst). +2. Open a `moq-lite` session. +3. Spawn `hang::Watch` on the broadcast. +4. Subscribe `catalog.json` via `hang::Catalog::fetch`. Pick the first + audio rendition with `container.kind == "legacy"`. +5. Subscribe to that rendition's track. For each frame: + `Container::Legacy::Format::decode` β†’ `EncodedAudioChunk` β†’ + `opus::Decoder::decode` (or `audiopus`) β†’ write Float32 samples + to `--output-pcm`. +6. Exit after `--duration` seconds OR if catalog says `Ended`. + +Output format: little-endian Float32 PCM, no header. One channel for +mono, interleaved L/R for stereo (controlled by catalog's +`numberOfChannels`). The Gradle test reads this file or pipe. + +#### `hang-publish` β€” `cli/hang-interop/hang-publish/src/main.rs` +Args: `--relay-url <...>` `--jwt ` `--broadcast ` `--freq-hz ` `--duration ` `--channels <1|2>`. +Behaviour: +1. Connect, open session, claim broadcast under given path. +2. Publish a `catalog.json` track with hang Root JSON declaring one + `audio/data` rendition: `{ codec: "opus", container: { kind: "legacy" }, + sampleRate: 48000, numberOfChannels: , bitrate: 32000, + jitter: 20 }`. +3. Generate sine wave at `freq-hz` Hz at 48 kHz, encode via libopus + (`audiopus` crate), wrap each packet as `varint(timestamp_us) + + opus_packet`, push into groups of 5 frames, FIN per group. +4. Run for `duration` seconds, then send `Announce::Ended` and exit. + +#### `udp-loss-shim` β€” `cli/hang-interop/udp-loss-shim/src/main.rs` +Args: `--listen 127.0.0.1:` `--upstream 127.0.0.1:` `--loss-rate <0..1>`. +Behaviour: standard tokio UDP relay. For each datagram received, +`if rng.gen::() < loss_rate { drop }` else forward. Used for I9. + +### Browser harness (bun + Playwright) + +New module at `nestsClient-browser-interop/`. + +``` +nestsClient-browser-interop/ +β”œβ”€β”€ package.json +β”œβ”€β”€ tsconfig.json +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ listen.html +β”‚ β”œβ”€β”€ publish.html +β”‚ β”œβ”€β”€ listen.ts # imports @moq/watch + @moq/lite, sets up PCM tap +β”‚ β”œβ”€β”€ publish.ts # imports @moq/publish + @moq/lite, sets up sine source +β”‚ β”œβ”€β”€ pcm-tap-worklet.ts # AudioWorkletProcessor that posts Float32 to main +β”‚ └── server.ts # bun static + WebSocket back-channel +└── bun.lockb +``` + +`package.json` deps (all pinned via bun.lockb): +``` +{ + "dependencies": { + "@moq/lite": "", + "@moq/watch": "", + "@moq/publish": "", + "@moq/hang": "" + } +} +``` + +Pin to the same npm versions that `nostrnests/nests` `NestsUI-v2/package.json` +ships. Document the rev in `nestsClient-browser-interop/REV`. + +#### `listen.ts` +Mirrors NostrNests' `transport/moq-transport.ts` `Watch.Broadcast` +configuration verbatim where possible. Reads `relay`, `path`, `jwt`, +`ws`, `duration` from query params. Sets up an `AudioContext`, +registers `pcm-tap-worklet`, hooks the worklet into +`broadcast.audio.root`, posts every `inputs[0]` Float32Array out to +`ws://localhost:/pcm` over WebSocket. Closes when `duration` +elapses. + +#### `publish.ts` +Reads same params plus `freqHz` and `channels`. Builds an +`OscillatorNode` at `freqHz` connected to a +`MediaStreamAudioDestinationNode`. Passes the resulting MediaStream's +audio track into `Publish.Broadcast`'s `audio.source`. Closes after +`duration`. + +#### `server.ts` +Bun HTTP server: serves `listen.html`, `publish.html`, and the bundled +JS from `dist/`. Bun WebSocket server on a separate path: receives +PCM chunks from the harness page, writes them to a file the Gradle +test reads. Argv: `--port ` `--out-pcm `. + +#### Build +`bun install && bun build src/listen.ts src/publish.ts src/pcm-tap-worklet.ts --outdir dist --target browser`. +Wrapped by a Gradle task `interopBuildBrowserHarness` that runs once +per Cargo.lock-equivalent change. + +#### Driver + +`nestsClient/src/jvmTest/.../interop/native/PlaywrightDriver.kt` +shells out to `npx playwright test` (or uses `playwright-java` if +available; check Maven Central). Key flags for Chromium: +``` +--ignore-certificate-errors-spki-list= +--enable-quic +--origin-to-force-quic-on=127.0.0.1: +``` +Or simpler for a one-off test instance: +`--ignore-certificate-errors`. + +The driver returns when the harness page reports completion (via a +console message Playwright watches for, or a final WebSocket message). + +## Kotlin test infrastructure + +### `NativeMoqRelayHarness.kt` +Path: `nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarness.kt`. + +Public API: +```kotlin +class NativeMoqRelayHarness { + val relayUrl: String // https://127.0.0.1:/ + fun mintJwt(broadcastNamespace: String, publish: Boolean, pubkeyHex: String): String + fun loopbackHostPort(): Pair + fun hangListenBin(): Path + fun hangPublishBin(): Path + fun udpLossShimBin(): Path + fun browserHarnessUrl(): String // bun static server + fun browserPcmFile(): Path +} +``` + +Owned via JUnit `@BeforeAll` / `@AfterAll`. Lifecycle: +1. Lazily build cargo binaries on first `@BeforeAll`. +2. Generate ES256 keypair + JWKS file. +3. Generate self-signed cert. +4. Start `moq-relay` subprocess; await stderr "listening on" line; record port. +5. Start bun static server (only if browser path used in the test class). +6. Subprocesses tracked for kill in `@AfterAll`. + +### `SineWaveAudioCapture.kt` +Path: `nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/SineWaveAudioCapture.kt`. + +Implements `AudioCapture` from +`nestsClient/src/commonMain/.../audio/Audio.kt`. Per-frame produces +`960` 16-bit PCM samples of a `freqHz` sine at 48 kHz, advancing a +sample-counter (deterministic, frame-perfect, no wall-clock). + +```kotlin +class SineWaveAudioCapture( + private val freqHz: Int = 440, + private val channels: Int = 1, + private val amplitude: Short = 16_383, // ~half-scale, leaves headroom +) : AudioCapture { + private var sampleIdx: Long = 0L + override fun start() { /* no-op */ } + override suspend fun readFrame(): ShortArray? { + val samplesPerFrame = AudioFormat.FRAME_SIZE_SAMPLES + val out = ShortArray(samplesPerFrame * channels) + for (i in 0 until samplesPerFrame) { + val t = sampleIdx + i + val v = (amplitude * sin(2.0 * PI * freqHz * t / AudioFormat.SAMPLE_RATE_HZ)).toInt().toShort() + for (ch in 0 until channels) out[i * channels + ch] = v + } + sampleIdx += samplesPerFrame + return out + } + override fun stop() { /* no-op */ } +} +``` + +### `CapturingOpusDecoder.kt` +Path: `nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/CapturingOpusDecoder.kt`. + +Wraps the real `MediaCodecOpusDecoder` (or `JvmOpusDecoder` if added) +and tees decoded PCM into a thread-safe buffer the test asserts on. +Used for "Rust β†’ Amethyst" and "browser β†’ Amethyst" directions. + +### `PcmAssertions.kt` +Path: `nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/PcmAssertions.kt`. + +Signal-domain assertions: +```kotlin +object PcmAssertions { + /** Sample count within Β±tolerance of expected duration. */ + fun assertSampleCount(samples: FloatArray, expectedDurationSec: Double, sampleRate: Int = 48_000, tolerance: Double = 0.05) + /** FFT peak frequency in expected range. Implements a tiny power-of-2 FFT inline. */ + fun assertFftPeak(samples: FloatArray, expectedHz: Double, halfWindowHz: Double = 5.0, sampleRate: Int = 48_000) + /** RMS amplitude in [minRms, maxRms]. */ + fun assertRms(samples: FloatArray, minRms: Float = 0.3f, maxRms: Float = 0.9f) + /** Zero-crossing rate within tolerance β€” catches Opus predictor warble. */ + fun assertZeroCrossingRate(samples: FloatArray, expectedPerSecond: Double, tolerance: Double = 0.10, sampleRate: Int = 48_000) + /** Find a contiguous silence window (RMS < threshold) of at least minDurSec, return its [startSec, endSec]. */ + fun findSilenceWindow(samples: FloatArray, minDurSec: Double, threshold: Float = 0.01f, sampleRate: Int = 48_000): ClosedRange? +} +``` + +FFT: use a small in-test radix-2 implementation (~50 lines) so we don't +pull a JTransforms / JCommons dep. + +## Test scenarios + +All scenarios live in two files: +- `nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/HangInteropTest.kt` +- `nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt` + +Gated by `-DnestsHangInterop=true` and `-DnestsBrowserInterop=true` +respectively (mirror existing `-DnestsInterop=true`). + +Each scenario asserts (a) a process / Kotlin side runs to completion +(b) PCM samples arrive (c) signal-domain bounds hold. + +| ID | Scenario | Direction | Hang (Rust) | Browser | +|---|---|---|---|---| +| **I1** | 5 s sine 440 Hz, listener attached pre-broadcast | both | βœ… P0 | βœ… P0 | +| **I2** | Late-join: listener attaches at T+2 s of a 5 s broadcast | both | βœ… P0 | βœ… P0 | +| **I3** | Mute 1 s mid-3 s broadcast; assert silence window | Aβ†’ref | βœ… P0 | βœ… P0 | +| **I4** | Stereo Opus (numberOfChannels=2); freq differs L/R (440 / 660) | both | βœ… P0 | βœ… P0 | +| **I5** | Speaker hot-swap at T+2.5 s (force JWT refresh); listener no audio gap | Aβ†’ref | βœ… P1 | βœ… P1 | +| **I6** | One speaker, three listeners | Aβ†’ref | βœ… P1 | optional | +| **I7** | Web speaker drops + reconnects mid-broadcast | refβ†’A | βœ… P1 | βœ… P1 | +| **I8** | SubscribeDrop on unknown track | both | βœ… P0 | optional | +| **I9** | 1% packet loss via udp-loss-shim | Aβ†’ref | βœ… P1 | βœ… P1 | +| **I10** | 60 s broadcast, sample count β‰₯ 95% expected | Aβ†’ref | βœ… P0 | βœ… P0 | +| **I11** | Wire-byte capture: assert no `OpusHead\\1\\1...` in first audio frame | Aβ†’ref | βœ… P0 | n/a | +| **I12** | Goaway: drive moq-relay to send Goaway; speaker accepts gracefully | Aβ†’ref | βœ… P0 | optional | +| **I13** | `framesPerGroup=50` long broadcast against actual `Container.Consumer` | Aβ†’ref | n/a | βœ… P0 | +| **I14** | WebCodecs 3-frame warmup absorbs T8 OpusHead skip cleanly | Aβ†’ref | n/a | βœ… P0 | +| **I15** | `WT-Available-Protocols` Chromium roundtrip (verify negotiated subprotocol) | Aβ†’ref | n/a | βœ… P1 | + +I1–I4, I8, I10–I12 + I13, I14 are P0 β€” they cover every BLOCKER fix +in the audit (T1, T2, T3, T6, T7, T8, T9, T10) plus the framesPerGroup +regression risk and CSD-warmup interaction. I5, I7, I9 are P1 +(hot-swap + transport robustness, T11–T13). I6, I15 are P1 (multi- +listener + ALPN drift detection). + +### How each scenario is implemented + +Pattern for **Amethyst β†’ Rust** (e.g. I1): +```kotlin +@Test +fun amethyst_speaker_to_hang_listener_static_tone() { + val broadcast = "test/${UUID.randomUUID()}" + val speakerJwt = harness.mintJwt(broadcast, publish = true, pubkeyHex = TEST_PUBKEY) + val listenerJwt = harness.mintJwt(broadcast, publish = false, pubkeyHex = TEST_PUBKEY) + + // Spawn hang-listen subprocess, capturing PCM to a temp file. + val pcmFile = createTempFile(prefix = "hang-listen-pcm", suffix = ".bin") + val listenProc = ProcessBuilder( + harness.hangListenBin().toString(), + "--relay-url", harness.relayUrl, + "--jwt", listenerJwt, + "--broadcast", broadcast, + "--duration", "6", + "--output-pcm", pcmFile.toString(), + ).redirectErrorStream(true).start() + + // Drive Kotlin speaker via the production connect path. + val speaker = connectNestsSpeaker( + room = NestsRoomConfig( + authBaseUrl = "", + endpoint = harness.relayUrl, + hostPubkey = TEST_PUBKEY, + roomId = broadcast.substringAfterLast("/"), + ), + signer = TEST_SIGNER, + speakerPubkeyHex = TEST_PUBKEY, + captureFactory = { SineWaveAudioCapture(freqHz = 440) }, + encoderFactory = { JvmOpusEncoder() /* or whatever audiopus wrapper */ }, + // Bypass moq-auth: pre-mint our own JWT. + httpClient = StaticJwtNestsClient(speakerJwt), + transport = QuicWebTransportFactory(certificateValidator = PermissiveCertValidator), + scope = backgroundScope, + ) + val handle = speaker.startBroadcasting { /* level callback */ } + delay(5_000) + handle.close() + speaker.close() + + // Wait for hang-listen to exit cleanly. + assertTrue(listenProc.waitFor(2, TimeUnit.SECONDS)) + + // Read decoded PCM, run signal-domain assertions. + val pcm = readFloat32Pcm(pcmFile) + PcmAssertions.assertSampleCount(pcm, expectedDurationSec = 5.0) + PcmAssertions.assertFftPeak(pcm, expectedHz = 440.0) + PcmAssertions.assertZeroCrossingRate(pcm, expectedPerSecond = 880.0) +} +``` + +Pattern for **Rust β†’ Amethyst** (reverse of I1): +```kotlin +@Test +fun hang_publisher_to_amethyst_listener_static_tone() { + // ... spawn hang-publish subprocess ... + val capture = CapturingOpusDecoder(real = JvmOpusDecoder(channelCount = 1)) + val listener = connectNestsListener(/* injecting `capture` as the decoder */) + val handle = listener.subscribeSpeaker(TEST_PUBKEY, maxLatencyMs = 0) + NestPlayer(decoder = capture, ...).play(handle.objects) + delay(5_500) + val pcm = capture.takePcm() + PcmAssertions.assertFftPeak(pcm, expectedHz = 440.0) + // ... etc +} +``` + +Pattern for **Amethyst β†’ browser** (e.g. I1 browser): +```kotlin +@Test +fun amethyst_speaker_to_browser_listener_static_tone() { + val broadcast = "test/${UUID.randomUUID()}" + val speakerJwt = harness.mintJwt(broadcast, publish = true, pubkeyHex = TEST_PUBKEY) + val listenerJwt = harness.mintJwt(broadcast, publish = false, pubkeyHex = TEST_PUBKEY) + + // Drive browser harness via Playwright. + val pcmFile = harness.browserPcmFile() + val playwrightProc = PlaywrightDriver.openListenPage( + harnessUrl = "${harness.browserHarnessUrl()}/listen.html", + relayUrl = harness.relayUrl, + path = broadcast, + jwt = listenerJwt, + durationSec = 6, + wsOutPcm = pcmFile, + ) + + // Same Kotlin speaker setup as the hang test. + val speaker = connectNestsSpeaker(...) + val handle = speaker.startBroadcasting { } + delay(5_000) + handle.close() + speaker.close() + + assertTrue(playwrightProc.waitFor(3, TimeUnit.SECONDS)) + val pcm = readFloat32Pcm(pcmFile) + PcmAssertions.assertFftPeak(pcm, expectedHz = 440.0) + // Browser-side WebCodecs warmup drops first 3 frames; allow looser tolerance: + PcmAssertions.assertSampleCount(pcm, expectedDurationSec = 5.0, tolerance = 0.10) +} +``` + +## Phases + +Total: ~5 days. P0 deliverable (1+2+4) is **3 days**. + +### Phase 1 β€” Native moq-relay + Rust sidecars (1 day) + +1. Pick `kixelated/moq` rev. Clone to `cli/hang-interop/.cache/moq` or + reference via `git` Cargo source. Document rev in + `cli/hang-interop/REV`. +2. Write `cli/hang-interop/Cargo.toml` workspace + the three binary + crates (`hang-listen`, `hang-publish`, `udp-loss-shim`). Verify + `cargo build --release` succeeds end-to-end. +3. Add Gradle task `interopBuildHangSidecars` (in + `nestsClient/build.gradle.kts`) that: + - Runs `cargo build --release` in `cli/hang-interop/`. + - Exposes binary paths to JVM tests via `tasks.test { systemProperty(...) }`. + - Caches based on `Cargo.lock` hash. +4. Write `NativeMoqRelayHarness.kt` (subprocess management, JWT mint, + cert generation, port reservation). +5. Write `SineWaveAudioCapture.kt`, `CapturingOpusDecoder.kt`, + `PcmAssertions.kt`. +6. Write a JVM-side Opus encoder/decoder stub if not already present + (`audiopus`-equivalent β€” see existing `:nestsClient` to confirm + what's available on JVM target; may need to add a JNI binding or + reuse the Android one via a JVM-compatible build). +7. Wire one passing test: `HangInteropTest.amethyst_speaker_to_hang_listener_static_tone` + (I1, Aβ†’hang). + +### Phase 2 β€” Rust path scenarios (1 day) + +8. Reverse direction: `hang_publisher_to_amethyst_listener_static_tone`. +9. I2 (late-join), I3 (mute window), I4 (stereo), I8 (SubscribeDrop), + I11 (CSD wire-byte capture β€” capture the first uni stream's first + frame and assert `payload[0..7] != "OpusHead"`). +10. I12 (Goaway): add a control message to moq-relay's admin port if + available; otherwise simulate by sending Goaway from the speaker + side and verifying our handler. + +### Phase 3 β€” Hot-swap + transport robustness (1 day) + +11. I5 (speaker hot-swap mid-broadcast): force JWT refresh via + `connectReconnectingNestsSpeaker(tokenRefreshAfterMs = 2_500)`. + Assert no silence window on the listener side > 200 ms. +12. I7 (publisher hot-swap on the Rust side): kill + restart + `hang-publish` process; expect listener to see Ended β†’ Active + + decoder reset (T13). +13. I9 (1% packet loss): wrap Kotlin speaker's transport with + `udp-loss-shim` between client and relay. Assert sample count + β‰₯ 80% expected, FFT peak still at 440 Hz, no contiguous silence + > 200 ms. +14. I10 (60 s long broadcast): straightforward extension of I1 + with a long duration; mostly a test-runtime-budget check. + +### Phase 4 β€” Browser harness (1.5 days) + +15. Bootstrap `nestsClient-browser-interop/`: bun init, install + `@moq/lite` `@moq/watch` `@moq/publish` `@moq/hang` at pinned + versions matching `nostrnests/nests` `NestsUI-v2`. Document rev. +16. Write `listen.ts` + `pcm-tap-worklet.ts` + `listen.html`. Mirror + NostrNests' `transport/moq-transport.ts` Watch.Broadcast config. +17. Write `publish.ts` + `publish.html` (sine source via Oscillator + β†’ MediaStreamAudioDestinationNode). +18. Write `server.ts` (bun static + WebSocket back-channel, writes + PCM to a file on disk). +19. Write Gradle task `interopBuildBrowserHarness` that runs + `bun install && bun build`. +20. Write `PlaywrightDriver.kt` shelling out to `npx playwright test` + or using `playwright-java` (Maven Central). Cert-pin via + `--ignore-certificate-errors-spki-list` if feasible, else + `--ignore-certificate-errors`. Expose `openListenPage` and + `openPublishPage`. +21. Wire I1, I2, I3, I4 through `BrowserInteropTest.kt`. + +### Phase 5 β€” Browser-only scenarios (0.5 day) + +22. I13 (`framesPerGroup=50` against actual `Container.Consumer`): + long broadcast, assert no eviction-driven silence beyond expected + bounds. +23. I14 (WebCodecs warmup Γ— CSD skip interaction): assert that with + T8's CODEC_CONFIG filter active, the browser receives a normal + decode after the standard 3-frame warmup (no extra warmup penalty). +24. I15 (`WT-Available-Protocols` negotiation): Playwright's + `browser.newContext()` exposes the response headers; assert + `WT-Protocol` matches `moq-lite-03` (or whatever we advertised). + +## CI integration + +Update `.github/workflows/.yml`: + +```yaml +jobs: + default: + steps: + - run: ./gradlew :nestsClient:jvmTest + + hang-interop: + needs: default + steps: + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + - uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + cli/hang-interop/target + key: ${{ runner.os }}-cargo-${{ hashFiles('cli/hang-interop/Cargo.lock') }} + - run: ./gradlew :nestsClient:jvmTest -DnestsHangInterop=true + + browser-interop: + needs: default + steps: + - uses: actions-rs/toolchain@v1 + - uses: oven-sh/setup-bun@v1 + - run: npx playwright install --with-deps chromium + - uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cache/ms-playwright + nestsClient-browser-interop/node_modules + key: ${{ runner.os }}-browser-${{ hashFiles('nestsClient-browser-interop/bun.lockb', 'cli/hang-interop/Cargo.lock') }} + - run: ./gradlew :nestsClient:jvmTest -DnestsBrowserInterop=true +``` + +PR-level: all three. Browser-interop can run in parallel with +hang-interop. + +## Test runtime budget + +| Tier | Cold | Warm | +|---|---|---| +| Hang path full (12 scenarios Γ— ~7 s) | +30 s cargo build first time | ~85 s | +| Browser path (8 scenarios Γ— ~12 s) | +60 s bun install + Chromium boot | ~95 s | +| Combined warm | n/a | ~3 minutes | + +Acceptable for PR-level CI. + +## Risks + mitigations + +| Risk | Mitigation | +|---|---| +| `kixelated/moq` HEAD breaks pin | Pin git rev in `cli/hang-interop/Cargo.toml` + REV file. Bump deliberately. | +| moq-relay config schema changes between revs | Pin rev. Document `relay.toml` fields used. Smoke test: boot relay, assert known broadcast lookup works, in `@BeforeAll`. | +| Self-signed cert + Chromium WebTransport rejection | Use `--ignore-certificate-errors-spki-list` (preferred) or `--ignore-certificate-errors` for test-only Chromium instance. | +| JVM Opus encoder/decoder availability | If `:nestsClient` JVM target lacks Opus, vendor `audiopus` JNI or write a small wrapper. Reuse `MediaCodecOpusEncoder`/`Decoder` if a JVM `MediaCodec` polyfill exists; otherwise pure-Java `concentus` library is a fallback. Decide at Phase 1. | +| Port conflicts on CI | Use `ServerSocket(0).localPort` for relay + bun + WebSocket back-channel. | +| Cargo build cost in cold CI | Cache `target/` keyed on `Cargo.lock` hash. | +| Loss shim platform portability | Pure-Rust tokio shim works linux/mac/win. | +| Playwright flakiness in headless audio | Always pass `--enable-features=AutoplayPolicy=NoUserGestureRequired` + use AudioContext.resume() explicitly in the harness. | +| Audio comparison flake under loss | Use loose tolerance bounds + signal-domain assertions inherently noise-tolerant. | +| Sine wave vs real speech encoded differently | Document explicitly: sine catches wire-format + decoder correctness. Real-speech parity is a separate field-test concern, out of scope. | + +## Definition of done + +1. Hang path: I1–I4, I8, I10, I11, I12 green on a fresh CI run with + no flake. **P0**. +2. Hang path: I5, I7, I9 green. **P1**. +3. Browser path: I1–I4, I13, I14 green on a fresh CI run with no + flake. **P0**. +4. Browser path: I5, I7, I9, I10, I15 green. **P1**. +5. Audit-branch fixes T1–T14 each have β‰₯ 1 hang-tier AND/OR + browser-tier scenario asserting their wire output. Gap matrix + committed at `nestsClient/plans/2026-05-06-cross-stack-interop-test-gap-matrix.md`. +6. Both `-DnestsHangInterop=true` and `-DnestsBrowserInterop=true` + in the default PR-level GitHub Actions config. +7. `cli/hang-interop/REV` and `nestsClient-browser-interop/REV` + document the pinned upstream revs. +8. New plan filed at + `nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md` + (or similar) summarising what landed, any deviations from this + plan, and any follow-ups discovered. + +## Out of scope (intentionally) + +- **moq-auth NIP-98 path**: existing `NostrNestsAuthInteropTest` + covers it via Docker'd auth sidecar. Don't conflate. +- **Real microphone speech audio**: sine wave is sufficient for + wire-format and decoder correctness. Real-speech parity is a + separate field-test concern. +- **Real cellular network conditions**: I9's loss shim approximates; + real cellular has bursty + asymmetric loss + variable RTT. +- **Real device speaker DAC + hardware**: PCM is captured pre-DAC; + hardware playback is outside protocol scope. +- **Multi-rendition catalog**: nests audio rooms ship one rendition. + Defer multi-bitrate to a separate task if/when needed. +- **Video tracks**: nests is audio-only. +- **iOS Kotlin/Native interop**: `:nestsClient` iOS target builds but + the test runner is JVM. iOS-specific bugs are caught by the + shared-`commonMain` code paths the JVM tests already exercise. +- **`moq-lite-04` ALPN bump**: separate task, requires Lite-04 codec + diff (Announce.hops as OriginList, AnnounceInterest.exclude_hop, + Probe.rtt). Don't roll into this plan. + +## When picking up + +This plan is self-contained. The agent should: + +1. Read the audit branch's audio-path commits to understand which + wire shapes each scenario must validate + (`git log main..origin/claude/debug-audio-dropout-n0g6Z --oneline | grep nests`). +2. Re-clone `kixelated/moq` to `/tmp/moq` if not present: + `git clone --depth=1 https://github.com/kixelated/moq.git /tmp/moq`. + Confirm `/tmp/moq/rs/moq-relay`, `/tmp/moq/rs/hang`, `/tmp/moq/rs/moq-lite`, + `/tmp/moq/js/lite`, `/tmp/moq/js/watch`, `/tmp/moq/js/publish`, + `/tmp/moq/js/hang` are all present (sparse-checkout if needed). +3. Implement Phases 1–2 + 4 (the P0 deliverable). Land Phase 3 + 5 in + a follow-up if budget runs short. +4. After every phase: run the relevant Gradle task and confirm + green. Don't proceed to the next phase until the prior is clean. +5. Each P0 scenario commits separately. Phase 4 may bundle multiple + commits since the harness setup is one logical chunk. + +For protocol reference: + +- `kixelated/moq/rs/hang/src/catalog/`: catalog Root + Audio + Container schema. +- `kixelated/moq/rs/hang/src/container/legacy.rs`: legacy varint+payload format. +- `kixelated/moq/rs/moq-lite/src/lite/{publisher,subscribe,announce,group,version,probe}.rs`: moq-lite wire. +- `kixelated/moq/js/watch/src/{broadcast.ts,audio/{decoder,source}.ts}`: browser listener flow. +- `kixelated/moq/js/publish/src/{broadcast.ts,audio/encoder.ts}`: browser publisher flow. +- Existing audit findings for which wire shapes each fix targets: + this repo's `nestsClient/plans/2026-04-26-moq-lite-gap.md`.