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
|
||||
|
||||
Reference in New Issue
Block a user