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
+4
View File
@@ -176,3 +176,7 @@ packaging/appimage/squashfs-root/
benchmark/src/main/jniLibs/
/tools/marmot-interop/state
# Cargo build artifacts for the cross-stack interop sidecars at
# cli/hang-interop/. Cargo.lock is committed (binary workspace).
/cli/hang-interop/target/
+370
View File
@@ -0,0 +1,370 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "hang-listen"
version = "0.0.1"
dependencies = [
"anyhow",
"clap",
"tokio",
]
[[package]]
name = "hang-publish"
version = "0.0.1"
dependencies = [
"anyhow",
"clap",
"tokio",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.52.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"
dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "udp-loss-shim"
version = "0.0.1"
dependencies = [
"anyhow",
"clap",
"tokio",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+26
View File
@@ -0,0 +1,26 @@
[workspace]
resolver = "2"
members = [
"hang-listen",
"hang-publish",
"udp-loss-shim",
]
# Phase 1 ships these as stub binaries that compile cleanly but do
# nothing. Phase 2 fills in real implementations against `hang` /
# `moq-lite` / `web-transport-quinn`. Versions tracked in REV.
[workspace.package]
version = "0.0.1"
edition = "2024"
publish = false
license = "MIT OR Apache-2.0"
[workspace.dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
[profile.release]
opt-level = 3
lto = "thin"
strip = true
+22
View File
@@ -0,0 +1,22 @@
# Pinned upstream revisions for the cross-stack interop harness.
#
# These versions are what NativeMoqRelayHarness installs (via
# `cargo install --version <v> --root <cache>`) and what our sidecar
# crates pin against in Cargo.toml. Bump deliberately — a silent
# upstream rev change can mask a regression.
#
# See: nestsClient/plans/2026-05-06-cross-stack-interop-test.md
# https://github.com/kixelated/moq commit at the time this harness was
# written. Used as the source-of-truth for the exact API shapes the
# sidecar crates are coded against. Phase 1 doesn't compile against
# upstream yet (sidecars are stubs); Phase 2 will pin the published
# crate versions below to track this rev.
KIXELATED_MOQ_GIT_REV=9e2461ee4941968f7b8c410e472448639d2aa4a3
# Published crate versions on crates.io that come from the rev above.
MOQ_RELAY_VERSION=0.10.25
MOQ_TOKEN_CLI_VERSION=0.5.23
HANG_VERSION=0.15.8
MOQ_LITE_VERSION=0.15.15
MOQ_NATIVE_VERSION=0.13
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "hang-listen"
version.workspace = true
edition.workspace = true
publish.workspace = true
license.workspace = true
# Phase 1: stub. Subscribes are added in Phase 2 via `hang` +
# `moq-lite` + `web-transport-quinn` against the rev pinned in
# ../REV. See nestsClient/plans/2026-05-06-cross-stack-interop-test.md
# (Phase 2 step 8) for the catalog → AudioConfig → Container::Legacy
# decode loop this binary will host.
[[bin]]
name = "hang-listen"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
tokio.workspace = true
+36
View File
@@ -0,0 +1,36 @@
//! hang-listen — reference moq-lite / hang audio listener for the
//! cross-stack interop harness. **Phase 1 stub** — Phase 2 fills in
//! the real subscribe loop. See
//! `nestsClient/plans/2026-05-06-cross-stack-interop-test.md`.
use anyhow::Result;
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "hang-listen",
about = "Reference moq-lite / hang audio listener (Phase 1 stub)"
)]
struct Args {
#[arg(long)]
relay_url: String,
#[arg(long)]
jwt: Option<String>,
#[arg(long)]
broadcast: String,
#[arg(long, default_value_t = 5)]
duration: u64,
#[arg(long)]
output_pcm: Option<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
eprintln!(
"hang-listen Phase-1 stub — relay_url={} broadcast={} duration={}s output_pcm={:?}",
args.relay_url, args.broadcast, args.duration, args.output_pcm
);
eprintln!("Phase 2 will implement the actual subscribe loop. Exiting cleanly.");
Ok(())
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "hang-publish"
version.workspace = true
edition.workspace = true
publish.workspace = true
license.workspace = true
# Phase 1: stub. Phase 2 wires this against `hang` + `audiopus` for
# the reverse direction (Rust → Amethyst) interop scenarios.
[[bin]]
name = "hang-publish"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
tokio.workspace = true
+36
View File
@@ -0,0 +1,36 @@
//! hang-publish — reference moq-lite / hang audio publisher for the
//! cross-stack interop harness. **Phase 1 stub.**
use anyhow::Result;
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "hang-publish",
about = "Reference moq-lite / hang audio publisher (Phase 1 stub)"
)]
struct Args {
#[arg(long)]
relay_url: String,
#[arg(long)]
jwt: Option<String>,
#[arg(long)]
broadcast: String,
#[arg(long, default_value_t = 440)]
freq_hz: u32,
#[arg(long, default_value_t = 5)]
duration: u64,
#[arg(long, default_value_t = 1)]
channels: u32,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
eprintln!(
"hang-publish Phase-1 stub — relay_url={} broadcast={} freq_hz={} duration={}s channels={}",
args.relay_url, args.broadcast, args.freq_hz, args.duration, args.channels
);
eprintln!("Phase 2 will implement the actual publish loop. Exiting cleanly.");
Ok(())
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "udp-loss-shim"
version.workspace = true
edition.workspace = true
publish.workspace = true
license.workspace = true
# Phase 1: stub. Phase 3 wires this for the I9 packet-loss scenario.
[[bin]]
name = "udp-loss-shim"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
tokio.workspace = true
@@ -0,0 +1,27 @@
//! udp-loss-shim — UDP loopback that drops a configurable fraction of
//! datagrams, used by the I9 packet-loss scenario. **Phase 1 stub.**
use anyhow::Result;
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "udp-loss-shim", about = "UDP packet-loss shim (Phase 1 stub)")]
struct Args {
#[arg(long)]
listen: String,
#[arg(long)]
upstream: String,
#[arg(long, default_value_t = 0.0)]
loss_rate: f32,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
eprintln!(
"udp-loss-shim Phase-1 stub — listen={} upstream={} loss_rate={}",
args.listen, args.upstream, args.loss_rate
);
eprintln!("Phase 3 will implement actual UDP forwarding. Exiting cleanly.");
Ok(())
}
+105
View File
@@ -104,4 +104,109 @@ tasks.withType<Test>().configureEach {
System.getProperty("nestsProd")?.let { systemProperty("nestsProd", it) }
System.getProperty("nestsProdEndpoint")?.let { systemProperty("nestsProdEndpoint", it) }
System.getProperty("nestsProdAuth")?.let { systemProperty("nestsProdAuth", it) }
// Cross-stack interop (Hang/Rust) opt-in. Forwarded the same way as
// -DnestsInterop. See nestsClient/plans/2026-05-06-cross-stack-interop-test.md.
System.getProperty("nestsHangInterop")?.let { systemProperty("nestsHangInterop", it) }
}
// ---- Cross-stack interop: Rust sidecar build + binary path forwarding -------
//
// Phase 1 of the interop plan ships the workspace at `cli/hang-interop/`
// with three stub binaries (hang-listen, hang-publish, udp-loss-shim).
// `interopBuildHangSidecars` runs `cargo build --release` against it and
// resolves the upstream `moq-relay` + `moq-token` binaries via
// `cargo install`, caching everything under
// `~/.cache/amethyst-nests-interop/hang-interop-cargo/` so reruns are
// fast. Binary paths are forwarded to test workers as system properties.
//
// Opt-in only: Phase 1 just verifies the harness can boot a relay; the
// actual interop scenarios land in Phase 2 once `hang-listen` /
// `hang-publish` have real subscribe/publish loops. See
// `nestsClient/plans/2026-05-06-cross-stack-interop-test.md` for the
// full plan and the pinned upstream versions in `cli/hang-interop/REV`.
val hangInteropDir = rootProject.layout.projectDirectory.dir("cli/hang-interop")
val hangInteropCacheDir =
layout.projectDirectory
.dir(System.getProperty("user.home") ?: "/tmp")
.dir(".cache/amethyst-nests-interop/hang-interop-cargo")
// Versions are duplicated from cli/hang-interop/REV so Gradle has them
// at configuration time; bumping requires touching both files.
val moqRelayVersion = "0.10.25"
val moqTokenCliVersion = "0.5.23"
val interopInstallMoqRelay by tasks.registering(Exec::class) {
description = "cargo install moq-relay $moqRelayVersion (interop)"
group = "interop"
commandLine(
"cargo", "install",
"moq-relay",
"--version", moqRelayVersion,
"--root", hangInteropCacheDir.asFile.absolutePath,
"--locked",
)
val installed =
hangInteropCacheDir.dir("bin").file(
if (org.gradle.internal.os.OperatingSystem.current().isWindows) "moq-relay.exe" else "moq-relay",
)
outputs.file(installed)
outputs.cacheIf { true }
onlyIf { !installed.asFile.exists() }
doFirst { hangInteropCacheDir.asFile.mkdirs() }
}
val interopInstallMoqTokenCli by tasks.registering(Exec::class) {
description = "cargo install moq-token-cli $moqTokenCliVersion (interop)"
group = "interop"
commandLine(
"cargo", "install",
"moq-token-cli",
"--version", moqTokenCliVersion,
"--root", hangInteropCacheDir.asFile.absolutePath,
"--locked",
)
val installed =
hangInteropCacheDir.dir("bin").file(
if (org.gradle.internal.os.OperatingSystem.current().isWindows) "moq-token-cli.exe" else "moq-token-cli",
)
outputs.file(installed)
outputs.cacheIf { true }
onlyIf { !installed.asFile.exists() }
doFirst { hangInteropCacheDir.asFile.mkdirs() }
}
val interopBuildSidecars by tasks.registering(Exec::class) {
description = "cargo build --release for cli/hang-interop sidecars"
group = "interop"
workingDir = hangInteropDir.asFile
commandLine("cargo", "build", "--release")
// Track only manifests + sources; the `target/` subtree is the
// output, including it as an input would mark the task always
// out-of-date.
val sidecarSources =
fileTree(hangInteropDir.asFile) {
include("Cargo.toml", "Cargo.lock")
include("hang-listen/**", "hang-publish/**", "udp-loss-shim/**")
exclude("**/target/**")
}
inputs.files(sidecarSources)
outputs.dir(hangInteropDir.dir("target/release"))
}
val interopBuildHangSidecars by tasks.registering {
description = "Build all hang-interop binaries (sidecars + moq-relay + moq-token)."
group = "interop"
dependsOn(interopBuildSidecars, interopInstallMoqRelay, interopInstallMoqTokenCli)
}
tasks.withType<Test>().configureEach {
val isHangInterop = System.getProperty("nestsHangInterop") == "true"
if (isHangInterop) {
dependsOn(interopBuildHangSidecars)
}
val sidecarRelease = hangInteropDir.dir("target/release").asFile
val cargoBin = hangInteropCacheDir.dir("bin").asFile
systemProperty("nestsHangInteropSidecarsDir", sidecarRelease.absolutePath)
systemProperty("nestsHangInteropCargoBinDir", cargoBin.absolutePath)
}
@@ -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
```
@@ -0,0 +1,284 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.audio
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Signal-domain assertions on decoded Float32 PCM, used by the
* cross-stack interop tests. All assertions take the raw PCM array
* + the sample rate; the FFT helper allocates internally so callers
* don't need to size buffers.
*
* **What these catch.** A round-trip Opus encode/decode against an
* out-of-spec wire frame won't always throw — an
* `OpusHead`-prefixed first frame, for instance, decodes to
* silence-ish noise rather than the expected tone. Asserting
* specific signal properties (peak frequency, RMS, zero-crossing
* rate) catches every variant of "the bytes round-tripped but the
* audio is wrong" without false positives from frame-loss
* smoothing.
*/
object PcmAssertions {
/**
* Sample count within ±[tolerance] (fractional) of the expected
* duration × sample rate. Tolerance ≥ 0.05 covers Opus look-ahead
* + WebCodecs warmup + container framing slack.
*/
fun assertSampleCount(
samples: FloatArray,
expectedDurationSec: Double,
sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
tolerance: Double = 0.05,
) {
val expected = expectedDurationSec * sampleRate
val deviation = abs(samples.size - expected) / expected
check(deviation <= tolerance) {
"sample count ${samples.size} differs from expected $expected by ${"%.3f".format(deviation)} (> $tolerance)"
}
}
/**
* RMS amplitude of [samples] is in `[minRms, maxRms]`. Useful
* to catch full-scale clipping (peak too high) and silence
* (peak too low).
*/
fun assertRms(
samples: FloatArray,
minRms: Float = 0.05f,
maxRms: Float = 0.95f,
) {
val rms = rms(samples)
check(rms in minRms..maxRms) {
"RMS ${"%.4f".format(rms)} not in [$minRms, $maxRms]"
}
}
/**
* Peak FFT frequency of [samples] is within ±[halfWindowHz]
* of [expectedHz]. Uses a simple Hann-windowed radix-2 FFT
* inline (~50 lines, no JTransforms / JCommons dep).
*/
fun assertFftPeak(
samples: FloatArray,
expectedHz: Double,
halfWindowHz: Double = 5.0,
sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
) {
val peak = peakFrequencyHz(samples, sampleRate)
val deviation = abs(peak - expectedHz)
check(deviation <= halfWindowHz) {
"FFT peak ${"%.2f".format(peak)} Hz off expected $expectedHz Hz by ${"%.2f".format(deviation)} (> $halfWindowHz)"
}
}
/**
* Zero crossings per second within ±[tolerance] (fractional)
* of [expectedPerSecond]. Catches Opus predictor warble that
* preserves average power but distorts waveform shape — e.g.
* an "OpusHead" first-frame regression decodes to noisy garbage
* with a very different zero-crossing rate than a clean tone.
*/
fun assertZeroCrossingRate(
samples: FloatArray,
expectedPerSecond: Double,
tolerance: Double = 0.10,
sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
) {
if (samples.size < 2) error("need at least 2 samples for ZCR")
var crossings = 0
for (i in 1 until samples.size) {
if ((samples[i - 1] >= 0f) != (samples[i] >= 0f)) crossings++
}
val durationSec = samples.size.toDouble() / sampleRate
val rate = crossings / durationSec
val deviation = abs(rate - expectedPerSecond) / expectedPerSecond
check(deviation <= tolerance) {
"zero-crossing rate ${"%.1f".format(rate)}/s differs from $expectedPerSecond/s by " +
"${"%.3f".format(deviation)} (> $tolerance)"
}
}
/**
* Find a contiguous silence window of at least [minDurSec]
* (RMS below [threshold] over a 100-ms sliding window). Returns
* `[startSec, endSec]` if found, null otherwise. Used by I3
* (mute window) — speaker mutes for 1 s mid-3-s broadcast,
* listener should observe a corresponding silence window.
*/
fun findSilenceWindow(
samples: FloatArray,
minDurSec: Double,
threshold: Float = 0.01f,
sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
): ClosedRange<Double>? {
val windowSamples = max(1, sampleRate / 10) // 100-ms window
if (samples.size < windowSamples) return null
var inSilence = false
var silenceStart = 0
for (start in 0..samples.size - windowSamples step windowSamples / 2) {
val rms = rms(samples, start, windowSamples)
if (rms < threshold) {
if (!inSilence) {
inSilence = true
silenceStart = start
}
} else if (inSilence) {
val durSec = (start - silenceStart).toDouble() / sampleRate
if (durSec >= minDurSec) {
return (silenceStart.toDouble() / sampleRate)..(start.toDouble() / sampleRate)
}
inSilence = false
}
}
if (inSilence) {
val durSec = (samples.size - silenceStart).toDouble() / sampleRate
if (durSec >= minDurSec) {
return (silenceStart.toDouble() / sampleRate)..(samples.size.toDouble() / sampleRate)
}
}
return null
}
// ---- internals ---------------------------------------------------------
private fun rms(
samples: FloatArray,
from: Int = 0,
len: Int = samples.size,
): Float {
if (len == 0) return 0f
var sum = 0.0
for (i in from until from + len) {
val v = samples[i].toDouble()
sum += v * v
}
return sqrt(sum / len).toFloat()
}
/**
* Find the bin with the largest magnitude in a Hann-windowed
* radix-2 FFT of [samples] (truncated to the next power of 2 ≤ N),
* returning the centre frequency of that bin in Hz. Plenty
* accurate for tone detection at 5-Hz resolution given a
* 10000-sample window @ 48 kHz.
*/
private fun peakFrequencyHz(
samples: FloatArray,
sampleRate: Int,
): Double {
if (samples.size < 16) error("need ≥ 16 samples for FFT")
val n = largestPow2AtMost(samples.size)
val re = DoubleArray(n)
val im = DoubleArray(n)
for (i in 0 until n) {
// Hann window suppresses spectral leakage so the peak
// bin reliably matches the input frequency.
val w = 0.5 * (1.0 - cos(2.0 * PI * i / (n - 1)))
re[i] = samples[i] * w
}
fftRadix2(re, im)
// Only the first n/2 bins are unique (real input → mirror).
var maxBin = 1
var maxMag2 = -1.0
for (k in 1 until n / 2) {
val mag2 = re[k] * re[k] + im[k] * im[k]
if (mag2 > maxMag2) {
maxMag2 = mag2
maxBin = k
}
}
return maxBin.toDouble() * sampleRate / n
}
private fun largestPow2AtMost(n: Int): Int {
var p = 1
while (p shl 1 <= n) p = p shl 1
return p
}
/**
* In-place iterative radix-2 Cooley-Tukey FFT. [re] / [im] must
* be the same length and a power of 2. Adapted from the standard
* textbook recipe — kept inline so we don't pull a transform
* library (jtransforms, commons-math) into nestsClient test
* dependencies.
*/
private fun fftRadix2(
re: DoubleArray,
im: DoubleArray,
) {
val n = re.size
require(n > 0 && (n and (n - 1)) == 0) { "fft length must be power of 2; got $n" }
// Bit-reversal permutation.
var j = 0
for (i in 1 until n) {
var bit = n ushr 1
while (j and bit != 0) {
j = j xor bit
bit = bit ushr 1
}
j = j or bit
if (i < j) {
val tr = re[i]
re[i] = re[j]
re[j] = tr
val ti = im[i]
im[i] = im[j]
im[j] = ti
}
}
// Butterflies.
var size = 2
while (size <= n) {
val half = size / 2
val theta = -2.0 * PI / size
val wReStep = cos(theta)
val wImStep = sin(theta)
var k = 0
while (k < n) {
var wRe = 1.0
var wIm = 0.0
for (m in 0 until half) {
val tRe = wRe * re[k + m + half] - wIm * im[k + m + half]
val tIm = wRe * im[k + m + half] + wIm * re[k + m + half]
re[k + m + half] = re[k + m] - tRe
im[k + m + half] = im[k + m] - tIm
re[k + m] = re[k + m] + tRe
im[k + m] = im[k + m] + tIm
val nwRe = wRe * wReStep - wIm * wImStep
val nwIm = wRe * wImStep + wIm * wReStep
wRe = nwRe
wIm = nwIm
}
k += size
}
size = size shl 1
}
}
}
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.audio
import kotlinx.coroutines.runBlocking
import kotlin.math.PI
import kotlin.math.sin
import kotlin.test.Test
import kotlin.test.assertFails
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* Unit-level checks for [PcmAssertions] + [SineWaveAudioCapture]
* that don't need the cross-stack harness. Without these, a
* regression in the FFT / RMS / ZCR helpers might land silently
* because the only callers are gated behind `-DnestsHangInterop=true`.
*/
class PcmAssertionsTest {
@Test
fun fft_peak_finds_known_tone() {
val sampleRate = AudioFormat.SAMPLE_RATE_HZ
val pcm = sineFloat(440.0, durationSec = 1.0, sampleRate = sampleRate, amplitude = 0.5f)
PcmAssertions.assertFftPeak(pcm, expectedHz = 440.0, halfWindowHz = 5.0)
// A wrong-frequency claim must fail.
assertFails {
PcmAssertions.assertFftPeak(pcm, expectedHz = 1000.0, halfWindowHz = 5.0)
}
}
@Test
fun rms_window_excludes_silence_and_clipping() {
val pcm = sineFloat(440.0, durationSec = 1.0, amplitude = 0.5f)
PcmAssertions.assertRms(pcm, minRms = 0.30f, maxRms = 0.40f)
val silent = FloatArray(48_000)
assertFails { PcmAssertions.assertRms(silent, minRms = 0.30f, maxRms = 0.40f) }
val clipped = FloatArray(48_000) { 1.0f }
assertFails { PcmAssertions.assertRms(clipped, minRms = 0.30f, maxRms = 0.40f) }
}
@Test
fun zero_crossings_match_sine_period() {
val pcm = sineFloat(440.0, durationSec = 1.0, amplitude = 0.5f)
// 440 Hz sine has 2 zero-crossings per period → 880/sec.
PcmAssertions.assertZeroCrossingRate(pcm, expectedPerSecond = 880.0, tolerance = 0.05)
}
@Test
fun silence_window_finds_mid_burst() {
// 1 s tone, 1 s silence, 1 s tone.
val sampleRate = AudioFormat.SAMPLE_RATE_HZ
val tone = sineFloat(440.0, durationSec = 1.0, sampleRate = sampleRate, amplitude = 0.5f)
val silence = FloatArray(sampleRate)
val pcm = tone + silence + tone
val window = PcmAssertions.findSilenceWindow(pcm, minDurSec = 0.5)
assertNotNull(window)
// Window should overlap the [1s, 2s] silent slice.
val overlapStart = maxOf(window.start, 1.0)
val overlapEnd = minOf(window.endInclusive, 2.0)
check(overlapEnd - overlapStart > 0.4) {
"expected silence window to overlap [1.0, 2.0]s; got [${window.start}, ${window.endInclusive}]"
}
// No silence in pure-tone audio.
assertNull(PcmAssertions.findSilenceWindow(tone + tone, minDurSec = 0.5))
}
@Test
fun sample_count_tolerance_works() {
val pcm = FloatArray(48_000)
PcmAssertions.assertSampleCount(pcm, expectedDurationSec = 1.0)
// 5% tolerance with a 0.94-s array (6% short) must fail.
val short = FloatArray((0.94 * 48_000).toInt())
assertFails { PcmAssertions.assertSampleCount(short, expectedDurationSec = 1.0) }
}
@Test
fun sine_wave_capture_is_frame_perfect() {
val capture = SineWaveAudioCapture(freqHz = 440)
val frames = mutableListOf<ShortArray>()
runBlocking {
// 5 frames × 960 samples = 4800 samples = 100 ms @ 48 kHz.
repeat(5) { capture.readFrame()?.let(frames::add) }
}
check(frames.size == 5)
check(frames.all { it.size == AudioFormat.FRAME_SIZE_SAMPLES })
// Frame boundary should be continuous: the next sample after
// frame N's last is frame N+1's first, by phase alignment.
// We don't assert byte equality (Opus has internal predictors),
// but check that each frame's first sample isn't identical
// to the previous one's first — phase actually advanced.
check(frames[0][0] != frames[1][0] || frames[1][0] != frames[2][0])
}
private fun sineFloat(
freqHz: Double,
durationSec: Double,
sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
amplitude: Float = 0.5f,
): FloatArray {
val n = (durationSec * sampleRate).toInt()
val out = FloatArray(n)
val step = 2.0 * PI * freqHz / sampleRate
for (i in 0 until n) out[i] = (amplitude * sin(step * i)).toFloat()
return out
}
private operator fun FloatArray.plus(other: FloatArray): FloatArray {
val out = FloatArray(size + other.size)
System.arraycopy(this, 0, out, 0, size)
System.arraycopy(other, 0, out, size, other.size)
return out
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.audio
import kotlin.math.PI
import kotlin.math.sin
/**
* Deterministic sine-wave [AudioCapture] for cross-stack interop tests.
*
* Generates [AudioFormat.FRAME_SIZE_SAMPLES] samples per call at the
* audio pipeline's native [AudioFormat.SAMPLE_RATE_HZ] (48 kHz). The
* sample counter is frame-perfect and never reads wall-clock — what
* the test sends is exactly what reaches the decoder. The decoded
* peak-frequency assertion in
* [com.vitorpamplona.nestsclient.audio.PcmAssertions.assertFftPeak]
* relies on this determinism; a wall-clock-based source would drift
* and trigger spurious failures on slow CI workers.
*
* Mono only (`channels = 1`) for Phase 1 — the I4 stereo scenario
* (Phase 2) extends this to a per-channel `freqHzL` / `freqHzR` pair.
*/
class SineWaveAudioCapture(
private val freqHz: Int = 440,
private val amplitude: Short = 16_383,
) : AudioCapture {
private var sampleIdx: Long = 0L
override fun start() {
// No device to allocate.
}
override suspend fun readFrame(): ShortArray? {
val samples = AudioFormat.FRAME_SIZE_SAMPLES
val out = ShortArray(samples)
val baseIdx = sampleIdx
val angularStep = 2.0 * PI * freqHz / AudioFormat.SAMPLE_RATE_HZ
for (i in 0 until samples) {
val v = (amplitude * sin(angularStep * (baseIdx + i))).toInt()
// Clamp defensively — amplitude is well below Short.MAX_VALUE
// by default, but a future bigger amplitude could otherwise
// wrap on the .toShort() truncation.
out[i] = v.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
}
sampleIdx = baseIdx + samples
return out
}
override fun stop() {
// No device to release.
}
}
@@ -0,0 +1,381 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.interop.native
import java.io.File
import java.net.DatagramSocket
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
* Boots a native `moq-relay` subprocess + provides the test sidecar
* binary paths for the cross-stack interop harness.
*
* - `moq-relay` is `cargo install`ed at the version pinned in
* `cli/hang-interop/REV` and cached under
* `~/.cache/amethyst-nests-interop/hang-interop-cargo/bin/`.
* - TLS: `--tls-generate localhost` so the relay self-signs at
* startup. Kotlin clients use the existing
* `PermissiveCertValidator` to skip chain validation.
* - Auth: `--auth-public ""` so connections need no JWT. Real JWT
* issuance is exercised separately by the existing
* `NostrNestsAuthInteropTest` against the Docker'd `moq-auth`.
*
* One harness instance per test class — startup is ~500 ms once the
* cached binaries exist, so tests amortise cheaply.
*
* **Phase 1 status**: harness boots the relay and exposes paths to
* the (stub) sidecar binaries `hang-listen` / `hang-publish` /
* `udp-loss-shim`. Phase 2 fills in the sidecars' real subscribe /
* publish loops. See
* `nestsClient/plans/2026-05-06-cross-stack-interop-test.md`.
*/
class NativeMoqRelayHarness private constructor(
private val relayProcess: Process,
private val relayPort: Int,
private val sidecarsDir: Path,
private val cargoBinDir: Path,
) : AutoCloseable {
private var stopped = false
/** Base relay URL. Append a broadcast namespace for connections. */
val relayUrl: String get() = "https://127.0.0.1:$relayPort"
/** UDP loopback (host, port) the relay listens on. */
fun loopbackHostPort(): Pair<String, Int> = "127.0.0.1" to relayPort
/** Path to the (Phase-1 stub) hang-listen binary. */
fun hangListenBin(): Path = sidecarsDir.resolve(binName("hang-listen"))
/** Path to the (Phase-1 stub) hang-publish binary. */
fun hangPublishBin(): Path = sidecarsDir.resolve(binName("hang-publish"))
/** Path to the (Phase-1 stub) udp-loss-shim binary. */
fun udpLossShimBin(): Path = sidecarsDir.resolve(binName("udp-loss-shim"))
/** Path to the cargo-installed `moq-token-cli` binary. */
fun moqTokenBin(): Path = cargoBinDir.resolve(binName("moq-token-cli"))
override fun close() {
if (stopped) return
stopped = true
runCatching { relayProcess.destroy() }
if (!relayProcess.waitFor(5, TimeUnit.SECONDS)) {
runCatching { relayProcess.destroyForcibly() }
}
}
companion object {
/** Gate property — mirrors `nestsInterop`. */
const val ENABLE_PROPERTY = "nestsHangInterop"
/**
* `interopBuildHangSidecars` writes this. Points to
* `cli/hang-interop/target/release` where the (Phase-1
* stub) sidecar binaries live.
*/
const val SIDECARS_DIR_PROPERTY = "nestsHangInteropSidecarsDir"
/**
* Cargo install root used by `interopInstallMoqRelay` /
* `interopInstallMoqTokenCli`. Sub-directory `bin/` holds
* `moq-relay` + `moq-token`.
*/
const val CARGO_BIN_DIR_PROPERTY = "nestsHangInteropCargoBinDir"
private const val PORT_READY_TIMEOUT_MS = 30_000L
private const val PORT_PROBE_INTERVAL_MS = 200L
fun isEnabled(): Boolean = System.getProperty(ENABLE_PROPERTY) == "true"
/**
* JUnit "skipped" if the gate isn't on, like the existing
* [com.vitorpamplona.nestsclient.interop.NostrNestsHarness.assumeNestsInterop].
*/
fun assumeHangInterop() {
if (isEnabled()) return
val msg =
"Skipping cross-stack hang interop test — set -D$ENABLE_PROPERTY=true to enable. " +
"See nestsClient/plans/2026-05-06-cross-stack-interop-test.md."
try {
val assume = Class.forName("org.junit.Assume")
val assumeTrue = assume.getMethod("assumeTrue", String::class.java, Boolean::class.javaPrimitiveType)
assumeTrue.invoke(null, msg, false)
} catch (e: java.lang.reflect.InvocationTargetException) {
throw e.targetException ?: e
} catch (_: ClassNotFoundException) {
throw IllegalStateException(msg)
}
}
@Volatile private var shared: NativeMoqRelayHarness? = null
private val sharedLock = Any()
/**
* Bring the relay up if not already running; reuses the same
* subprocess across test classes within one JVM run. Mirrors
* the singleton pattern in [com.vitorpamplona.nestsclient.interop.NostrNestsHarness].
*/
fun shared(): NativeMoqRelayHarness {
shared?.let { return it }
synchronized(sharedLock) {
shared?.let { return it }
val instance = doStart()
Runtime.getRuntime().addShutdownHook(
Thread({ runCatching { instance.close() } }, "NativeMoqRelayHarness-shutdown"),
)
shared = instance
return instance
}
}
private fun doStart(): NativeMoqRelayHarness {
check(isEnabled()) {
"NativeMoqRelayHarness.shared() called without -D$ENABLE_PROPERTY=true."
}
val sidecarsDir = requireDirProperty(SIDECARS_DIR_PROPERTY)
val cargoBinDir = requireDirProperty(CARGO_BIN_DIR_PROPERTY)
val moqRelay = cargoBinDir.resolve(binName("moq-relay"))
check(Files.isExecutable(moqRelay)) {
"moq-relay not found at $moqRelay — did `interopBuildHangSidecars` run? " +
"Try: ./gradlew :nestsClient:interopBuildHangSidecars"
}
check(Files.isExecutable(sidecarsDir.resolve(binName("hang-listen")))) {
"hang-listen sidecar not found under $sidecarsDir — did `interopBuildSidecars` run?"
}
val port = reservePort()
val pb =
ProcessBuilder(
moqRelay.toString(),
"--server-bind",
"127.0.0.1:$port",
// moq-relay also opens an outbound clustering
// client; its default `[::]:0` bind fails in
// sandboxes without IPv6 (errno 97 EAFNOSUPPORT).
// Pin to IPv4 loopback to keep the harness
// portable across CI runners.
"--client-bind",
"127.0.0.1:0",
"--tls-generate",
"localhost",
// Empty prefix grants pub+sub on every path, so
// tests don't need to mint JWTs. The
// moq-relay/auth.rs `verify` path falls through
// to public access when no JWT is present.
"--auth-public",
"",
"--log-level",
"info",
).redirectErrorStream(true)
val process = pb.start()
val drainer = ProcessOutputDrainer(process, "moq-relay").also { it.start() }
try {
// moq-relay logs `addr=… listening` on bind. Wait for
// that line — strictly more reliable than a port
// probe (TCP probes succeed on a UDP-only listener,
// and UDP probes against a SO_REUSEPORT-bound socket
// can also succeed even when the relay is healthy).
drainer.waitForLine("listening", PORT_READY_TIMEOUT_MS)
// Belt-and-braces: also confirm the UDP port is bound.
// If something's broken in the log path this still
// catches a non-listening relay within a few seconds.
waitForUdpBound("127.0.0.1", port, 3_000L)
} catch (t: Throwable) {
// Best-effort log capture before tearing down so the
// failure includes WHY the relay didn't come up.
val tail = drainer.tail()
runCatching { process.destroyForcibly() }
throw IllegalStateException(
"moq-relay did not become ready on 127.0.0.1:$port within " +
"${PORT_READY_TIMEOUT_MS}ms.\n--- moq-relay log tail ---\n$tail",
t,
)
}
return NativeMoqRelayHarness(
relayProcess = process,
relayPort = port,
sidecarsDir = sidecarsDir,
cargoBinDir = cargoBinDir,
)
}
private fun requireDirProperty(name: String): Path {
val raw = System.getProperty(name)
check(!raw.isNullOrBlank()) {
"system property '$name' not set — did the Gradle test task forward it? " +
"(see :nestsClient build.gradle.kts)"
}
val path = File(raw).toPath()
check(Files.isDirectory(path)) {
"system property '$name' = '$raw' is not a directory; " +
"did `interopBuildHangSidecars` run?"
}
return path
}
/**
* Ask the OS for a free TCP port, close the socket, and use
* that port number for the relay's UDP listener. There's a
* tiny window where another process could grab the port; in
* practice CI loopback is uncontested. Reused from the same
* pattern used elsewhere in the test infra.
*/
private fun reservePort(): Int {
ServerSocket(0).use { return it.localPort }
}
/**
* Confirm the relay's UDP port is bound by trying to bind a
* *second* `DatagramSocket` on it. If the OS rejects with
* `BindException`, something owns the port — almost
* certainly the relay we just spawned. UDP namespace is
* separate from TCP, so this is the only kind of probe that
* meaningfully reports "is the relay listening" on a
* QUIC-only data plane.
*
* Defaults to `SO_REUSEADDR=false` so a relay bound without
* `SO_REUSEPORT` correctly fails our second bind. Falls back
* to the relay's startup log line as the primary signal —
* see `doStart` callers.
*/
private fun waitForUdpBound(
host: String,
port: Int,
timeoutMs: Long,
) {
val deadline = System.currentTimeMillis() + timeoutMs
var lastError: Throwable? = null
while (System.currentTimeMillis() < deadline) {
try {
val probe = DatagramSocket(null)
probe.reuseAddress = false
try {
probe.bind(InetSocketAddress(host, port))
// Bind succeeded → nothing else is on this
// UDP port. The relay isn't bound yet.
} finally {
probe.close()
}
Thread.sleep(PORT_PROBE_INTERVAL_MS)
} catch (_: java.net.BindException) {
return
} catch (t: Throwable) {
lastError = t
Thread.sleep(PORT_PROBE_INTERVAL_MS)
}
}
throw IllegalStateException(
"moq-relay UDP port $host:$port did not bind within ${timeoutMs}ms",
lastError,
)
}
private fun binName(stem: String): String =
if (System
.getProperty("os.name")
.orEmpty()
.lowercase()
.contains("win")
) {
"$stem.exe"
} else {
stem
}
}
}
/**
* Reads the subprocess's combined stdout/stderr into a bounded ring
* so the harness can include the tail in a failure message. Without
* this the relay's log line `listening on 127.0.0.1:<port>` is the
* only signal that startup succeeded, and a silent error (cert
* generation failure, port collision after the OS reservation, …)
* leaves us with nothing to include in the assertion.
*/
private class ProcessOutputDrainer(
private val process: Process,
private val name: String,
) {
private val ring = ConcurrentLinkedQueue<String>()
private val maxLines = 64
private var thread: Thread? = null
private val lock = ReentrantLock()
private val newLineCond = lock.newCondition()
fun start() {
thread =
Thread({
process.inputStream.bufferedReader().useLines { lines ->
for (line in lines) {
ring.add(line)
while (ring.size > maxLines) ring.poll()
lock.withLock { newLineCond.signalAll() }
}
}
}, "NativeMoqRelayHarness-$name").apply {
isDaemon = true
start()
}
}
fun tail(): String = ring.joinToString("\n")
/**
* Block until the drainer has observed a line containing
* [needle], scanning lines that have already been buffered
* (handles the race where the relay finished logging "listening"
* before [waitForLine] was called) plus any new lines that
* arrive within [timeoutMs]. Throws if the deadline expires
* before a match. Substring match rather than regex to keep
* upstream-log-format tweaks from breaking us.
*/
fun waitForLine(
needle: String,
timeoutMs: Long,
) {
val deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
if (ring.any { it.contains(needle) }) return
lock.withLock {
while (true) {
if (ring.any { it.contains(needle) }) return
val remaining = deadlineNanos - System.nanoTime()
if (remaining <= 0) {
throw IllegalStateException(
"did not observe '$needle' in $name output within ${timeoutMs}ms",
)
}
newLineCond.awaitNanos(remaining)
}
}
}
}
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.interop.native
import java.nio.file.Files
import java.util.concurrent.TimeUnit
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Phase 1 smoke test for the cross-stack interop harness. Proves
* the load-bearing infra works end-to-end:
*
* - `interopBuildHangSidecars` Gradle task installed `moq-relay`
* and compiled the (stub) sidecar binaries.
* - [NativeMoqRelayHarness] boots a real `moq-relay` subprocess
* with a self-signed cert and `--auth-public ""`, ready to
* accept WebTransport handshakes.
* - The Phase-1 stub `hang-listen` binary runs cleanly and
* exits 0 (no protocol logic yet — that's Phase 2).
*
* Doesn't exercise any wire format. The actual interop scenarios
* (I1 sine-wave round-trip, I2 late-join, …) live in
* `HangInteropTest` once Phase 2 has the real subscribe/publish
* loops in `hang-listen` / `hang-publish`. See
* `nestsClient/plans/2026-05-06-cross-stack-interop-test.md`
* Phase 2 step 7.
*
* Gated by `-DnestsHangInterop=true`. Without that property the
* test "skips" via `assumeHangInterop()` so default `:nestsClient:jvmTest`
* runs stay green when the Rust toolchain isn't installed.
*/
class NativeMoqRelayHarnessSmokeTest {
@BeforeTest
fun gate() {
NativeMoqRelayHarness.assumeHangInterop()
}
@Test
fun harness_boots_relay_and_exposes_sidecar_binaries() {
val harness = NativeMoqRelayHarness.shared()
val (host, port) = harness.loopbackHostPort()
assertEquals("127.0.0.1", host)
assertTrue(port in 1024..65535, "expected ephemeral port, got $port")
assertTrue(
harness.relayUrl.startsWith("https://127.0.0.1:"),
"relayUrl should be a localhost https URL, got ${harness.relayUrl}",
)
// Sidecar binaries exist + are executable. Phase 2 fills in
// the actual subscribe/publish loops; here we just verify
// they can be invoked.
for (bin in listOf(harness.hangListenBin(), harness.hangPublishBin(), harness.udpLossShimBin())) {
assertTrue(Files.isExecutable(bin), "sidecar binary not executable: $bin")
}
// moq-token CLI from cargo install — exercised once Phase 2
// wires up real JWT-authenticated scenarios. Just check
// existence here.
assertTrue(
Files.isExecutable(harness.moqTokenBin()),
"moq-token CLI not executable: ${harness.moqTokenBin()}",
)
}
@Test
fun stub_hang_listen_runs_cleanly() {
val harness = NativeMoqRelayHarness.shared()
// The stub returns immediately with exit code 0. This proves
// the binary is reachable from the test JVM and clap parsing
// succeeds — i.e. Phase 2 only has to flesh out the body.
val proc =
ProcessBuilder(
harness.hangListenBin().toString(),
"--relay-url",
harness.relayUrl,
"--broadcast",
"test/smoke",
"--duration",
"1",
).redirectErrorStream(true).start()
val exited = proc.waitFor(10, TimeUnit.SECONDS)
val output = proc.inputStream.bufferedReader().readText()
assertTrue(exited, "hang-listen stub did not exit within 10 s. Output:\n$output")
assertEquals(0, proc.exitValue(), "hang-listen stub exited non-zero. Output:\n$output")
assertTrue(
output.contains("Phase-1 stub"),
"expected hang-listen Phase-1 stub banner in output. Got:\n$output",
)
}
}