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 mention00f6cba31+207057374were 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.
33 KiB
Plan: cross-stack interop test (T16)
Status: ✅ Implemented and merged. See:
2026-05-06-cross-stack-interop-test-results.mdfor the scenario inventory + per-scenario status2026-05-06-cross-stack-interop-test-gap-matrix.mdfor the T-series wire-fix → asserting-scenario mapping (DoD #5)2026-05-07-t16-closure-roadmap.mdfor 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 (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/moqRusthangcrate 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/watchand@moq/publishrunning in headless Chromium via Playwright. Validates the actual production stack: Chromium QUIC, WebCodecsAudioDecoder, AudioWorklet rendering,WT-Available-Protocolssub-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/moqpinned to a known-good rev. Use the same rev as the productionnostrnests/nestsDocker image'smoq-relaybuild, if discoverable; otherwise pin to the latest commit onmainat implementation time and document it in anestsClient/tests/hang-interop/REVfile. - 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):
Exact field names depend on the pinned moq-relay version. Verify at implementation time by reading
# 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"moq-relay/src/config.rsat 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-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:
- Connect to relay via
web-transport-quinnwith WT-Available-Protocols =["moq-lite-04", "moq-lite-03"](matches Amethyst). - Open a
moq-litesession. - Spawn
hang::Watchon the broadcast. - Subscribe
catalog.jsonviahang::Catalog::fetch. Pick the first audio rendition withcontainer.kind == "legacy". - Subscribe to that rendition's track. For each frame:
Container::Legacy::Format::decode→EncodedAudioChunk→opus::Decoder::decode(oraudiopus) → write Float32 samples to--output-pcm. - Exit after
--durationseconds OR if catalog saysEnded.
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:
- Connect, open session, claim broadcast under given path.
- Publish a
catalog.jsontrack with hang Root JSON declaring oneaudio/datarendition:{ codec: "opus", container: { kind: "legacy" }, sampleRate: 48000, numberOfChannels: <channels>, bitrate: 32000, jitter: 20 }. - Generate sine wave at
freq-hzHz at 48 kHz, encode via libopus (audiopuscrate), wrap each packet asvarint(timestamp_us) + opus_packet, push into groups of 5 frames, FIN per group. - Run for
durationseconds, then sendAnnounce::Endedand 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:
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:
- Lazily build cargo binaries on first
@BeforeAll. - Generate ES256 keypair + JWKS file.
- Generate self-signed cert.
- Start
moq-relaysubprocess; await stderr "listening on" line; record port. - Start bun static server (only if browser path used in the test class).
- 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.ktnestsClient/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):
@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)
- Pick
kixelated/moqrev. Clone tonestsClient/tests/hang-interop/.cache/moqor reference viagitCargo source. Document rev innestsClient/tests/hang-interop/REV. - Write
nestsClient/tests/hang-interop/Cargo.tomlworkspace + the three binary crates (hang-listen,hang-publish,udp-loss-shim). Verifycargo build --releasesucceeds end-to-end. - Add Gradle task
interopBuildHangSidecars(innestsClient/build.gradle.kts) that:- Runs
cargo build --releaseinnestsClient/tests/hang-interop/. - Exposes binary paths to JVM tests via
tasks.test { systemProperty(...) }. - Caches based on
Cargo.lockhash.
- Runs
- Write
NativeMoqRelayHarness.kt(subprocess management, JWT mint, cert generation, port reservation). - Write
SineWaveAudioCapture.kt,CapturingOpusDecoder.kt,PcmAssertions.kt. - Write a JVM-side Opus encoder/decoder stub if not already present
(
audiopus-equivalent — see existing:nestsClientto confirm what's available on JVM target; may need to add a JNI binding or reuse the Android one via a JVM-compatible build). - Wire one passing test:
HangInteropTest.amethyst_speaker_to_hang_listener_static_tone(I1, A→hang).
Phase 2 — Rust path scenarios (1 day)
- Reverse direction:
hang_publisher_to_amethyst_listener_static_tone. - 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"). - 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)
- I5 (speaker hot-swap mid-broadcast): force JWT refresh via
connectReconnectingNestsSpeaker(tokenRefreshAfterMs = 2_500). Assert no silence window on the listener side > 200 ms. - I7 (publisher hot-swap on the Rust side): kill + restart
hang-publishprocess; expect listener to see Ended → Active- decoder reset (T13).
- I9 (1% packet loss): wrap Kotlin speaker's transport with
udp-loss-shimbetween client and relay. Assert sample count ≥ 80% expected, FFT peak still at 440 Hz, no contiguous silence200 ms.
- 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)
- Bootstrap
nestsClient/tests/browser-interop/: bun init, install@moq/lite@moq/watch@moq/publish@moq/hangat pinned versions matchingnostrnests/nestsNestsUI-v2. Document rev. - Write
listen.ts+pcm-tap-worklet.ts+listen.html. Mirror NostrNests'transport/moq-transport.tsWatch.Broadcast config. - Write
publish.ts+publish.html(sine source via Oscillator → MediaStreamAudioDestinationNode). - Write
server.ts(bun static + WebSocket back-channel, writes PCM to a file on disk). - Write Gradle task
interopBuildBrowserHarnessthat runsbun install && bun build. - Write
PlaywrightDriver.ktshelling out tonpx playwright testor usingplaywright-java(Maven Central). Cert-pin via--ignore-certificate-errors-spki-listif feasible, else--ignore-certificate-errors. ExposeopenListenPageandopenPublishPage. - Wire I1, I2, I3, I4 through
BrowserInteropTest.kt.
Phase 5 — Browser-only scenarios (0.5 day)
- I13 (
framesPerGroup=50against actualContainer.Consumer): long broadcast, assert no eviction-driven silence beyond expected bounds. - 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).
- I15 (
WT-Available-Protocolsnegotiation): Playwright'sbrowser.newContext()exposes the response headers; assertWT-Protocolmatchesmoq-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
- Hang path: I1–I4, I8, I10, I11, I12 green on a fresh CI run with no flake. P0.
- Hang path: I5, I7, I9 green. P1.
- Browser path: I1–I4, I13, I14 green on a fresh CI run with no flake. P0.
- Browser path: I5, I7, I9, I10, I15 green. P1.
- 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. - Both
-DnestsHangInterop=trueand-DnestsBrowserInterop=truein the default PR-level GitHub Actions config. nestsClient/tests/hang-interop/REVandnestsClient/tests/browser-interop/REVdocument the pinned upstream revs.- 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
NostrNestsAuthInteropTestcovers 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:
:nestsClientiOS target builds but the test runner is JVM. iOS-specific bugs are caught by the shared-commonMaincode paths the JVM tests already exercise. moq-lite-04ALPN 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:
- 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). - Re-clone
kixelated/moqto/tmp/moqif 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/hangare all present (sparse-checkout if needed). - Implement Phases 1–2 + 4 (the P0 deliverable). Land Phase 3 + 5 in a follow-up if budget runs short.
- After every phase: run the relevant Gradle task and confirm green. Don't proceed to the next phase until the prior is clean.
- 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.