Files
amethyst/nestsClient/plans/2026-05-06-cross-stack-interop-test.md
T
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

734 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```toml
# 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.
```toml
# 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-listen` — `nestsClient/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::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` — `nestsClient/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-shim` — `nestsClient/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:
```kotlin
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).
```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<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):
```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 = "<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):
```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 `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)
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/tests/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/<existing-workflow>.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
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`.