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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -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
|
||||
}
|
||||
}
|
||||
+70
@@ -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.
|
||||
}
|
||||
}
|
||||
+381
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user