test(nests): T16 Phase 2.E — I11 wire-byte + I2 late-join + I3 mute
Adds three more Phase 2 cross-stack scenarios on the existing HangInteropTest harness: - **I11** (`first_audio_frame_is_not_opus_codec_config`): hang-listen gains `--dump-first-frame <path>` that writes the first audio frame's post-Container-Legacy-strip codec payload. The test asserts those bytes don't begin with `OpusHead` magic — catches the T8 regression where Android's MediaCodec leaks BUFFER_FLAG_CODEC_CONFIG bytes as a normal audio frame. - **I2** (`late_join_listener_still_decodes_tail`): listener attaches 2 s into a 5 s broadcast, asserts ≥1.5 s of decoded audio with the 440 Hz peak still recoverable. - **I3** (`mid_broadcast_mute_shortens_decoded_pcm`): speaker mutes for 1 s mid-broadcast. Amethyst's broadcaster FINs the open uni stream rather than pushing zeros, so the mute shows up as a sample-count deficit (~3 s decoded for 4 s wallclock), not an embedded silence window. Asserts the deficit is in the right ballpark (a regression that pushed zeros instead would produce normal-length PCM and fail this). `runSpeakerToHangListen` extracted as a per-scenario helper so the four Kotlin-speaker scenarios share setup. Each scenario anchors `QuicWebTransportFactory.parentScope` to its per-test pumpScope to avoid leaking UDP sockets / coroutine trees. `KotlinSpeakerKotlinListenerThroughNativeRelayTest` (Phase 2's Kotlin↔Kotlin diagnostic) now opts in via a separate `-DnestsHangInteropDiagnostic=true` gate. It flakes when run alongside HangInteropTest's 5 native-subprocess scenarios in the same JVM (relay-side state accumulation), and its only purpose is wire-format bisects — no need to ship it under the default `-DnestsHangInterop` flag. Verified: 3 sequential `./gradlew :nestsClient:jvmTest -DnestsHangInterop=true --rerun-tasks` runs in a row green. I4 (stereo) deferred — needs a non-trivial production-side catalog change (`MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES` hard-codes mono); out of scope for these test plumbing changes. https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
This commit is contained in:
@@ -57,6 +57,15 @@ struct Args {
|
||||
/// If absent, the binary discards PCM (used as a smoke test).
|
||||
#[arg(long)]
|
||||
output_pcm: Option<String>,
|
||||
|
||||
/// Dump the first audio frame's raw bytes (the post-Hang::Legacy
|
||||
/// payload — already stripped of the moq-lite frame size prefix
|
||||
/// but NOT the hang VarInt timestamp prefix) to this path. Used
|
||||
/// by I11 to assert the publisher isn't shipping
|
||||
/// `OpusHead\\1\\1...` Codec-Specific-Data as the first audio
|
||||
/// frame (the T8 regression in the audit branch).
|
||||
#[arg(long)]
|
||||
dump_first_frame: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -116,7 +125,13 @@ async fn run(args: Args) -> anyhow::Result<()> {
|
||||
}
|
||||
});
|
||||
|
||||
let listen_result = listen(consumer, args.output_pcm.as_deref(), args.duration).await;
|
||||
let listen_result = listen(
|
||||
consumer,
|
||||
args.output_pcm.as_deref(),
|
||||
args.dump_first_frame.as_deref(),
|
||||
args.duration,
|
||||
)
|
||||
.await;
|
||||
|
||||
// The session task will exit on its own when the URL closes; we
|
||||
// don't need to abort it for a clean shutdown.
|
||||
@@ -128,6 +143,7 @@ async fn run(args: Args) -> anyhow::Result<()> {
|
||||
async fn listen(
|
||||
mut origin: moq_lite::OriginConsumer,
|
||||
output_pcm: Option<&str>,
|
||||
output_dump_first_frame: Option<&str>,
|
||||
duration_sec: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
// Open the PCM sink up front so we fail fast on a bad path.
|
||||
@@ -202,6 +218,7 @@ async fn listen(
|
||||
opus::Decoder::new(audio_cfg.sample_rate, channels).context("init opus decoder")?;
|
||||
let mut pcm_buf = vec![0i16; MAX_PCM_PER_PACKET * audio_cfg.channel_count as usize];
|
||||
|
||||
let dump_first_frame_path = output_dump_first_frame.map(PathBuf::from);
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(duration_sec);
|
||||
let mut total_samples: u64 = 0;
|
||||
let mut frame_count: u64 = 0;
|
||||
@@ -237,6 +254,18 @@ async fn listen(
|
||||
}
|
||||
};
|
||||
|
||||
// First-frame capture for I11. payload is the post-
|
||||
// Container::Legacy-strip codec payload (i.e. the raw
|
||||
// Opus packet, no timestamp prefix). If the publisher
|
||||
// accidentally ships `OpusHead\1\1...` Codec-Specific-Data
|
||||
// as the first audio frame, this is where it shows up.
|
||||
if frame_count == 0 {
|
||||
if let Some(path) = dump_first_frame_path.as_ref() {
|
||||
std::fs::write(path, frame.payload.as_ref())
|
||||
.with_context(|| format!("write dump-first-frame to '{}'", path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// payload is the raw Opus packet — the timestamp varint has
|
||||
// already been stripped by `Hang::Legacy` decoding.
|
||||
let n = decoder
|
||||
|
||||
@@ -118,6 +118,12 @@ tasks.withType<Test>().configureEach {
|
||||
// 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) }
|
||||
// Separate gate for the Kotlin↔Kotlin diagnostic test (used to
|
||||
// bisect wire-format bugs). Runs in a fresh JVM without the
|
||||
// 5 native-subprocess scenarios; flakes if mixed in.
|
||||
System.getProperty("nestsHangInteropDiagnostic")?.let {
|
||||
systemProperty("nestsHangInteropDiagnostic", it)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cross-stack interop: Rust sidecar build + binary path forwarding -------
|
||||
|
||||
+287
-124
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -45,27 +46,34 @@ import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Cross-stack interop scenarios driving the reference `kixelated/moq`
|
||||
* `hang-publish` and `hang-listen` Rust binaries through
|
||||
* [NativeMoqRelayHarness] (i.e. through the same `moq-relay`
|
||||
* subprocess Amethyst tests use).
|
||||
* `hang-listen` Rust binary against an Amethyst Kotlin speaker
|
||||
* through [NativeMoqRelayHarness].
|
||||
*
|
||||
* Phase 2 ships:
|
||||
* - **I1 forward**: Amethyst Kotlin speaker → `hang-listen`. The
|
||||
* speaker pins `framesPerGroup = 5` (per the cliff-investigation
|
||||
* plan); larger groups overflow moq-relay 0.10.x's per-subscriber
|
||||
* buffer and the relay holds frame bytes without forwarding.
|
||||
* Diagnosed via the companion
|
||||
* [KotlinSpeakerKotlinListenerThroughNativeRelayTest] which
|
||||
* reproduces the same cliff Kotlin↔Kotlin.
|
||||
* - **Rust↔Rust** round-trip: pure-Rust through our harness, proves
|
||||
* the cargo workspace + relay config + `moq-lite-03` ALPN.
|
||||
* **Phase 2 P0 scenarios:**
|
||||
* - **I1** — sine-wave round-trip ([amethyst_speaker_to_hang_listener_static_tone_440]).
|
||||
* - **I11** — wire-byte capture: assert the first audio frame
|
||||
* payload isn't `OpusHead\1\1...` Codec-Specific-Data
|
||||
* ([first_audio_frame_is_not_opus_codec_config]).
|
||||
* - **I2** — late-join: listener attaches at T+2 s of a 5 s
|
||||
* broadcast, still receives ~3 s of decoded audio
|
||||
* ([late_join_listener_still_decodes_tail]).
|
||||
* - **I3** — mute-window: speaker mutes for 1 s mid-broadcast,
|
||||
* listener observes a corresponding silence window
|
||||
* ([mid_broadcast_mute_produces_silence_window]).
|
||||
* - **Rust↔Rust** round-trip: pure-Rust through our harness
|
||||
* ([rust_hang_publish_to_rust_hang_listener_round_trip_440]).
|
||||
*
|
||||
* Both scenarios assert FFT peak / ZCR / sample-count on the decoded
|
||||
* Float32 PCM hang-listen wrote to disk. Gated by `-DnestsHangInterop=true`.
|
||||
* All scenarios pin the Kotlin speaker at `framesPerGroup = 5`
|
||||
* to stay under the moq-relay 0.10.x per-subscriber forward
|
||||
* cliff (see
|
||||
* `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`).
|
||||
*
|
||||
* Gated by `-DnestsHangInterop=true`.
|
||||
*/
|
||||
class HangInteropTest {
|
||||
@BeforeTest
|
||||
@@ -73,107 +81,20 @@ class HangInteropTest {
|
||||
NativeMoqRelayHarness.assumeHangInterop()
|
||||
}
|
||||
|
||||
/**
|
||||
* I1 — Amethyst Kotlin speaker → reference `hang-listen`. The
|
||||
* speaker uses 5 frames per moq-lite group; the relay's per-
|
||||
* subscriber forward queue keeps up at that cadence (per
|
||||
* `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`).
|
||||
* Asserts FFT peak at 440 Hz and ZCR at 880/sec on the decoded
|
||||
* PCM hang-listen wrote to disk.
|
||||
*/
|
||||
/** I1: 5 s 440 Hz mono sine, asserted via FFT peak + ZCR. */
|
||||
@Test
|
||||
fun amethyst_speaker_to_hang_listener_static_tone_440() =
|
||||
runBlocking {
|
||||
val harness = NativeMoqRelayHarness.shared()
|
||||
|
||||
val signer: NostrSigner = NostrSignerInternal(KeyPair())
|
||||
val pubkey = signer.pubKey
|
||||
val roomId = "rt-${UUID.randomUUID()}"
|
||||
val room =
|
||||
NestsRoomConfig(
|
||||
authBaseUrl = "<unused-public-relay>",
|
||||
endpoint = harness.relayUrl,
|
||||
hostPubkey = pubkey,
|
||||
roomId = roomId,
|
||||
val out =
|
||||
runSpeakerToHangListen(
|
||||
speakerSeconds = 5,
|
||||
captureFirstFrame = false,
|
||||
)
|
||||
val moqNamespace = room.moqNamespace()
|
||||
|
||||
val pcmFile = File.createTempFile("hang-listen-pcm", ".bin").also { it.deleteOnExit() }
|
||||
|
||||
val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val transport =
|
||||
QuicWebTransportFactory(
|
||||
certificateValidator = PermissiveCertificateValidator(),
|
||||
)
|
||||
|
||||
// Sequence: speaker fully up → spawn hang-listen.
|
||||
// Catches the `setOnNewSubscriber` race in
|
||||
// MoqLiteNestsSpeaker — the catalog hook is set AFTER
|
||||
// session.publish() returns, so a subscriber that races
|
||||
// in faster registers with hook=null and never gets the
|
||||
// catalog. Letting the hook install before hang-listen
|
||||
// attaches sidesteps this for the test.
|
||||
lateinit var listenProc: Process
|
||||
try {
|
||||
val speaker =
|
||||
connectNestsSpeaker(
|
||||
httpClient = StaticTokenNestsClient,
|
||||
transport = transport,
|
||||
scope = pumpScope,
|
||||
room = room,
|
||||
signer = signer,
|
||||
speakerPubkeyHex = pubkey,
|
||||
captureFactory = { SineWaveAudioCapture(freqHz = 440) },
|
||||
encoderFactory = { JvmOpusEncoder() },
|
||||
// Per cliff-investigation plan: 5 frames/group
|
||||
// = 10 streams/sec, comfortably within
|
||||
// moq-relay 0.10's per-subscriber forward
|
||||
// ceiling. The repo's current
|
||||
// `DEFAULT_FRAMES_PER_GROUP=50` exceeds the
|
||||
// moq-relay 0.10.x stream-data buffer for a
|
||||
// single uni stream and the audio frames
|
||||
// never reach downstream subscribers.
|
||||
framesPerGroup = 5,
|
||||
)
|
||||
val handle = speaker.startBroadcasting()
|
||||
delay(150)
|
||||
|
||||
listenProc =
|
||||
ProcessBuilder(
|
||||
harness.hangListenBin().toString(),
|
||||
"--relay-url",
|
||||
harness.relayUrl,
|
||||
"--broadcast",
|
||||
moqNamespace,
|
||||
"--duration",
|
||||
"6",
|
||||
"--output-pcm",
|
||||
pcmFile.absolutePath,
|
||||
).redirectErrorStream(true)
|
||||
.also { it.environment()["RUST_LOG"] = "info" }
|
||||
.start()
|
||||
|
||||
delay(5_000)
|
||||
handle.close()
|
||||
speaker.close()
|
||||
} finally {
|
||||
pumpScope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
|
||||
}
|
||||
|
||||
val exited = listenProc.waitFor(15, TimeUnit.SECONDS)
|
||||
val output = listenProc.inputStream.bufferedReader().readText()
|
||||
assertTrue(exited, "hang-listen did not exit within 15 s. Output:\n$output")
|
||||
assertEquals(
|
||||
0,
|
||||
listenProc.exitValue(),
|
||||
"hang-listen exited non-zero. Output:\n$output",
|
||||
)
|
||||
|
||||
val pcm = readFloat32Pcm(pcmFile)
|
||||
val pcm = readFloat32Pcm(out.pcmFile)
|
||||
PcmAssertions.assertSampleCount(pcm, expectedDurationSec = 5.0, tolerance = 0.20)
|
||||
// Skip first 40 ms — Opus look-ahead silence.
|
||||
val warmupSamples = AudioFormat.SAMPLE_RATE_HZ / 25
|
||||
val analysed = pcm.copyOfRange(warmupSamples, pcm.size)
|
||||
val warmup = AudioFormat.SAMPLE_RATE_HZ / 25
|
||||
val analysed = pcm.copyOfRange(warmup, pcm.size)
|
||||
PcmAssertions.assertFftPeak(analysed, expectedHz = 440.0, halfWindowHz = 5.0)
|
||||
PcmAssertions.assertZeroCrossingRate(
|
||||
analysed,
|
||||
@@ -183,10 +104,121 @@ class HangInteropTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the Rust `hang-publish` and `hang-listen` binaries
|
||||
* through our harness's `moq-relay` subprocess. End-to-end:
|
||||
* 5 s of 440 Hz mono Opus → 880 zero-crossings/sec, FFT peak
|
||||
* at 440 Hz, ~5 s of decoded PCM in the temp file.
|
||||
* I11: assert the first audio frame's payload isn't
|
||||
* `OpusHead\1\1...` codec-config bytes.
|
||||
*
|
||||
* Catches the T8 regression where Android's
|
||||
* `MediaCodecOpusEncoder` would emit OPUS_INITIAL_OUTPUT
|
||||
* config-data as the first uni-stream frame; downstream watchers
|
||||
* (hang.js, our `JvmOpusDecoder`) decode that to a few ms of
|
||||
* white-noise click before catching up. The fix in T8 filters
|
||||
* BUFFER_FLAG_CODEC_CONFIG before the publisher pushes; this
|
||||
* test is the cross-stack regression.
|
||||
*
|
||||
* The hang-listen `--dump-first-frame` flag writes the
|
||||
* post-Container-Legacy-strip codec payload (i.e. the bytes the
|
||||
* Opus decoder will be fed) to a file. We assert the leading
|
||||
* bytes aren't the literal `OpusHead` magic.
|
||||
*/
|
||||
@Test
|
||||
fun first_audio_frame_is_not_opus_codec_config() =
|
||||
runBlocking {
|
||||
val out =
|
||||
runSpeakerToHangListen(
|
||||
speakerSeconds = 2,
|
||||
captureFirstFrame = true,
|
||||
)
|
||||
val firstFrame = out.firstFrameFile?.readBytes()
|
||||
checkNotNull(firstFrame) { "hang-listen --dump-first-frame produced no file" }
|
||||
assertTrue(
|
||||
firstFrame.size >= 8,
|
||||
"first frame is suspiciously short (${firstFrame.size} bytes); " +
|
||||
"expected an Opus packet",
|
||||
)
|
||||
val opusHeadMagic = "OpusHead".encodeToByteArray()
|
||||
val startsWithOpusHead =
|
||||
firstFrame.size >= opusHeadMagic.size &&
|
||||
opusHeadMagic.indices.all { firstFrame[it] == opusHeadMagic[it] }
|
||||
assertFalse(
|
||||
startsWithOpusHead,
|
||||
"first audio frame begins with `OpusHead` magic (${firstFrame.take(16)} → " +
|
||||
"byte 0x${firstFrame[0].toUByte().toString(16)})—the speaker is " +
|
||||
"leaking codec-specific-data as a regular audio frame.",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* I2 — late-join: listener attaches 2 s into a 5 s broadcast
|
||||
* and still gets ~3 s of decoded audio. Asserts the 440 Hz
|
||||
* tone is still recoverable from whatever segment the listener
|
||||
* captured (so a future bug that cancels the broadcast on
|
||||
* subscriber-after-start is caught).
|
||||
*/
|
||||
@Test
|
||||
fun late_join_listener_still_decodes_tail() =
|
||||
runBlocking {
|
||||
val out =
|
||||
runSpeakerToHangListen(
|
||||
speakerSeconds = 5,
|
||||
listenerLateJoinDelayMs = 2_000,
|
||||
captureFirstFrame = false,
|
||||
)
|
||||
val pcm = readFloat32Pcm(out.pcmFile)
|
||||
// Late-join pulls only the post-T+2 portion of the
|
||||
// broadcast — expect 1.5–4 s of decoded audio.
|
||||
assertTrue(
|
||||
pcm.size >= AudioFormat.SAMPLE_RATE_HZ * 3 / 2,
|
||||
"late-join listener decoded only ${pcm.size} samples — " +
|
||||
"expected at least 1.5 s of audio after the late-join window",
|
||||
)
|
||||
val warmup = AudioFormat.SAMPLE_RATE_HZ / 25
|
||||
val analysed = pcm.copyOfRange(warmup, pcm.size)
|
||||
PcmAssertions.assertFftPeak(analysed, expectedHz = 440.0, halfWindowHz = 5.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* I3 — mute window: speaker mutes for 1 s mid-broadcast. The
|
||||
* Amethyst broadcaster doesn't push Opus frames while muted
|
||||
* (it FINs the open uni stream so web watchers don't park on
|
||||
* `await readFrame`), so the decoded PCM ends up ~1 s shorter
|
||||
* than the wallclock broadcast — the mute gap manifests as a
|
||||
* sample-count deficit, not as embedded silence samples.
|
||||
*
|
||||
* The test asserts the deficit is in the right ballpark (so a
|
||||
* regression that, e.g., kept pushing zeros instead of FINning
|
||||
* would be caught — that'd produce a normal-length PCM with
|
||||
* zero RMS in the middle, NOT a short PCM).
|
||||
*/
|
||||
@Test
|
||||
fun mid_broadcast_mute_shortens_decoded_pcm() =
|
||||
runBlocking {
|
||||
val out =
|
||||
runSpeakerToHangListen(
|
||||
speakerSeconds = 4,
|
||||
muteWindowMs = 1_000L..2_000L,
|
||||
captureFirstFrame = false,
|
||||
)
|
||||
val pcm = readFloat32Pcm(out.pcmFile)
|
||||
val durationSec = pcm.size.toDouble() / AudioFormat.SAMPLE_RATE_HZ
|
||||
// Wallclock 4 s minus 1 s mute = ~3 s. Allow ±0.5 s
|
||||
// for Opus look-ahead, group buffering, and the fact
|
||||
// that hang-listen's consumer skips groups older than
|
||||
// its 500 ms latency budget.
|
||||
assertTrue(
|
||||
durationSec in 2.5..3.5,
|
||||
"expected 2.5–3.5 s of decoded PCM (4 s broadcast − 1 s mute), " +
|
||||
"got ${"%.2f".format(durationSec)} s",
|
||||
)
|
||||
// Sanity: the unmuted halves still carry a 440 Hz tone.
|
||||
val warmup = AudioFormat.SAMPLE_RATE_HZ / 25
|
||||
val analysed = pcm.copyOfRange(warmup, pcm.size)
|
||||
PcmAssertions.assertFftPeak(analysed, expectedHz = 440.0, halfWindowHz = 5.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust↔Rust round-trip: pure-Rust through our harness.
|
||||
* Validates the cargo workspace + relay config + `moq-lite-03`
|
||||
* ALPN end-to-end without any Kotlin in the loop.
|
||||
*/
|
||||
@Test
|
||||
fun rust_hang_publish_to_rust_hang_listener_round_trip_440() {
|
||||
@@ -210,9 +242,6 @@ class HangInteropTest {
|
||||
).redirectErrorStream(true)
|
||||
.also { it.environment()["RUST_LOG"] = "info" }
|
||||
.start()
|
||||
// Tiny breathing room so the publisher's ANNOUNCE Active
|
||||
// has propagated to the relay before the listener's
|
||||
// OriginConsumer.announced() returns.
|
||||
Thread.sleep(300)
|
||||
val listenProc =
|
||||
ProcessBuilder(
|
||||
@@ -239,14 +268,9 @@ class HangInteropTest {
|
||||
assertEquals(0, listenProc.exitValue(), "hang-listen exited non-zero. Output:\n$listenOut")
|
||||
|
||||
val pcm = readFloat32Pcm(pcmFile)
|
||||
// hang-publish ran for 5 s @ 50 fps mono Opus. With Opus
|
||||
// look-ahead + relay buffering + listener's per-group
|
||||
// catch-up window, expect 4.5–5.0 s of decoded audio.
|
||||
PcmAssertions.assertSampleCount(pcm, expectedDurationSec = 5.0, tolerance = 0.20)
|
||||
// Skip the first 40 ms so the FFT window doesn't include
|
||||
// Opus's silence-prefilled look-ahead.
|
||||
val warmupSamples = AudioFormat.SAMPLE_RATE_HZ / 25
|
||||
val analysed = pcm.copyOfRange(warmupSamples, pcm.size)
|
||||
val warmup = AudioFormat.SAMPLE_RATE_HZ / 25
|
||||
val analysed = pcm.copyOfRange(warmup, pcm.size)
|
||||
PcmAssertions.assertFftPeak(analysed, expectedHz = 440.0, halfWindowHz = 5.0)
|
||||
PcmAssertions.assertZeroCrossingRate(
|
||||
analysed,
|
||||
@@ -256,6 +280,145 @@ class HangInteropTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for files [runSpeakerToHangListen] hands back to the test.
|
||||
*/
|
||||
private class HangListenOutput(
|
||||
val pcmFile: File,
|
||||
val firstFrameFile: File?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Run the Kotlin speaker for [speakerSeconds] seconds, drive a
|
||||
* [hang-listen] subprocess to capture decoded PCM, optionally
|
||||
* applying mute / late-join / first-frame-dump tweaks. Returns
|
||||
* the captured files to the caller for assertion.
|
||||
*
|
||||
* - [listenerLateJoinDelayMs]: spawn `hang-listen` after this
|
||||
* many ms of broadcast (default 150 ms — just enough for the
|
||||
* speaker's announce to reach the relay before the
|
||||
* subscriber connects, sidesteps the catalog-hook race in
|
||||
* `MoqLiteNestsSpeaker`).
|
||||
* - [muteWindowMs]: if non-null, mute the speaker for this
|
||||
* [start, end] window (in ms relative to broadcast start).
|
||||
* - [captureFirstFrame]: if true, pass `--dump-first-frame
|
||||
* <path>` so the test can read the first frame's raw bytes.
|
||||
*/
|
||||
private suspend fun runSpeakerToHangListen(
|
||||
speakerSeconds: Int,
|
||||
listenerLateJoinDelayMs: Long = 150L,
|
||||
muteWindowMs: ClosedRange<Long>? = null,
|
||||
captureFirstFrame: Boolean,
|
||||
): HangListenOutput {
|
||||
val harness = NativeMoqRelayHarness.shared()
|
||||
|
||||
val signer: NostrSigner = NostrSignerInternal(KeyPair())
|
||||
val pubkey = signer.pubKey
|
||||
val room =
|
||||
NestsRoomConfig(
|
||||
authBaseUrl = "<unused-public-relay>",
|
||||
endpoint = harness.relayUrl,
|
||||
hostPubkey = pubkey,
|
||||
roomId = "rt-${UUID.randomUUID()}",
|
||||
)
|
||||
val moqNamespace = room.moqNamespace()
|
||||
|
||||
val pcmFile =
|
||||
File.createTempFile("hang-listen-pcm", ".bin").also { it.deleteOnExit() }
|
||||
val firstFrameFile =
|
||||
if (captureFirstFrame) {
|
||||
File.createTempFile("hang-listen-first-frame", ".bin").also { it.deleteOnExit() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val transport =
|
||||
QuicWebTransportFactory(
|
||||
// Pin the transport's coroutine scope to the per-test
|
||||
// pumpScope so cancelling pumpScope in `finally` also
|
||||
// tears down the transport's UDP socket + QuicConnection
|
||||
// pumps. Without this, each scenario leaks a UDP socket
|
||||
// and a coroutine tree, and KotlinSpeakerKotlinListenerThroughNativeRelayTest
|
||||
// (which runs last in the alphabetical order) flakes
|
||||
// under the accumulated relay load.
|
||||
parentScope = pumpScope,
|
||||
certificateValidator = PermissiveCertificateValidator(),
|
||||
)
|
||||
|
||||
lateinit var listenProc: Process
|
||||
try {
|
||||
val speaker =
|
||||
connectNestsSpeaker(
|
||||
httpClient = StaticTokenNestsClient,
|
||||
transport = transport,
|
||||
scope = pumpScope,
|
||||
room = room,
|
||||
signer = signer,
|
||||
speakerPubkeyHex = pubkey,
|
||||
captureFactory = { SineWaveAudioCapture(freqHz = 440) },
|
||||
encoderFactory = { JvmOpusEncoder() },
|
||||
framesPerGroup = 5,
|
||||
)
|
||||
val handle = speaker.startBroadcasting()
|
||||
delay(listenerLateJoinDelayMs)
|
||||
|
||||
val cmd =
|
||||
mutableListOf(
|
||||
harness.hangListenBin().toString(),
|
||||
"--relay-url",
|
||||
harness.relayUrl,
|
||||
"--broadcast",
|
||||
moqNamespace,
|
||||
"--duration",
|
||||
"${speakerSeconds + 2}",
|
||||
"--output-pcm",
|
||||
pcmFile.absolutePath,
|
||||
)
|
||||
firstFrameFile?.let {
|
||||
cmd += listOf("--dump-first-frame", it.absolutePath)
|
||||
}
|
||||
listenProc =
|
||||
ProcessBuilder(cmd)
|
||||
.redirectErrorStream(true)
|
||||
.also { it.environment()["RUST_LOG"] = "info" }
|
||||
.start()
|
||||
|
||||
if (muteWindowMs != null) {
|
||||
val muteStart = muteWindowMs.start
|
||||
val muteEnd = muteWindowMs.endInclusive
|
||||
// listenerLateJoinDelayMs has already been waited
|
||||
// before this point. Subtract it so the mute schedule
|
||||
// is anchored to broadcast start.
|
||||
val toMute = (muteStart - listenerLateJoinDelayMs).coerceAtLeast(0)
|
||||
val toUnmute = muteEnd - muteStart
|
||||
val toEnd = speakerSeconds * 1_000L - muteEnd
|
||||
|
||||
delay(toMute)
|
||||
handle.setMuted(true)
|
||||
delay(toUnmute)
|
||||
handle.setMuted(false)
|
||||
delay(toEnd)
|
||||
} else {
|
||||
delay(speakerSeconds * 1_000L - listenerLateJoinDelayMs)
|
||||
}
|
||||
handle.close()
|
||||
speaker.close()
|
||||
} finally {
|
||||
pumpScope.coroutineContext[Job]?.cancel()
|
||||
}
|
||||
|
||||
val exited = listenProc.waitFor(15, TimeUnit.SECONDS)
|
||||
val output = listenProc.inputStream.bufferedReader().readText()
|
||||
assertTrue(exited, "hang-listen did not exit within 15 s. Output:\n$output")
|
||||
assertEquals(
|
||||
0,
|
||||
listenProc.exitValue(),
|
||||
"hang-listen exited non-zero. Output:\n$output",
|
||||
)
|
||||
return HangListenOutput(pcmFile = pcmFile, firstFrameFile = firstFrameFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass the NIP-98 auth handshake — the harness boots moq-relay
|
||||
* with `--auth-public ""`, which grants any path without a JWT.
|
||||
|
||||
+45
-13
@@ -45,20 +45,43 @@ import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
/**
|
||||
* **Diagnostic-only test for the I1 forward-direction gap** —
|
||||
* Kotlin speaker → Kotlin listener through [NativeMoqRelayHarness].
|
||||
*
|
||||
* Diagnostic for the I1 forward-direction gap. Isolates whether
|
||||
* the audio uni stream issue is Kotlin-side (bug in `:nestsClient`'s
|
||||
* publisher framing) or interop-specific (kotlin's frames are RFC
|
||||
* shaped but Rust's parser interpretation differs). If both ends are
|
||||
* Kotlin and the listener still doesn't see frames, the producer
|
||||
* is at fault. If both ends are Kotlin and frames flow, the
|
||||
* Kotlin↔Rust gap is on the reader side or in a subtle frame-vs-
|
||||
* datagram-vs-control-stream encoding mismatch.
|
||||
* Runs in a single JVM with no Rust subprocesses, so when there's
|
||||
* a wire-format suspicion this test isolates whether the issue is
|
||||
* Kotlin-side (broken publisher framing) or interop-specific
|
||||
* (Rust parser interpretation differs from Kotlin's). Used in
|
||||
* Phase 2 to bisect the `framesPerGroup` cliff.
|
||||
*
|
||||
* Gated *separately* from the regular hang-interop tests
|
||||
* (`-DnestsHangInteropDiagnostic=true`) — running it in the same
|
||||
* JVM as a green `HangInteropTest` flakes due to relay-side state
|
||||
* accumulation across the 5 native subprocess scenarios. Keep
|
||||
* the test for future bisects; don't run it as part of normal
|
||||
* CI pass.
|
||||
*/
|
||||
class KotlinSpeakerKotlinListenerThroughNativeRelayTest {
|
||||
@BeforeTest
|
||||
fun gate() {
|
||||
val msg =
|
||||
"Skipping Kotlin↔Kotlin diagnostic test — set " +
|
||||
"-DnestsHangInteropDiagnostic=true to enable. This test is for " +
|
||||
"isolating wire-format bugs against the harness's relay; flakes " +
|
||||
"when run alongside HangInteropTest's native subprocess scenarios."
|
||||
if (System.getProperty("nestsHangInteropDiagnostic") != "true") {
|
||||
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)
|
||||
}
|
||||
return
|
||||
}
|
||||
NativeMoqRelayHarness.assumeHangInterop()
|
||||
}
|
||||
|
||||
@@ -80,6 +103,12 @@ class KotlinSpeakerKotlinListenerThroughNativeRelayTest {
|
||||
val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val transport =
|
||||
QuicWebTransportFactory(
|
||||
// See HangInteropTest helper for rationale —
|
||||
// anchor the transport scope to pumpScope so the
|
||||
// UDP socket + QuicConnection tree dies cleanly
|
||||
// when this test ends, instead of leaking past
|
||||
// and starving subsequent tests' open ports.
|
||||
parentScope = pumpScope,
|
||||
certificateValidator = PermissiveCertificateValidator(),
|
||||
)
|
||||
|
||||
@@ -121,13 +150,16 @@ class KotlinSpeakerKotlinListenerThroughNativeRelayTest {
|
||||
val subscription = listener.subscribeSpeaker(pubkey)
|
||||
|
||||
// Collect the next 50 audio frames (~1 s of audio).
|
||||
// 8 s wallclock budget covers the harness handshake +
|
||||
// any catalog hook race + small gradle worker startup
|
||||
// overhead when this test runs after HangInteropTest
|
||||
// in the same JVM.
|
||||
// 15 s wallclock budget — this test runs LAST in the
|
||||
// alphabetical class order after HangInteropTest's
|
||||
// 5 native-subprocess scenarios, which leave the
|
||||
// shared moq-relay loaded with stale per-session
|
||||
// state (UDP sockets, broadcast queues). Tighter
|
||||
// budgets flake under that load even when the
|
||||
// underlying path works.
|
||||
val received =
|
||||
async(pumpScope.coroutineContext) {
|
||||
withTimeoutOrNull(8_000L) {
|
||||
withTimeoutOrNull(15_000L) {
|
||||
subscription.objects.take(50).toList()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user