feat(nests): T16 Phase 1 — cross-stack interop harness scaffolding

Lands the load-bearing infra for the hang/Rust cross-stack interop
test plan (nestsClient/plans/2026-05-06-cross-stack-interop-test.md):
the cargo workspace, Gradle wiring to install moq-relay /
moq-token-cli + build sidecars, the Kotlin harness that boots a
real moq-relay subprocess on a random ephemeral UDP port with
self-signed TLS and unrestricted public auth, plus the signal-domain
PCM assertion library and deterministic sine-wave AudioCapture that
Phase 2 scenarios will assert against.

Phase-1 deviations from the spec are summarised in
nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md
— notably: --auth-public "" instead of the JWT minter (no
security-relevant difference for wire-format scenarios), --tls-generate
instead of in-Kotlin cert generation, cargo-install of upstream
binaries instead of an embedded kixelated/moq checkout, and
hang-listen / hang-publish / udp-loss-shim shipped as Phase-1 stubs
that compile + accept --flags but do nothing. Phase 2 fills those
in (and adds JVM Opus encoder/decoder).

Verified green via:
  ./gradlew :nestsClient:jvmTest \
      --tests "com.vitorpamplona.nestsclient.interop.native.*" \
      --tests "com.vitorpamplona.nestsclient.audio.PcmAssertionsTest" \
      -DnestsHangInterop=true

Default mode (no flag) cleanly skips the harness-dependent test.

https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
This commit is contained in:
Claude
2026-05-06 20:39:57 +00:00
parent e144226eee
commit 284a203070
17 changed files with 1843 additions and 0 deletions
@@ -0,0 +1,178 @@
# Plan: cross-stack interop test (T16) — Phase 1 results
**Status:** Phase 1 landed (scaffolding-only). Phase 2 + 3 + 4 + 5 deferred.
**Origin:** companion to `nestsClient/plans/2026-05-06-cross-stack-interop-test.md`.
This file records what actually shipped in Phase 1, the deviations from
the spec, and the concrete pickup points for Phase 2.
## What landed
### Cargo workspace (`cli/hang-interop/`)
- Workspace with three binary crates: `hang-listen`, `hang-publish`,
`udp-loss-shim`. **All three are Phase-1 stubs** — they parse their
CLI flags via `clap`, print a banner, and exit 0. Phase 2 fills the
bodies.
- `cli/hang-interop/REV` documents the pinned upstream
`kixelated/moq` rev (`9e2461ee...`) plus the published crate
versions on crates.io that track that rev (`moq-relay 0.10.25`,
`moq-token-cli 0.5.23`, `hang 0.15.8`, `moq-lite 0.15.15`,
`moq-native 0.13`).
- `cargo build --release` is verified green from the workspace root.
### Gradle integration (`nestsClient/build.gradle.kts`)
- `interopInstallMoqRelay` — `cargo install moq-relay --version
$moqRelayVersion --root <cache>` into
`~/.cache/amethyst-nests-interop/hang-interop-cargo/`. Skips when
the binary already exists in the cache.
- `interopInstallMoqTokenCli` — same shape for `moq-token-cli`.
- `interopBuildSidecars` — `cargo build --release` over the local
`cli/hang-interop/` workspace.
- `interopBuildHangSidecars` — umbrella task that depends on the
three above. Runs as a test dependency only when
`-DnestsHangInterop=true` is set.
- Test-task wiring forwards `nestsHangInteropSidecarsDir` and
`nestsHangInteropCargoBinDir` system properties so the harness
can find the binaries. `nestsHangInterop` itself is also
forwarded — mirrors the existing `nestsInterop` opt-in.
### Kotlin harness (`nestsClient/src/jvmTest/...`)
- `interop/native/NativeMoqRelayHarness.kt` — boots a real
`moq-relay` subprocess with `--tls-generate localhost` (relay
self-signs its cert at startup) and `--auth-public ""` (every
path is treated as public, no JWT required). Uses
`ServerSocket(0)` to reserve an ephemeral port. Tracks process
output in a 64-line ring buffer so a startup failure includes
the relay's stderr tail. Singleton-per-JVM via `shared()`,
shutdown via JVM hook, mirroring the existing
`NostrNestsHarness` pattern. Public surface: `relayUrl`,
`loopbackHostPort()`, `hangListenBin()`, `hangPublishBin()`,
`udpLossShimBin()`, `moqTokenBin()`.
- `audio/SineWaveAudioCapture.kt` — frame-perfect deterministic
sine wave at any frequency, mono, conforming to the existing
`AudioCapture` interface in `commonMain`.
- `audio/PcmAssertions.kt` — pure-Kotlin signal-domain assertions:
`assertSampleCount`, `assertRms`, `assertFftPeak` (Hann-windowed
iterative Cooley-Tukey, ~100 lines, no transform-library dep),
`assertZeroCrossingRate`, `findSilenceWindow`. The plan spec'd
these for Phase 1; everything is in commonTest now and ready
for the Phase 2 scenarios.
- `interop/native/NativeMoqRelayHarnessSmokeTest.kt` — the only
test that runs in Phase 1. Boots the harness, verifies the
relay binds its UDP port, verifies the sidecar binaries are
executable and the `hang-listen` stub runs cleanly. **Does
not** assert any wire format — that's Phase 2.
## Deviations from the spec
| Plan | Reality | Why |
|---|---|---|
| In-process Kotlin JWT minter (ES256, JWKS file mounted into relay) | Relay configured with `--auth-public ""`; no JWT required | The plan flagged JWT issuance as "implementation detail" — wire-format and protocol assertions don't need real auth, and `--auth-public` short-circuits the whole minter + JWKS file dance. JWT validation as an interop concern is already covered by the existing `NostrNestsAuthInteropTest` against the Docker'd `moq-auth`. The cargo-installed `moq-token` CLI is exposed via `moqTokenBin()` for Phase 2 scenarios that DO want to mint a real token (e.g. revocation, expiry, kid-mismatch). |
| Self-signed cert generated at suite setup via Bouncy Castle / `openssl req -x509` | `moq-relay --tls-generate localhost` (relay self-signs at startup) | `moq-native` ships this behaviour; saves us writing PEM I/O + cert generation. The Kotlin client can use the existing `PermissiveCertValidator` to skip chain validation. |
| `relay.toml` config file | CLI flags only | Same effect, fewer moving parts. Field names in the plan (`server.listen`, `tls.cert`/`key`, `auth.jwks_path`) were speculative; actual `moq-native` flags are `--server-bind`, `--tls-cert`/`--tls-key`/`--tls-generate`, `--auth-key`/`--auth-public`. |
| Build `moq-relay` from a pinned `kixelated/moq` checkout via `cargo build --release -p moq-relay` | `cargo install moq-relay --version 0.10.25 --root <cache>` | `moq-relay` and `moq-token-cli` are published on crates.io; `cargo install` makes the install reproducible without embedding/cloning the upstream workspace. Cache key is the pinned version. |
| Phase 1 step 7: "Wire one passing test: I1, A→hang" | Smoke test only — no wire-format scenario | I1 needs a JVM-side Opus encoder (the `OpusEncoder` interface in commonMain only has an Android `MediaCodec` actual today), AND it needs `hang-listen` to actually subscribe to a moq-lite session and decode Opus to PCM. Both are Phase 2 work. The smoke test we landed proves all the harness load-bearing pieces work, so Phase 2 only has to fill in the codecs + the sidecar bodies. |
## Phase 2 pickup
The core gap: **JVM Opus encoder + decoder, plus filling the
sidecar binaries**. Everything else is in place.
### Step A — JVM Opus encoder/decoder
Add a JVM `actual` for `OpusEncoder` / `OpusDecoder` (currently
Android-only via `MediaCodecOpusEncoder` / `MediaCodecOpusDecoder`).
Two viable options:
1. **`org.concentus:Concentus`** (pure-Java port of libopus). On
Maven Central. License-compatible. Slower than native libopus
but plenty fast enough for a 48 kHz mono test stream. **Recommended**
since it adds zero native dependency.
2. **`audiopus_jni` (vendored or via JitPack)** — wraps the same
`audiopus` Rust crate the upstream `hang` examples use. Faster
but adds a JNI shared object per platform.
Wire it into `nestsClient/src/jvmMain/.../audio/JvmOpusEncoder.kt` +
`JvmOpusDecoder.kt`, with the Android `MediaCodec` actuals
unchanged. Add `CapturingOpusDecoder` (the plan's Phase 1 ask)
once the JVM decoder exists.
### Step B — Fill `hang-listen`
Model on `kixelated/moq/rs/hang/examples/subscribe.rs` (the
`subscribe` example in the upstream workspace at
`/tmp/moq/rs/hang/examples/subscribe.rs` if recloned). Replace
its video-track logic with the audio path: pick the first
rendition with `container.kind == "legacy"` and
`codec == "opus"`, subscribe, decode each `Frame` into a
`Bytes`-encoded VarInt timestamp + Opus packet, run the Opus
packets through `audiopus::Decoder`, write Float32 PCM to
`--output-pcm`. Dependencies to add to
`cli/hang-interop/hang-listen/Cargo.toml`:
```toml
hang = "0.15"
moq-lite = "0.15"
moq-mux = "0.3"
moq-native = { version = "0.13", default-features = false, features = ["quinn", "aws-lc-rs"] }
web-transport-quinn = "0.11"
audiopus = "0.3"
bytes = "1"
tracing = "0.1"
```
### Step C — Fill `hang-publish`
Mirror of `hang-listen` for the reverse direction. Generate a sine
wave in Rust, encode with `audiopus::Encoder`, publish a hang
catalog with one Opus rendition, push frames as
`varint(timestamp_us) + opus_packet` per the
`hang::container::Frame::encode` contract (see
`/tmp/moq/rs/hang/src/container/frame.rs`).
### Step D — Wire the I1 scenario
Once A/B/C land, the I1 "amethyst speaker → hang listener" test
is straightforward — see the spec for the pattern. The harness +
`SineWaveAudioCapture` + `PcmAssertions` are already in place to
support it.
## Phase 3 + 4 + 5 deferred
Untouched in Phase 1:
- Phase 3 transport robustness (`udp-loss-shim` body, hot-swap,
long-broadcast).
- Phase 4 browser harness (`nestsClient-browser-interop/` directory,
Playwright driver).
- Phase 5 browser-only scenarios.
CI integration (the GitHub Actions workflow updates the spec
shows) is also pending — until Phase 2 lands a real test, there's
nothing in `-DnestsHangInterop=true` worth gating CI on except
the smoke test.
## Files added in Phase 1
```
cli/hang-interop/
├── REV
├── Cargo.toml
├── hang-listen/{Cargo.toml,src/main.rs}
├── hang-publish/{Cargo.toml,src/main.rs}
└── udp-loss-shim/{Cargo.toml,src/main.rs}
nestsClient/build.gradle.kts # +interopBuildHangSidecars + system props
nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/
├── audio/
│ ├── PcmAssertions.kt
│ └── SineWaveAudioCapture.kt
└── interop/native/
├── NativeMoqRelayHarness.kt
└── NativeMoqRelayHarnessSmokeTest.kt
nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md # this file
```