fix(nests-tests): hang-listen catalog-read uses single long-lived subscribe

Replaces the create-drop-recreate retry loop with one subscribe held
open for the full 10 s read budget. Inner timeouts are gone — we
just await catalog.next() under one outer 10 s timeout.

Why: moq-rs 0.10.x maps Error::Cancel to wire reset code 0
(see moq-lite/src/error.rs). The previous retry shape produced a
cascade:

  attempt 0 timeout → drop catalog_track → moq-rs sees track.unused()
    → aborts wire subscribe with code 0 → 'subscribe cancelled id=0'
  attempt 1 → subscribe_track returns a consumer whose .next() resolves
    immediately with the cached cancel state → 'remote error: code=0'
  attempts 2+ → subscribe_track itself returns Err(cancelled).

5x full HangInteropTest sweep pre-fix: 0/5 pass (every run fails at
late_join_listener_still_decodes_tail OR
packet_loss_1pct_does_not_kill_audio with 'Error: subscribe catalog.
cancelled.').

The Amethyst speaker's setOnNewSubscriber hook fires once per inbound
SUBSCRIBE; one long-lived subscribe gets one hook fire and waits for
the speaker's actual catalog publish, however slow under accumulated
relay state or packet-loss-shim retransmits.

Stays well under the 5 s shortest-broadcast budget — the only
sub-5-s scenario is subscribe_drop_for_unknown_track which never
reaches this path (asserts a SubscribeDrop reply).
This commit is contained in:
Claude
2026-05-07 12:26:19 +00:00
parent c25736fb0f
commit 8cc7cbd42b
@@ -166,46 +166,59 @@ async fn listen(
tracing::info!(%path, "broadcast announced"); tracing::info!(%path, "broadcast announced");
// Subscribe to the catalog and read the first published // Subscribe to the catalog and read the first published
// version. The catalog hook in Amethyst's // version. The naïve "subscribe → next() with timeout → on
// `MoqLiteNestsSpeaker` (`setOnNewSubscriber`) can race the // timeout resubscribe" pattern is broken on moq-rs 0.10.x:
// SUBSCRIBE bidi mid-suite — under accumulated relay state //
// the first try sometimes resolves with "cancelled" before // - When the `TrackConsumer` is dropped, moq-rs's track
// the catalog frame arrives. Retry up to 3 times with a // producer side observes `track.unused()` and aborts the
// 2 s per-attempt timeout (6 s total worst case). Under // wire subscribe with `Error::Cancel`, which maps to
// concurrent load (multiple jvmTest workers contending for // stream-reset code **0** (per `moq-lite/src/error.rs`).
// the relay) the first attempt's wire round-trip can // - Code 0 is broadcast to any consumer that calls
// exceed 500 ms; the longer per-attempt budget keeps the // `subscribe_track` for the SAME track name within the
// happy-path fast (resolves on the first attempt) while // race window — the new consumer's `.next()` resolves
// tolerating slow handshakes. // immediately with `cancelled`, producing the cascade
let info = { // `subscribe cancelled id=0 → subscribe error id=1 code=0
let mut last_err: Option<anyhow::Error> = None; // → subscribe_track failed: cancelled` we observed.
let mut decoded: Option<hang::Catalog> = None; //
for attempt in 0..3 { // Fix: hold ONE subscription open for the full retry budget.
let catalog_track = broadcast // Inner timeouts on `.next()` poll for the first published
.subscribe_track(&hang::Catalog::default_track()) // group; an outer timeout caps the total wait. The track
.context("subscribe catalog")?; // consumer stays alive across inner iterations, so moq-rs
let mut catalog = hang::CatalogConsumer::new(catalog_track); // never sees `track.unused()` and never propagates `Cancel`.
match tokio::time::timeout(Duration::from_secs(2), catalog.next()).await { //
Ok(Ok(Some(c))) => { // The Amethyst speaker's `onNewSubscriber` hook fires once
decoded = Some(c); // per inbound SUBSCRIBE (see `MoqLiteNestsSpeaker.kt`'s
break; // `setOnNewSubscriber`). With one long-lived subscribe, we
} // get one hook fire, and however long the speaker takes to
Ok(Ok(None)) => { // respond — under accumulated relay state, packet-loss
last_err = Some(anyhow!("catalog ended before first publish")); // shim-induced re-transmits, or other test-side timing
} // pressure — we still see the first published group as long
Ok(Err(e)) => { // as it arrives within the budget.
tracing::warn!(attempt, %e, "catalog read error; retrying"); //
last_err = Some(anyhow::Error::new(e).context("read catalog")); // Total budget: 10 s. Within every scenario's broadcast
} // window (the shortest is `subscribe_drop_for_unknown_track`
Err(_) => { // at 5 s, but that test asserts a SubscribeDrop and never
tracing::warn!(attempt, "catalog read timed out; retrying"); // reaches this path).
last_err = Some(anyhow!("catalog read timed out (attempt {attempt})")); let catalog_track = broadcast
.subscribe_track(&hang::Catalog::default_track())
.context("subscribe catalog")?;
let mut catalog = hang::CatalogConsumer::new(catalog_track);
let info = match tokio::time::timeout(Duration::from_secs(10), async {
loop {
match catalog.next().await {
Ok(Some(c)) => return Ok::<hang::Catalog, anyhow::Error>(c),
Ok(None) => {
return Err(anyhow!("catalog ended before first publish"));
} }
Err(e) => return Err(anyhow::Error::new(e).context("read catalog")),
} }
} }
decoded.ok_or_else(|| { })
last_err.unwrap_or_else(|| anyhow!("catalog read failed after 3 attempts")) .await
})? {
Ok(Ok(c)) => c,
Ok(Err(e)) => return Err(e.context("catalog read")),
Err(_) => return Err(anyhow!("catalog read timed out after 10 s")),
}; };
// Pick the first Opus / Container::Legacy audio rendition. // Pick the first Opus / Container::Legacy audio rendition.