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
+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",
)
}
}