Files
amethyst/nestsClient/plans/2026-05-06-cross-stack-interop-test.md
Claude 589ced580c docs(nests): refresh all T16 + cliff plans to current state
8 plan files updated to reflect what's actually shipped vs. what
each doc said before:

- 2026-05-06-cross-stack-interop-test.md: 'Spec — ready to
  implement' → 'Implemented and merged' with pointers to results,
  gap matrix, and closure roadmap.

- 2026-05-06-cross-stack-interop-test-results.md: scenario
  inventory updated to show all branches merged into
  claude/cross-stack-interop-test-XAbYB; suite-flake caveats
  flagged with cross-refs to the routing investigation; file
  inventory now lists BrowserInteropTest + PlaywrightDriver +
  the moved nestsClient/tests/browser-interop/ tree (no more 'in
  sister branches not yet merged' section).

- 2026-05-06-cross-stack-interop-test-gap-matrix.md: T8/T10/T11/
  T12/T13 all show hang  + browser ; I13/I14 'NOT YET LANDED'
  callouts removed; the 'in sister branch' qualifier on T13 / I7
  / I6 dropped; coverage-holes section replaced with caveat
  pointers to the routing investigation.

- 2026-05-06-i4-stereo-cross-stack-scenario.md: 'Spec — ready for
  implementation' → 'Landed' with PR #2755 + the three test
  scenarios that ship the assertion.

- 2026-05-06-phase4-browser-harness.md: 'Spec — ready for
  implementation' → 'Landed', with notes on how the implementation
  diverged (used Container.Legacy.Consumer not @moq/watch; cert
  pinning via serverCertificateHashes; I2/I3/I4/I13/I14 originally
  deferred but later landed).

- 2026-05-06-phase4-browser-harness-results.md: status updated to
  reflect 4.D CI removed per maintainer ask; layout note for
  the nestsClient/tests/browser-interop/ relocation; full
  scenario list reconciled with results doc.

- 2026-05-07-late-join-catalog-flake-investigation.md: status
  updated to mention 00f6cba31 + 207057374 were reverted, and
  link to 2026-05-07-moq-relay-routing-investigation.md as the
  action plan. Adds note that the flake also affects four
  browser-tier scenarios after Browser I7 landed.

- 2026-05-01-quic-stream-cliff-investigation.md: adds an HCgOY-
  override banner — recommendation 2 (framesPerGroup = 5) was
  superseded 4 days later by HCgOY field tests landing the prod
  default at 50. Cross-refs the reconciliation + production-
  rerun plans. Open follow-up #3 ('reset to 1') updated with
  the same context.

No code or test changes.
2026-05-07 14:52:21 +00:00

33 KiB
Raw Permalink Blame History

Plan: cross-stack interop test (T16)

Status: Implemented and merged. See:

  • 2026-05-06-cross-stack-interop-test-results.md for the scenario inventory + per-scenario status
  • 2026-05-06-cross-stack-interop-test-gap-matrix.md for the T-series wire-fix → asserting-scenario mapping (DoD #5)
  • 2026-05-07-t16-closure-roadmap.md for the next-steps roadmap (residual upstream relay flake → tighten assertions → wire CI)

The spec text below is preserved for archaeology; some scenarios were re-shaped during implementation (I12 GOAWAY is N/A in moq-lite-03; I13's framesPerGroup = 50 got pinned to 5 due to the local relay's per-stream byte cliff).

Origin: audit of claude/debug-audio-dropout-n0g6Z against the audio path verified all wire fixes (T1T14) 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 (T1T14) 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:<rand>     │   │ matches relay's  │         │  - hang-listen <args>       │
 └─────────▲────────────┘   └──────────────────┘         │  - hang-publish <args>      │
           │                                             │                             │
           │                                             │ 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 nestsClient/tests/hang-interop/REV file.
  • Build: cargo build --release -p moq-relay --manifest-path <pinned/checkout>/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):
    # relay.toml
    [server]
    listen = "127.0.0.1:<random-port>"
    
    [tls]
    cert = "<temp>/cert.pem"
    key = "<temp>/key.pem"
    
    [auth]
    jwks_path = "<temp>/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": "<broadcast-namespace>",
  "put":  ["<pubkey>"]      // for publisher tokens
  "get":  [""]              // for listener tokens (read all under root)
  "iat":  <now>,
  "exp":  <now + 600>
}

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 nestsClient/tests/hang-interop/ in this repo.

# nestsClient/tests/hang-interop/Cargo.toml
[workspace]
members = ["hang-listen", "hang-publish", "udp-loss-shim"]

[workspace.dependencies]
hang        = { git = "https://github.com/kixelated/moq", rev = "<pinned>" }
moq-lite    = { git = "https://github.com/kixelated/moq", rev = "<pinned>" }
web-transport-quinn = "0.11"
tokio       = { version = "1", features = ["full"] }
anyhow      = "1"

Three binaries:

hang-listennestsClient/tests/hang-interop/hang-listen/src/main.rs

Args: --relay-url <https://...> --jwt <token> --broadcast <path> --duration <secs> --output-pcm <path-or-->. 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::decodeEncodedAudioChunkopus::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-publishnestsClient/tests/hang-interop/hang-publish/src/main.rs

Args: --relay-url <...> --jwt <token> --broadcast <path> --freq-hz <int> --duration <secs> --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: <channels>, 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-shimnestsClient/tests/hang-interop/udp-loss-shim/src/main.rs

Args: --listen 127.0.0.1:<port> --upstream 127.0.0.1:<port> --loss-rate <0..1>. Behaviour: standard tokio UDP relay. For each datagram received, if rng.gen::<f32>() < loss_rate { drop } else forward. Used for I9.

Browser harness (bun + Playwright)

New module at nestsClient/tests/browser-interop/.

nestsClient/tests/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": "<pinned>",
    "@moq/watch": "<pinned>",
    "@moq/publish": "<pinned>",
    "@moq/hang": "<pinned>"
  }
}

Pin to the same npm versions that nostrnests/nests NestsUI-v2/package.json ships. Document the rev in nestsClient/tests/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:<port>/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 <int> --out-pcm <path>.

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=<sha256-of-our-test-cert>
--enable-quic
--origin-to-force-quic-on=127.0.0.1:<relay-port>

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:

class NativeMoqRelayHarness {
    val relayUrl: String  // https://127.0.0.1:<port>/<broadcast-path>
    fun mintJwt(broadcastNamespace: String, publish: Boolean, pubkeyHex: String): String
    fun loopbackHostPort(): Pair<String, Int>
    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).

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:

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<Double>?
}

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

I1I4, I8, I10I12 + 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, T11T13). I6, I15 are P1 (multi- listener + ALPN drift detection).

How each scenario is implemented

Pattern for Amethyst → Rust (e.g. I1):

@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 = "<unused>",
            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):

@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):

@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 nestsClient/tests/hang-interop/.cache/moq or reference via git Cargo source. Document rev in nestsClient/tests/hang-interop/REV.
  2. Write nestsClient/tests/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 nestsClient/tests/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)

  1. Reverse direction: hang_publisher_to_amethyst_listener_static_tone.
  2. 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").
  3. 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)

  1. I5 (speaker hot-swap mid-broadcast): force JWT refresh via connectReconnectingNestsSpeaker(tokenRefreshAfterMs = 2_500). Assert no silence window on the listener side > 200 ms.
  2. I7 (publisher hot-swap on the Rust side): kill + restart hang-publish process; expect listener to see Ended → Active
    • decoder reset (T13).
  3. 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.

  4. 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)

  1. Bootstrap nestsClient/tests/browser-interop/: bun init, install @moq/lite @moq/watch @moq/publish @moq/hang at pinned versions matching nostrnests/nests NestsUI-v2. Document rev.
  2. Write listen.ts + pcm-tap-worklet.ts + listen.html. Mirror NostrNests' transport/moq-transport.ts Watch.Broadcast config.
  3. Write publish.ts + publish.html (sine source via Oscillator → MediaStreamAudioDestinationNode).
  4. Write server.ts (bun static + WebSocket back-channel, writes PCM to a file on disk).
  5. Write Gradle task interopBuildBrowserHarness that runs bun install && bun build.
  6. 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.
  7. Wire I1, I2, I3, I4 through BrowserInteropTest.kt.

Phase 5 — Browser-only scenarios (0.5 day)

  1. I13 (framesPerGroup=50 against actual Container.Consumer): long broadcast, assert no eviction-driven silence beyond expected bounds.
  2. 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).
  3. 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/<existing-workflow>.yml:

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
            nestsClient/tests/hang-interop/target
          key: ${{ runner.os }}-cargo-${{ hashFiles('nestsClient/tests/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/tests/browser-interop/node_modules
          key: ${{ runner.os }}-browser-${{ hashFiles('nestsClient/tests/browser-interop/bun.lockb', 'nestsClient/tests/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 nestsClient/tests/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: I1I4, 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: I1I4, 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 T1T14 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. nestsClient/tests/hang-interop/REV and nestsClient/tests/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 12 + 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.