5/5 sweep BUILD SUCCESSFUL post-merge of `origin/main` (5 `:quic`
commits: ALPN-list threading `2a4c07ae`, PTO STREAM retransmits
`d5c854be`, RFC 9001 §6 1-RTT key update `b622d0c9`, multiconnect
pacing `86a4727e`, qlog flush `31d19258`). Pre-merge baseline on
the same branch with the same TRACE capture: 3 fail / 5,
all in `late_join_listener_still_decodes_tail`. 55/55 tests pass.
Updated:
- routing-investigation: marked CLOSED, added Closure section with
pre-merge vs post-merge sweep counts + sample 1.6 ms-RTT
late-join trace.
- late-join investigation: marked closed.
- closure-roadmap: Priority 1 ✅; Priorities 2 and 3 unblocked.
Preserved a post-merge passing late-join relay trace under
`nestsClient/plans/artefacts/2026-05-07-routing-race-closed-by-merge/`
as the "what healthy looks like" baseline.
23 KiB
Plan: investigate moq-relay 0.10.x per-broadcast subscribe-routing race
Status: ✅ CLOSED. The flake was a :quic packet-acceptance bug,
not a moq-relay routing race. Merging
origin/main (5 commits: ALPN-list threading 2a4c07ae, PTO STREAM
retransmits d5c854be, 1-RTT key update b622d0c9,
multiconnect/multiplex 86a4727e, qlog flush 31d19258) closes
it. Post-merge sweep:
5/5 BUILD SUCCESSFUL, 55/55 tests pass on
./gradlew :nestsClient:jvmTest --tests HangInteropTest -DnestsHangInterop=true -DnestsHangInteropTraceRelay=true --rerun-tasks.
The acceptance bar of the closure roadmap's Priority 1 is met.
Priority 2 (2026-05-07-tighten-cross-stack-assertions.md) is now
unblocked; Priority 3 (2026-05-07-cross-stack-interop-ci-gating.md)
follows after that.
Closure (2026-05-07, post-merge)
After the corrected diagnosis below pinned the actor on :quic,
five :quic commits had landed on origin/main between the
session's merge base and pickup. Merging them and re-running the
5× sweep gives:
sweep 1: BUILD SUCCESSFUL in 5m 9s (11/11 pass)
sweep 2: BUILD SUCCESSFUL in 2m 51s (11/11 pass)
sweep 3: BUILD SUCCESSFUL in 2m 41s (11/11 pass)
sweep 4: BUILD SUCCESSFUL in 2m 35s (11/11 pass)
sweep 5: BUILD SUCCESSFUL in 2m 34s (11/11 pass)
Pre-merge baseline on the same branch (commit b2a42d9a, same
TRACE capture, same --rerun-tasks shape): 3 fail / 5 sweeps
(all late_join_listener_still_decodes_tail).
Post-merge sample relay trace for the previously-failing scenario shows the speaker now responding to the upstream SUBSCRIBE in ~1.5 ms:
20:14:17.460567 conn{id=0} subscribe started catalog.json
20:14:17.460585 conn{id=0} encoding self=Subscribe …catalog.json
20:14:17.462141 conn{id=0} decoded result=SubscribeOk ← 1.6 ms RTT
20:14:17.462446 conn{id=0} decoded result=Group seq=0
vs the pre-merge failing trace where the same span had 2.94 s of silence followed by Ended.
Likely actors among the merged :quic commits:
b622d0c9 feat(quic): RFC 9001 §6 1-RTT key update— addspeekKeyPhase+ key-rotation tracking inQuicConnectionParser. Pre-fix, every short-header packet whose KEY_PHASE bit didn't matchcurrentReceiveKeyPhasewas silently AEAD-failed and dropped. While quinn doesn't initiate key updates by default, the parser path also touched the short-header decoding side, plausibly fixing an adjacent off-by-one in short-payload header protection (new testShortPayloadHeaderProtectionTest.ktlands alongside).d5c854be fix(quic): PTO retransmits handshake CRYPTO + STREAM— fixes our outbound PTO when the peer never ACKs anything. Outbound-only fix, less likely to affect the inbound bidi-data parsing side.2a4c07ae fix(quic): thread offered ALPN list through TlsClient → ClientHello— affects handshake; speaker connection had already established before the failing bidi arrived, so unlikely to be the actor.
The empirical evidence is what counts: 5/5 sweep BUILD SUCCESSFUL post-merge. Bisecting WHICH of the 5 commits closes it can be done if needed for the post-mortem; not necessary to proceed with the closure roadmap.
Corrected diagnosis (2026-05-07, post-trace)
5× sweep on claude/t16-nestsclient-closure-1zBIc (rustc 1.95,
moq-relay 0.10.25, -DnestsHangInteropTraceRelay=true) →
3 failures / 5 sweeps, all in
late_join_listener_still_decodes_tail. Sweeps 1, 2, 3 failed;
sweeps 4, 5 passed.
For one of the failing runs (sweep 1, broadcast suffix
6d60532f…), the relay's full trace + the speaker-side
Log.d("NestTx") lines in JUnit <system-err> confirm:
relay log (conn{id=0} = relay↔speaker, conn{id=1} = relay↔listener)
─────────────────────────────────────────────────────────────────
18:34:52.085 conn{id=0} session accepted (speaker connects)
18:34:52.085 conn{id=0} encoding AnnounceInterest (relay → speaker bidi #1)
18:34:52.092 conn{id=0} decoded Active suffix=6d60… (speaker replied to AI)
18:34:54.151 conn{id=1} session accepted (listener connects, T+2.07 s)
18:34:54.152 conn{id=1} decoded Subscribe id=0 catalog.json
18:34:54.152 conn{id=1} subscribed started …catalog.json
18:34:54.152 conn{id=0} subscribe started id=0 …catalog.json ← upstream
18:34:54.152 conn{id=0} encoding Subscribe …catalog.json ← bidi #2
18:34:57.095 conn{id=0} decoded Ended suffix=6d60… ← 2.94 s of silence
then speaker tears down
18:34:57.095 conn{id=1} subscribed cancelled id=0
18:34:57.096 conn{id=0} subscribe cancelled id=0
speaker NestTx log (matching window)
─────────────────────────────────────────────────────────────────
18:34:52.092 ANNOUNCE inbound prefix='' → emitted Active suffix='6d60…'
18:34:52.111 send returning false — no inboundSubs (count=1)
18:34:53.111 send returning false — no inboundSubs (count=51)
18:34:54.111 send returning false — no inboundSubs (count=101)
18:34:55.111 send returning false — no inboundSubs (count=151)
18:34:56.111 send returning false — no inboundSubs (count=201)
18:34:57.092 send returning false — no inboundSubs (count=250)
↑ NO `SUBSCRIBE inbound` LOG.
The relay opens bidi #2 (peer-initiated bidi from relay TO
speaker) at 18:34:54.152 and writes a complete Subscribe { id:0, track:"catalog.json" } message to it. The wire send succeeds (no
relay-side error). The speaker's MoqLiteSession.handleInboundBidi
never logs SUBSCRIBE inbound id=0 broadcast=…6d60… — meaning the
bidi never reaches the moq-lite session's bidi pump's
launch { handleInboundBidi(bidi) } body. It is silently lost
between the speaker's QUIC stack and the application layer.
The same speaker connection HAS handled bidi #1 (the relay's
AnnounceInterest at 18:34:52.085) correctly — see the
ANNOUNCE inbound prefix='' log at 18:34:52.092. So the speaker's
pumpInboundBidis is NOT permanently dead; it stops surfacing
peer-opened bidis sometime between T=0 and T+2 s, intermittently.
For comparison, sweep 4's identical scenario (which passed) shows the relay's bidi #2 → speaker SubscribeOk round-trip in ~1.94 ms:
18:42:44.954530 conn{id=0} encoding Subscribe …catalog.json
18:42:44.956465 conn{id=0} decoded SubscribeOk
So the speaker CAN handle the late-join SUBSCRIBE bidi. It just sometimes loses it. The 60 % flake rate matches what the prior investigation observed.
Why the prior investigation pointed at moq-relay
The prior plan's "smoking gun" trace observed
ANNOUNCE inbound … emitted Active suffix='<x>' followed by
no further SUBSCRIBE inbound for the failing broadcast on
the speaker side, and concluded the relay must have failed to
forward the SUBSCRIBE upstream. That conclusion was correct given
only the speaker-side trace; what we now have — the relay-side
trace from Step 1 capture — shows the relay DID forward, so the
gap is between the wire and the speaker's app code.
The route is therefore not Origin::announced() →
broadcast.subscribe_track(...) (relay-side) but
QUIC bidi acceptance → WtPeerStreamDemux.readyStreams →
QuicWebTransportSession.incomingBidiStreams() →
MoqLiteSession.pumpInboundBidis → handleInboundBidi
(speaker-side). One link in that chain drops the second bidi about
40-60 % of the time.
What to investigate next (out of scope here)
The next agent picking this up should look at:
:quicmodule'sWtPeerStreamDemux— thereadyStreams = Channel<StrippedWtStream>(Channel.UNLIMITED)and itsconsumeAsFlow()consumer.consumeAsFlowis single-collector; we already verified onlypumpInboundBidiscollects on the speaker side (nopumpUniStreamsruns on a pure publisher), so the single-consumer constraint isn't violated, but a peer-bidi that wins the "is this the WT bidi prefix or a control stream" classification race might be misrouted to a different sink.- WT_BIDI_STREAM prefix stripping under flow control pressure.
emitStripped(WtPeerStreamDemux.kt:292-327) firesreadyStreams.trySend(...). The path that PREBUFFERS bytes between connection acceptance and prefix recognition is the most likely place a long-tail bidi gets lost — there's anArrayDeque<ByteArray> pendingper stream that gets flushed into the data Flow only after the prefix is identified. - Concurrent uni-stream openings during the warmup window.
The audio publisher's
send()returns false fast wheninboundSubs.isEmpty()(no uni stream opened), so the speaker shouldn't be opening 50 fps of uni streams pre-listener. But the audio publisher DOES eventually callendGroup()on a per-group cadence (every 100 ms at framesPerGroup=5/50fps); confirmendGroup()is a no-op when there's no current group. The trace shows it firing 50 times between T=0 and T+5 s.
This rules out any test-side mitigation that changes the moq-relay version, the speaker warmup duration, or the listener-side subscribe shape — the fault is below moq-lite, in the QUIC stack's bidi accept path.
Implications for the closure roadmap
- Priority 1 of the closure roadmap is misnamed. It's not
a moq-relay routing race; it's a
:quicpeer-bidi surfacing race. The CI gating (Priority 3) still can't be re-enabled until this is fixed, but the fix is in:quic, not in test code or moq-rs. - Priority 2 (tighten cross-stack assertions) is still blocked by the same flake — replacing soft-passes with hard floors makes 60 % of sweeps red, same as today.
- Step 4 of THIS plan (bump moq-relay version) is moot.
The bug isn't in moq-relay 0.10.x; it's in our QUIC stack's
bidi accept under a 2 s warmup. Bumping moq-relay would not
change this. (Also confirmed: 0.10.25 IS the latest crates.io
release; main HEAD
bdda6bd1does not modifyrecv_subscribe's synchronous lookup either.)
Trace artefact locations
- Per-test relay trace logs:
nestsClient/build/relay-logs-sweep-{1..5}/<methodName>-<seq>-<ts>.log(kept on the branch for the next pickup; small enough to commit if needed). - Per-test JUnit XML with speaker
<system-err>:nestsClient/build/sweep-logs/results-{1..5}/. - Per-sweep gradle log (build + first 50 lines of test output):
nestsClient/build/sweep-logs/sweep-{1..5}.log. - Cross-reference helper:
nestsClient/build/sweep-logs/analyze-sweep.sh(matches by test method name).
Progress log (2026-05-07)
- Step 1 instrumentation landed.
NativeMoqRelayHarnessnow accepts an optionaltestTagand writes the relay subprocess's combined stdout/stderr tonestsClient/build/relay-logs/<methodName>-<seq>-<ts>.logwhenever-DnestsHangInteropTraceRelay=trueis set. The subprocess runs withRUST_LOG=info,moq_relay=trace,moq_lite=trace,moq_native=debugso the per-broadcast subscribe-routing path is observable; quinn / rustls / h3 stay at info to keep the file < ~10 MB per scenario.HangInteropTestandBrowserInteropTestnow expose a JUnit 4TestNamerule and pass the method name intoresetSharedso each scenario's per-method log is easy to locate by name. - Step 4 ruled out —
cargo info moq-relayconfirms0.10.25is the currentcrates.iorelease; no newer minor exists. The plan's Step 4 path (next minor on crates.io) does not apply. The fallback is acargo install --git https://github.com/moq-dev/moq.git --rev <main-head>againstbdda6bd19a37ccdf7f7b66f3d760d8892ea8db59(main HEAD as of investigation) — moq-relay-v0.10.25 tag matches the published crate, so post-0.10.25 work lives only onmain. Also note the upstream moved fromkixelated/moqtomoq-dev/moq; REV'sKIXELATED_MOQ_GIT_REVpredates the move.
Source-level analysis (moq-rs 0.10.25 + main HEAD)
Working through moq-relay/src/connection.rs, moq-lite/src/lite/publisher.rs,
moq-relay/src/cluster.rs and moq-lite/src/model/{origin,broadcast}.rs
the routing path is:
- Speaker connects → relay's
subscriber.start_announceruns (moq-lite/src/lite/subscriber.rs:198-227). It creates theBroadcastDynamicwithdynamic = 1BEFORE callingcluster.publisher.publish_broadcast(...). publish_broadcast pushes to theprimaryorigin. cluster.run_combined()(moq-relay/src/cluster.rs:199-215) is a tokio shovel that loops onprimary.announced()/secondary.announced()and callscombined.publish_broadcast(...). This is the only place primary→combined gets fanned in, andtokio::select!doesn't run in the same task as start_announce, so there's a scheduling gap between primary publish and combined publish.- Listener connects → relay's
publisher.run_announcesreads fromcombined.consume_only(...)and forwards announces over the wire. - Listener subscribes → relay's
publisher.recv_subscribe(moq-lite/src/lite/publisher.rs:217-250) runsself.origin.consume_broadcast(&subscribe.broadcast)synchronously against combined. If the broadcast is in combined's tree, returns Some; otherwise None →Error::NotFound(wire code 13).
Even on main HEAD bdda6bd1 the consume_broadcast lookup is
synchronous (moq-lite/src/lite/publisher.rs:243-250). Commit
8d4a175 only renamed it to get_broadcast — same semantics.
Commit bea9b3a introduced OriginConsumer::wait_for_broadcast
as an async alternative AND added a TODO at
moq-relay/src/web.rs:325: "switch to announced_broadcast
(bounded by the fetch deadline) so freshly-connected subscribers
don't get a spurious 404 before the broadcast has gossiped." That
is upstream's own acknowledgement that the relay's subscribe path
inherits the gossip race, but the fix has not been applied to
recv_subscribe (the path this investigation cares about).
Failure-mode hypotheses (post-source-read)
-
H1 (gossip race): still the leading candidate, but with a more specific mechanism. The Speaker→Relay primary publish and the Relay→Listener combined publish are in different async tasks;
consume_broadcastis synchronous. The listener receives the wire ANNOUNCE only AFTERcombined.publish_broadcast(...), so by the time the listener-issued SUBSCRIBE arrives at the relay'srecv_subscribe, combined SHOULD contain the broadcast. But the smoking-gun trace shows the speaker-side never logs the upstream SUBSCRIBE for the failing path — i.e. either (a) the listener-side ANNOUNCE→SUBSCRIBE wire ordering is being broken by something between the relay's combined update and the wire emit, or (b)consume_broadcastreturns None despite combined knowing about the broadcast. The trace from Step 1 should disambiguate. -
H1b (
subscribe_trackCancel due todynamic == 0):BroadcastConsumer::subscribe_trackreturnsError::NotFound(mapped to wire code 13) if the underlying state'sdynamic == 0(moq-lite/src/model/broadcast.rs:300-302). start_announce incrementsdynamicBEFORE publish_broadcast, so the combined consumer's clone-of-clone-of-broadcast SHOULD always observedynamic >= 1. But it relies onBroadcastConsumer/BroadcastProducersharing the samestate: conducer::Producercell; if the broadcast gotCloned through combined'spublish_broadcast'sbroadcast.clone()call into a position where the state is shared, dynamic stays correct. (Confirmed —BroadcastConsumer::Cloneshares state.) So this isn't the cause. -
H1c (run_combined backlog): the cluster's run_combined loop is a SINGLE-CONSUMER pump from primary.announced(); if the loop is parked on a slow combined publish (rare; publish_broadcast is effectively a tree insert), a follow-up announce sits in the primary queue. But the listener doesn't observe the announce via combined until the pump runs — so this doesn't cause a spurious subscribe-without-announce. It only delays the listener's announce notification.
The trace from Step 1 is needed to pick between H1, H1c, and a
bug we haven't surfaced yet. Step 4 will not yield a fix —
verified by reading post-0.10.25 commits on main; no fix to
recv_subscribe's synchronous lookup has landed. The most
viable upstream fix would be to switch recv_subscribe to
origin.wait_for_broadcast(path).await with a bounded deadline.
Owns: the residual flake that affects four T16 scenarios:
late_join_listener_still_decodes_tail,
packet_loss_1pct_does_not_kill_audio,
long_broadcast_60s_tone_round_trips, and the new
chromium_publisher_*_kotlin_listener_recovers tests in browser-tier.
Blocks: CI gating for :nestsClient:jvmTest -DnestsHangInterop=true
and -DnestsBrowserInterop=true. Re-evaluate the
hang-interop / browser-interop workflow jobs once this is closed.
Cross-refs:
nestsClient/plans/2026-05-07-late-join-catalog-flake-investigation.md(smoking-gun trace + 4 mitigation attempts, 2 of which were net-negative and reverted).nestsClient/plans/2026-05-07-i7-post-reconnect-cliff-investigation.md(same kind of routing issue surfacing across publisher cycles).
What we know
For broadcasts that fail (sample suffixes from the trace:
10d4b6f2…, c75e2648…, f1be27ef…):
-
The Kotlin speaker side logs:
ANNOUNCE inbound prefix='' → emitted Active suffix='<broadcast>'- …then NOTHING for the entire test window.
- Audio publisher's
send()repeatsno inboundSubsat 50 fps until the test times out.
-
The Rust hang-listen side logs:
connected, version=moq-lite-03broadcast announced path=<broadcast>subscribe started id=0 broadcast=<broadcast> track=catalog.json- …then
subscribe error err=remote error: code=0exactly when the speaker tears down at the broadcast-window end (= relay forwardingCancel).
The relay accepts the listener's wire SUBSCRIBE on its downstream connection but never opens an upstream SUBSCRIBE bidi to the speaker for the failing broadcast. The upstream subscribe-pump that's supposed to forward downstream subscribes to the speaker isn't wired up by the time the listener subscribes.
For broadcasts that succeed (same trace, same JVM, different test):
ANNOUNCE inbound prefix='' → emitted Active suffix=<broadcast>
SUBSCRIBE inbound id=0 broadcast=<broadcast> track='catalog.json'
SUBSCRIBE registered id=0 …
openGroupStream subId=0 seq=0
…
All log lines fire; the relay forwards the upstream subscribe within ~1 ms of the downstream subscribe. Failure mode is binary: the relay does or does not forward.
Hypotheses, ranked by next step
H1 — moq-rs 0.10.x bug in Origin::announced() → upstream-pump setup race
Origin::announced().await returns the broadcast as soon as the
speaker's announce lands in the relay's origin map. The relay's
upstream-subscribe pump for that broadcast is set up on a separate
async path. If a downstream listener subscribes before the pump is
fully wired, the SUBSCRIBE accepts on the listener's wire (the
relay has the broadcast in its origin) but never propagates
upstream.
Status: prime suspect; see "smoking gun" in
2026-05-07-late-join-catalog-flake-investigation.md.
H2 — interaction with the --auth-public "" minimal config
The harness boots moq-relay with --auth-public "" to skip JWT
issuance. Production runs with full auth. It's possible the
auth-public path takes a different code path through the relay's
origin/subscribe wiring that's racier than the auth'd path.
Status: plausible; would explain why the flake isn't reported against the production deployment.
H3 — local-only timing race that resolves at higher latency
Loopback (127.0.0.1) has near-zero RTT. The relay's internal async setup may rely on the natural RTT cushion of a real network to sequence upstream-subscribe-pump setup vs. downstream-subscribe acceptance. We bypass that cushion in the test.
Status: less likely (the cliff plan's evidence shows lossy
network actually makes things worse via the serve_group task
pool) but worth ruling out.
Investigation plan
Step 1 — capture relay-side traces
NativeMoqRelayHarness.boot currently launches moq-relay with
RUST_LOG=info. Bump to RUST_LOG=moq_relay=trace,moq_lite=trace
and capture stderr to a per-test tempfile. Cross-reference with
the failing test's hang-listen stdout AND the speaker-side
Log.d("NestTx") traces (already captured in
<system-err> per JUnit XML).
Concretely: in NativeMoqRelayHarness.kt add a --log-stderr
option that the @BeforeTest hook sets to a <test-method>.log
path under nestsClient/build/relay-logs/. The Kotlin side
already has the speaker-side traces; the Rust side is the gap.
What to look for in the failed-broadcast log:
- Was a SUBSCRIBE bidi opened to the speaker for the failing
broadcast suffix? (moq_lite span:
subscribe). - Did the relay's
Origin::publish_broadcastcall complete before the listener's SUBSCRIBE arrived? - Any
track.unused()resolves on the publisher-side track that would explain immediate cancellation?
Step 2 — write a minimal reproducer
If Step 1 shows the bug is independent of our test framework, extract a minimum reproducer:
// reproducer.rs
let mut cmd = std::process::Command::new("moq-relay")
.args(&["--server-bind", "127.0.0.1:0", "--auth-public", "",
"--tls-generate", "localhost"])
.spawn()?;
// Run a moq-lite SPEAKER on one client, a moq-lite LISTENER on
// another, both pointed at the relay. Listener subscribes immediately
// after the speaker announces. Repeat 100×; count how many succeed.
Then strip the SPEAKER's announce timing, the LISTENER's subscribe
timing, the relay's --auth-public flag — bisect to the smallest
form that still reproduces.
Step 3 — file upstream
If Step 1 / 2 confirm a moq-rs bug, file a kixelated/moq issue
with:
- The reproducer.
- Smoking-gun trace pair from our test harness.
- Pin to moq-rs version
0.10.25(pernestsClient/tests/hang-interop/REV). - Cross-link to existing
2026-05-01-quic-stream-cliff-investigation.md's open follow-up #1 (the per-subscriber forward-queue cliff is a sister bug).
Step 4 — try newer moq-relay version
Bump MOQ_RELAY_VERSION in nestsClient/tests/hang-interop/REV
and nestsClient/build.gradle.kts to the next minor release on
crates.io (whatever's current at the time of pickup). Run the 5×
sweep. If the flake disappears, the upstream may have already
fixed it; we can pin past 0.10.x.
Risk: newer moq-relay versions may have wire-format changes
that break our current moq-lite-03 ALPN pin. The browser
harness's @moq/lite 0.2.x client offers moq-lite-04 AND
moq-lite-03, so a newer relay that drops 03 would still
negotiate fine via 04.
Acceptance criteria
- Sweep
for i in 1 2 3 4 5; do ./gradlew :nestsClient:jvmTest --tests HangInteropTest -DnestsHangInterop=true --rerun-tasks; donepasses 5/5. - Browser-tier sweep similarly stable.
- Either: (a) Upstream issue filed with reproducer (if the bug is in moq-rs and we can't fix it locally), OR (b) Local fix applied (e.g. version bump + REV update + Cargo.lock regenerate).
Out of scope
- The
:quicmodule'sMAX_STREAMS_UNIextension fix (d391ae1d) — already shipped, separate concern. - The production-side
framesPerGroupreconciliation (2026-05-07-framespergroup-production-rerun.md) — independent.