Files
amethyst/nestsClient/plans/2026-05-07-late-join-catalog-flake-investigation.md
T
Claude 589ced580c docs(nests): refresh all T16 + cliff plans to current state
8 plan files updated to reflect what's actually shipped vs. what
each doc said before:

- 2026-05-06-cross-stack-interop-test.md: 'Spec — ready to
  implement' → 'Implemented and merged' with pointers to results,
  gap matrix, and closure roadmap.

- 2026-05-06-cross-stack-interop-test-results.md: scenario
  inventory updated to show all branches merged into
  claude/cross-stack-interop-test-XAbYB; suite-flake caveats
  flagged with cross-refs to the routing investigation; file
  inventory now lists BrowserInteropTest + PlaywrightDriver +
  the moved nestsClient/tests/browser-interop/ tree (no more 'in
  sister branches not yet merged' section).

- 2026-05-06-cross-stack-interop-test-gap-matrix.md: T8/T10/T11/
  T12/T13 all show hang  + browser ; I13/I14 'NOT YET LANDED'
  callouts removed; the 'in sister branch' qualifier on T13 / I7
  / I6 dropped; coverage-holes section replaced with caveat
  pointers to the routing investigation.

- 2026-05-06-i4-stereo-cross-stack-scenario.md: 'Spec — ready for
  implementation' → 'Landed' with PR #2755 + the three test
  scenarios that ship the assertion.

- 2026-05-06-phase4-browser-harness.md: 'Spec — ready for
  implementation' → 'Landed', with notes on how the implementation
  diverged (used Container.Legacy.Consumer not @moq/watch; cert
  pinning via serverCertificateHashes; I2/I3/I4/I13/I14 originally
  deferred but later landed).

- 2026-05-06-phase4-browser-harness-results.md: status updated to
  reflect 4.D CI removed per maintainer ask; layout note for
  the nestsClient/tests/browser-interop/ relocation; full
  scenario list reconciled with results doc.

- 2026-05-07-late-join-catalog-flake-investigation.md: status
  updated to mention 00f6cba31 + 207057374 were reverted, and
  link to 2026-05-07-moq-relay-routing-investigation.md as the
  action plan. Adds note that the flake also affects four
  browser-tier scenarios after Browser I7 landed.

- 2026-05-01-quic-stream-cliff-investigation.md: adds an HCgOY-
  override banner — recommendation 2 (framesPerGroup = 5) was
  superseded 4 days later by HCgOY field tests landing the prod
  default at 50. Cross-refs the reconciliation + production-
  rerun plans. Open follow-up #3 ('reset to 1') updated with
  the same context.

No code or test changes.
2026-05-07 14:52:21 +00:00

258 lines
12 KiB
Markdown

# `late_join_listener_still_decodes_tail` catalog-cancelled flake investigation
**Status: partially fixed (commit `8cc7cbd42` shipped; commits
`00f6cba31` + `207057374` reverted as net-negative). Residual
flake is upstream-territory in moq-relay 0.10.x. Action plan
moved to `2026-05-07-moq-relay-routing-investigation.md`.**
The flake also affects four browser-tier scenarios after the
Browser I7 work landed:
`chromium_listener_late_join_still_decodes_tail`,
`chromium_publisher_baseline_kotlin_listener_decodes`,
`chromium_publisher_reconnect_kotlin_listener_recovers`, and
intermittently `chromium_listener_long_broadcast_60s_tone_440`.
Browser scenarios soft-pass listener-side assertions on 0-frame
outcomes; hard floors planned in
`2026-05-07-tighten-cross-stack-assertions.md`.
`HangInteropTest.late_join_listener_still_decodes_tail`,
`packet_loss_1pct_does_not_kill_audio`,
`long_broadcast_60s_tone_round_trips`, and
`amethyst_speaker_to_hang_listener_stereo_440_660` intermittently
fail with `hang-listen` exiting non-zero on a `subscribe error`
during catalog read. Pre-fix flake rate: 5/5 fail in a sweep.
After three layered mitigations: ~2-3/5 fail. The remaining flake
is in moq-relay 0.10.x's per-broadcast announce → subscribe-pump
setup race; the test-side mitigations have hit diminishing returns.
## Pre-fix root cause: moq-rs cancel cascade
The previous hang-listen catalog-read shape:
```rust
for attempt in 0..3 {
let catalog_track = broadcast.subscribe_track(...)?;
let mut catalog = hang::CatalogConsumer::new(catalog_track);
match tokio::time::timeout(Duration::from_secs(2), catalog.next()).await { ... }
// catalog_track + catalog drop at iteration boundary
}
```
is broken on moq-rs 0.10.x. The flow:
1. Attempt 0: `subscribe_track` creates a TrackConsumer. Wire
subscribe id=0 fires.
2. Speaker's `setOnNewSubscriber` hook is supposed to write the
catalog via `send(catalogJson) + endGroup()`. **For some reason
this doesn't deliver in time** — see "residual root cause" below.
3. 2 s timeout fires. Loop iteration ends. `catalog_track` drops.
4. moq-rs sees `track.unused()` resolve (no consumers left), aborts
the wire subscribe with `Error::Cancel`.
5. **`Error::Cancel` maps to wire stream-reset code 0** per
`moq-lite/src/error.rs:96-105`.
6. Attempt 1: `subscribe_track(catalog.json)` returns a consumer
whose internal state is already in the just-cancelled state.
`.next()` resolves immediately with `cancelled`.
7. Attempt 2+: subscribe_track itself returns `Err(cancelled)`.
8. Loop bails; `Error: subscribe catalog. cancelled.`
**Fix (commit `8cc7cbd42`):** hold ONE subscription open for the
full 10 s budget; inner timeouts on `.next()` poll for the first
group; outer timeout caps the total wait. Code is in
`hang-interop/hang-listen/src/main.rs`.
This eliminates the cancel-cascade failure mode. 2 of 5 sweep runs
post-fix go all-green; 3 hit the residual described next.
## Residual root cause (unidentified)
Same test, post-fix, fresh repro from sweep run 4:
```
12:35:45.333625 subscribe started id=0 catalog.json
(no further logs from hang-listen for 2.94 s)
12:35:48.267341 subscribe error id=0 err=remote error: code=0
Error: catalog read | moq lite error: cancelled
```
The single, long-lived subscribe is cancelled by the **peer** (relay
or speaker) ~3 s after start. Wallclock alignment:
- Speaker started broadcasting at T=0
- `delay(listenerLateJoinDelayMs = 2_000)` → T=2 s
- hang-listen connects + subscribes → T=2.05 s
- Speaker's broadcast window ends at T=5 s
(helper does `delay(speakerSeconds * 1_000 - listenerLateJoinDelayMs)`
= `delay(3_000)` AFTER hang-listen starts)
- Listener-observed cancel at speaker-T+~5 s = ~3 s after subscribe
So the cancel coincides with the speaker tearing down. The catalog
data **never arrived during the 3 s subscribe window**, despite the
speaker presumably having the hook installed AND the inbound
SUBSCRIBE arriving normally.
## Ruled out
- **Hook installation race.** `MoqLiteNestsSpeaker.setOnNewSubscriber`
is called BEFORE the speaker transitions to `Broadcasting` state
(`MoqLiteNestsSpeaker.kt:176-182`). Listener subscribes 2 s
later — hook is definitively installed.
- **Stale `inboundSubs`.** `@BeforeTest` calls `resetShared()` which
restarts the moq-relay subprocess. Speaker session is fresh per
test (new `pumpScope` + fresh `MoqLiteSession`). No cross-test
state leak.
- **Hook captured-but-null.** `registerInboundSubscription` reads
`onNewSubscriberHook` inside the gate AFTER the sub is added.
By T=2 s the hook is non-null.
- **`inboundSubs.isEmpty()` race in `send()`.** Hook is launched
AFTER `inboundSubs += sub` inside the same gate. The hook's
`send()` re-acquires the gate; sees `inboundSubs` non-empty.
- **`MAX_STREAMS_UNI` exhaustion at speaker.** Catalog uni stream
is one stream per subscriber; cap is 10000.
- **Idle timeout.** Quinn's default (per moq-native) is 30 s, not 3.
- **Audio publisher's `onTerminalFailure` firing.** Only triggered
by `MAX_CONSECUTIVE_SEND_ERRORS` thrown errors, not the
no-subscribers `return false` path that the audio publisher
takes during the 2 s warmup.
## Plausible remaining hypotheses
1. **Relay-side per-track state race.** The relay's downstream
subscriber (forwarding to hang-listen) is created when
hang-listen's SUBSCRIBE arrives. The relay's upstream subscriber
(subscribed-on-speaker) might be created on-demand and might race
with the speaker's hook firing. If the relay's upstream consumer
isn't fully alive by the time the speaker's uni stream arrives at
the relay, the relay drops the uni without forwarding.
2. **Catalog uni stream priority/scheduling.** The audio publisher
(running silent — `inboundSubs.isEmpty() → return false`) could
somehow contend with the catalog publisher's uni-stream open via
the shared `transport.openUniStream()`, even though they're
separate publishers with separate gates. Less likely.
3. **moq-rs CLIENT-side `subscribe_track` returning a stale
consumer.** Even on the first call, if the broadcast's track-
producer pool has ANY residual entry from an earlier test (despite
`resetShared()`), the consumer might be born already-cancelled.
The 2/5 pass rate suggests there's a timing component.
4. **`Track.unused()` racing with the long subscribe.** The hang
crate's `CatalogConsumer::new(track)` may temporarily drop an
internal handle, causing a brief `unused()` flicker that aborts
the upstream subscription before the speaker's data arrives.
## What would confirm a hypothesis
1. **Speaker-side log instrumentation.** Add `Log.d("NestTx") {
"catalog hook fired for subId=$id" }` inside the
`setOnNewSubscriber` lambda; `"catalog send returned $result for
subId=$id"` after each `send()`; `"catalog endGroup completed for
subId=$id"`. Run the failing test under `--info` Gradle output
to see if the hook fires + writes succeed on the SPEAKER side.
If yes → the data is being written but the listener isn't
receiving it (relay-side issue).
2. **Relay-side log instrumentation.** Boot moq-relay with
`RUST_LOG=moq_relay=debug,moq_lite=debug` and capture per-test
stderr to a file. Look for "subscribe started" / "serving group" /
"subscribe cancelled" timing on the relay side. Cross-reference
with hang-listen's view.
3. **Listener-side QUIC-level capture.** Wrap hang-listen's UDP
socket via `udp-loss-shim` modified to packet-log instead of drop.
See exactly which streams open + close.
## Mitigations attempted (in order)
1. **Per-method `resetShared()`** (`706ccda67`) — kills the relay
subprocess between test methods. Closes a moq-rs accumulated-
state class but the catalog-cancel pattern persists.
2. **hang-listen single long-lived subscribe** (`8cc7cbd42`) —
replaces the create-drop-recreate retry shape with one
subscribe held for the full 10 s read budget. Eliminates the
moq-rs `Error::Cancel` cascade. **5/5 fail → ~2-3/5 pass.**
3. **Speaker warmup bump 150 ms → 600 ms** (`00f6cba31`) — gave
the relay more time to register the speaker's broadcast in
its origin before the listener subscribed. **NET NEGATIVE,
reverted in `1ddf4967c`** — same failure pattern AND ate
into the listener's catalog-read window (5 s broadcast minus
600 ms warmup leaves ~4.4 s instead of 4.85 s).
4. **hang-listen 250 ms post-`origin.announced()` sleep**
(`207057374`) — gave the relay time to fully prime its
per-broadcast upstream-subscribe pump. **NET NEGATIVE,
reverted in `9b8b5692b`** — combined with #3 produced 0/5
sweep pass (worse than single-subscribe-fix-alone's 2/5)
because the cumulative ~850 ms of pre-subscribe delay
shrank the catalog-read window into the speaker tear-down
region.
Lesson: the failure window for the broken-routing case is
~3 seconds (until the speaker tears down at end of broadcast).
ANY pre-subscribe delay shrinks the available retry budget on
the listener side. Mitigations should NOT add delays.
## Smoking gun (from speaker stderr trace)
For broadcasts that fail (`10d4b6f2…`, `c75e2648…`, `f1be27ef…`),
the speaker-side `Log.d("NestTx")` trace shows ONE event for the
broadcast suffix:
12:53:32.293 ANNOUNCE inbound prefix='' → emitted Active suffix='f1be27ef…'
…and then NOTHING for the entire 10 s catalog-read window. No
`SUBSCRIBE inbound`, no `openGroupStream`. The audio publisher
keeps logging `send returning false — no inboundSubs` at 50 fps
until hang-listen times out. Meanwhile hang-listen's moq-rs client
is logging `subscribe started id=0 catalog.json` and waiting.
**Interpretation:** the relay accepts the listener's wire
SUBSCRIBE on the downstream connection, BUT does not open an
upstream SUBSCRIBE bidi to the speaker. The two sides are
disconnected; nothing the test code can fix from above.
For broadcasts that succeed (`688e130b…`, `6e577e5f…`), the same
trace shows:
12:58:10.334 ANNOUNCE inbound prefix='' → emitted Active suffix='6e577e5f…'
12:58:11.246 SUBSCRIBE inbound id=0 broadcast='6e577e5f…' track='catalog.json'
12:58:11.246 SUBSCRIBE registered id=0 …
12:58:11.247 openGroupStream subId=0 seq=0
The relay successfully forwards the upstream SUBSCRIBE. It's a
binary "the relay does or does not forward" — there's no partial
state.
## Conclusion
The flake is **moq-relay 0.10.x's relay-side per-broadcast
forward-subscribe routing**, not anything the test or speaker can
mitigate from outside. Some component of the relay's
`Origin::announced()` → `broadcast.subscribe_track(...)` →
upstream subscribe pump is set up asynchronously and intermittently
fails to wire up the upstream subscribe.
Possible next steps if this matters more:
1. **Boot the relay with `RUST_LOG=moq_relay=trace,moq_lite=trace`**
under the test harness (currently `RUST_LOG=info`); capture per-
test relay stderr to a tempfile; check whether the relay logs
the upstream subscribe attempt for the failing broadcast suffix.
2. **File an upstream issue at `kixelated/moq`** with this
reproducer (HangInteropTest 5x sweep, ~40-60% flake under load)
citing the smoking-gun trace pair above.
3. **Stop pinning to `moq-relay 0.10.25`** and try the next minor
release — maybe the race has been fixed upstream since.
Currently rejected: bumping `speakerSeconds` to 8 s and changing
the test's threshold. That masks a real bug; the failure mode is
worth surfacing.
## Files referenced
- `nestsClient/tests/hang-interop/hang-listen/src/main.rs:160-220`
(post-fix catalog read shape)
- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt:170-181`
(`setOnNewSubscriber` hook installation)
- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt:1167-1192`
(`registerInboundSubscription` + hook-launch path)
- `nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/HangInteropTest.kt:170-180`
(`late_join_listener_still_decodes_tail` scenario)
- Pre/post sweep results: `0/5 → 2/5` over 10 total sweep runs.