From 284a203070f777bd2583e2fe7da90d5233ea389b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 20:39:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(nests):=20T16=20Phase=201=20=E2=80=94=20cr?= =?UTF-8?q?oss-stack=20interop=20harness=20scaffolding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 4 + cli/hang-interop/Cargo.lock | 370 +++++++++++++++++ cli/hang-interop/Cargo.toml | 26 ++ cli/hang-interop/REV | 22 + cli/hang-interop/hang-listen/Cargo.toml | 21 + cli/hang-interop/hang-listen/src/main.rs | 36 ++ cli/hang-interop/hang-publish/Cargo.toml | 18 + cli/hang-interop/hang-publish/src/main.rs | 36 ++ cli/hang-interop/udp-loss-shim/Cargo.toml | 17 + cli/hang-interop/udp-loss-shim/src/main.rs | 27 ++ nestsClient/build.gradle.kts | 105 +++++ ...-05-06-cross-stack-interop-test-results.md | 178 ++++++++ .../nestsclient/audio/PcmAssertions.kt | 284 +++++++++++++ .../nestsclient/audio/PcmAssertionsTest.kt | 136 +++++++ .../nestsclient/audio/SineWaveAudioCapture.kt | 70 ++++ .../interop/native/NativeMoqRelayHarness.kt | 381 ++++++++++++++++++ .../native/NativeMoqRelayHarnessSmokeTest.kt | 112 +++++ 17 files changed, 1843 insertions(+) create mode 100644 cli/hang-interop/Cargo.lock create mode 100644 cli/hang-interop/Cargo.toml create mode 100644 cli/hang-interop/REV create mode 100644 cli/hang-interop/hang-listen/Cargo.toml create mode 100644 cli/hang-interop/hang-listen/src/main.rs create mode 100644 cli/hang-interop/hang-publish/Cargo.toml create mode 100644 cli/hang-interop/hang-publish/src/main.rs create mode 100644 cli/hang-interop/udp-loss-shim/Cargo.toml create mode 100644 cli/hang-interop/udp-loss-shim/src/main.rs create mode 100644 nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md create mode 100644 nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/PcmAssertions.kt create mode 100644 nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/PcmAssertionsTest.kt create mode 100644 nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/SineWaveAudioCapture.kt create mode 100644 nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarness.kt create mode 100644 nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarnessSmokeTest.kt diff --git a/.gitignore b/.gitignore index 6b80eb994..45b8a6b30 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/cli/hang-interop/Cargo.lock b/cli/hang-interop/Cargo.lock new file mode 100644 index 000000000..2eb45b996 --- /dev/null +++ b/cli/hang-interop/Cargo.lock @@ -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", +] diff --git a/cli/hang-interop/Cargo.toml b/cli/hang-interop/Cargo.toml new file mode 100644 index 000000000..a710f543a --- /dev/null +++ b/cli/hang-interop/Cargo.toml @@ -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 diff --git a/cli/hang-interop/REV b/cli/hang-interop/REV new file mode 100644 index 000000000..37b0b81bb --- /dev/null +++ b/cli/hang-interop/REV @@ -0,0 +1,22 @@ +# Pinned upstream revisions for the cross-stack interop harness. +# +# These versions are what NativeMoqRelayHarness installs (via +# `cargo install --version --root `) 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 diff --git a/cli/hang-interop/hang-listen/Cargo.toml b/cli/hang-interop/hang-listen/Cargo.toml new file mode 100644 index 000000000..eccff739c --- /dev/null +++ b/cli/hang-interop/hang-listen/Cargo.toml @@ -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 diff --git a/cli/hang-interop/hang-listen/src/main.rs b/cli/hang-interop/hang-listen/src/main.rs new file mode 100644 index 000000000..7d0769558 --- /dev/null +++ b/cli/hang-interop/hang-listen/src/main.rs @@ -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, + #[arg(long)] + broadcast: String, + #[arg(long, default_value_t = 5)] + duration: u64, + #[arg(long)] + output_pcm: Option, +} + +#[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(()) +} diff --git a/cli/hang-interop/hang-publish/Cargo.toml b/cli/hang-interop/hang-publish/Cargo.toml new file mode 100644 index 000000000..bb4debb8b --- /dev/null +++ b/cli/hang-interop/hang-publish/Cargo.toml @@ -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 diff --git a/cli/hang-interop/hang-publish/src/main.rs b/cli/hang-interop/hang-publish/src/main.rs new file mode 100644 index 000000000..b4ccbbc24 --- /dev/null +++ b/cli/hang-interop/hang-publish/src/main.rs @@ -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, + #[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(()) +} diff --git a/cli/hang-interop/udp-loss-shim/Cargo.toml b/cli/hang-interop/udp-loss-shim/Cargo.toml new file mode 100644 index 000000000..10b0bc7dc --- /dev/null +++ b/cli/hang-interop/udp-loss-shim/Cargo.toml @@ -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 diff --git a/cli/hang-interop/udp-loss-shim/src/main.rs b/cli/hang-interop/udp-loss-shim/src/main.rs new file mode 100644 index 000000000..2364fbb52 --- /dev/null +++ b/cli/hang-interop/udp-loss-shim/src/main.rs @@ -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(()) +} diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts index 28ea6e980..145e77aff 100644 --- a/nestsClient/build.gradle.kts +++ b/nestsClient/build.gradle.kts @@ -104,4 +104,109 @@ tasks.withType().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().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) } diff --git a/nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md b/nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md new file mode 100644 index 000000000..3d4a696cd --- /dev/null +++ b/nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md @@ -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 ` 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 ` | `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 +``` diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/PcmAssertions.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/PcmAssertions.kt new file mode 100644 index 000000000..2c5cd9ca5 --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/PcmAssertions.kt @@ -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? { + 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 + } + } +} diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/PcmAssertionsTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/PcmAssertionsTest.kt new file mode 100644 index 000000000..5e8265a4c --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/PcmAssertionsTest.kt @@ -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() + 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 + } +} diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/SineWaveAudioCapture.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/SineWaveAudioCapture.kt new file mode 100644 index 000000000..91aacaa1d --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/SineWaveAudioCapture.kt @@ -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. + } +} diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarness.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarness.kt new file mode 100644 index 000000000..b0a0b38fb --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarness.kt @@ -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 = "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:` 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() + 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) + } + } + } +} diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarnessSmokeTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarnessSmokeTest.kt new file mode 100644 index 000000000..b2700adbd --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarnessSmokeTest.kt @@ -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", + ) + } +}