Expands `AudioLatencyComparisonTest` from pure inter-arrival jitter
into a real one-way latency measurement against the same
`hang-publish` Rust reference, AND adds a loss-path variant gated
through `udp-loss-shim`.
`hang-publish` (Rust sidecar):
- new `--log-send-times` flag. When set, the publisher prints
`SEND frame=<N> send_t_us=<UNIX_EPOCH_MICROS>` to stdout for
every frame, captured with `SystemTime::now()` immediately
before `frame.encode(group)`. Lock + flush per line because a
piped Rust stdout is fully-buffered by default and the JVM-side
parser needs a live stream. Default off so existing scenarios
(the I7 reconnect test in particular) keep their stderr-only
RUST_LOG output untouched.
Kotlin test:
- `TimestampingOpusEncoder` wraps `JvmOpusEncoder` and stamps an
`Instant.now()` epoch-micros value per `encode(...)` call into a
shared map keyed by a monotonic frame counter. encode runs
immediately before `publisher.send` inside
`NestMoqLiteBroadcaster`, so the captured timestamp is the
closest cross-stack anchor we have to "moment the frame entered
the publisher's outbound buffer" — same anchor the Rust side
uses.
- One-way latency = arrival epoch-micros − send epoch-micros per
matched frame. Both sides read `CLOCK_REALTIME` on linux/macOS,
so values can be compared directly without a sync handshake when
the two processes share a host.
- Frame matching uses `MoqObject.objectId` directly. The listener
layer already synthesises objectId as a per-SUBSCRIPTION
monotonic counter, so it equals the publisher's absolute frame
index; a previous draft of this test did `groupId * 5 + objectId`
and silently aligned against the wrong frames, producing
negative latencies.
- New `under_5pct_packet_loss_pacing_and_one_way_latency` scenario.
Spawns TWO `udp-loss-shim` instances (one per publisher: the
shim latches a single client on first datagram, so a shared shim
would silently swallow the second publisher's traffic), each
forwarding 1:1 to the moq-relay's UDP port modulo a 5 %
bidirectional drop rate. The listener stays on the direct path
so any latency growth comes from publisher-side retransmit /
ack feedback, not listener-side loss.
- Both Kotlin and Rust pinned to `FRAMES_PER_GROUP = 5` (Rust's
default; Kotlin's default is 50). Matched so the per-group
uni-stream open/close cost is the same on both sides — the
comparison is about implementation, not about which stack picked
which group size.
Observed (10 s window, localhost, MacBook Pro M2):
===== Audio publisher comparison: clean (loss=0%) =====
Kotlin speaker ttf=137 ms inter-arrival p50/95/99/max=20.17/20.67/20.88/21.16 ms
one-way p50/95/99/max=80.60/81.31/81.74/83.91 ms
Rust hang-publish ttf=325 ms inter-arrival p50/95/99/max=20.00/21.18/21.49/21.95 ms
one-way p50/95/99/max=100.32/101.45/102.04/168.58 ms
===== Audio publisher comparison: loss-5pct (loss=5%) =====
Kotlin speaker one-way p50/95/99/max=80.50/81.55/82.31/86.43 ms
Rust hang-publish one-way p50/95/99/max=100.44/119.88/122.16/158.98 ms
Reading: Kotlin matches Rust on the clean path within ~20 ms
baseline (and actually sits below it), and absorbs 5 % loss with
only +0.6 ms p99 growth vs Rust's +20 ms. The 80-100 ms baseline
is moq-lite's group-buffer floor with 5-frames/group — protocol,
not stack.
Asserts: each side delivers >= 80 % of expected frames on the clean
path, >= 60 % under loss, and clean-path median inter-arrival sits
in [15, 35] ms. Tail percentiles are printed, not gated.
Gated by `-DnestsHangInterop=true`; both sidecars (hang-publish,
udp-loss-shim) come from `interopBuildSidecars`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-stack interop tests
Manually invoked. Not part of
build.yml— see "Why not in CI" below.
Two suites that drive the production Amethyst Kotlin nests stack against external reference implementations of moq-lite-03:
| Suite | Path | Runs | What it asserts |
|---|---|---|---|
HangInteropTest |
nestsClient/tests/hang-interop/ |
Rust hang-listen / hang-publish (kixelated reference) ↔ Amethyst Kotlin via a real moq-relay 0.10.x subprocess |
Wire-byte capture, FFT peaks on decoded PCM, sample-count floors, mute / hot-swap / packet-loss / late-join / 60 s long broadcast / multi-listener fan-out. |
BrowserInteropTest |
nestsClient/tests/browser-interop/ |
Headless Chromium running @moq/lite + @moq/hang via Playwright ↔ Amethyst Kotlin (forward + reverse) |
Same scenario family as hang-interop, plus WebCodecs AudioDecoder / AudioEncoder correctness, ALPN negotiation, browser-side reconnect. |
Coverage matrix: nestsClient/plans/2026-05-06-cross-stack-interop-test-gap-matrix.md
When to run
Run both suites locally before merging any change that touches:
quartz/.../nip53(audio rooms event types, catalog wire format)nestsClient/src/.../moq/lite/(moq-lite session, publisher/subscriber, codec)nestsClient/src/.../audio/(capture, encoder/decoder, broadcaster, player)nestsClient/src/.../MoqLiteNestsSpeaker.kt/MoqLiteNestsListener.ktnestsClient/src/.../ReconnectingNests*.kt:quic(WebTransport, packet header protection, key updates, stream demux)- The hang/browser sidecars themselves
Skip if the change is documentation, UI-only, build-script-only, or otherwise can't affect wire bytes / decoded audio.
Quick start
# Hang-tier (Rust ↔ Kotlin via real moq-relay subprocess)
./gradlew :nestsClient:jvmTest \
--tests "com.vitorpamplona.nestsclient.interop.native.HangInteropTest" \
-DnestsHangInterop=true
# Browser-tier (Chromium ↔ Kotlin) — also boots the moq-relay
./gradlew :nestsClient:jvmTest \
--tests "com.vitorpamplona.nestsclient.interop.native.BrowserInteropTest" \
-DnestsHangInterop=true \
-DnestsBrowserInterop=true
# Both together
./gradlew :nestsClient:jvmTest \
--tests "com.vitorpamplona.nestsclient.interop.native.HangInteropTest" \
--tests "com.vitorpamplona.nestsclient.interop.native.BrowserInteropTest" \
-DnestsHangInterop=true -DnestsBrowserInterop=true
The opt-in flags (-DnestsHangInterop=true / -DnestsBrowserInterop=true)
also act as JUnit Assume gates — without them, the suites mark every
scenario skipped rather than failing on missing prerequisites.
Prerequisites
| Tool | Why | Install |
|---|---|---|
| Rust ≥ 1.95 | moq-relay 0.10.25's transitive dep constant_time_eq 0.4.3 requires it |
rustup install 1.95 && rustup default 1.95 |
cargo on PATH |
Builds the hang-interop sidecars + cargo installs moq-relay, moq-token-cli |
Comes with rustup |
bun ≥ 1.3.x (browser only) |
Bundles the Chromium harness + drives Playwright | curl -fsSL https://bun.sh/install | bash |
| Playwright Chromium (browser only) | Headless Chromium for @moq/lite + @moq/hang |
Auto-installed by interopInstallPlaywrightChromium Gradle task |
The Gradle build automates everything from there. First run installs and caches:
moq-relay0.10.25 +moq-token-cli0.5.23 →~/.cache/amethyst-nests-interop/hang-interop-cargo/bin/- Sidecar release binaries →
nestsClient/tests/hang-interop/target/release/ - bun's
node_modules+dist/→nestsClient/tests/browser-interop/ - Playwright Chromium →
~/.cache/ms-playwright/
Cold first run: ~10 min hang, ~13 min browser. Cached: ~3-4 min hang, ~5-7 min browser.
Configuration knobs
| Flag | Default | Purpose |
|---|---|---|
-DnestsHangInterop=true |
unset (skip) | Enable HangInteropTest. |
-DnestsBrowserInterop=true |
unset (skip) | Enable BrowserInteropTest. Implies nestsHangInterop (the browser harness boots the same moq-relay subprocess). |
-DnestsHangInteropTraceRelay=true |
unset | Per-test moq-relay trace capture. Writes the relay subprocess's combined stdout/stderr (with RUST_LOG=info,moq_relay=trace,moq_lite=trace,moq_native=debug) to nestsClient/build/relay-logs/<methodName>-<seq>-<ts>.log. Use for debugging suite flakes. |
-DnestsHangInteropDiagnostic=true |
unset | Runs the Kotlin↔Kotlin variant gated separately (KotlinSpeakerKotlinListenerThroughNativeRelayTest) — useful to bisect wire-format bugs without involving Rust or Chromium. |
BUN_BIN, NPX_BIN (env) |
autodetected | Override the bun / npx binary path if your install lives outside the agent default (/root/.bun/bin/bun). |
Known limitations
chromium_listener_speaker_hot_swap_does_not_crashsoft-passes its PCM assertion — Chromium's@moq/lite0.2.x doesn't re-attach acrossActive::Ended → Active, so the browser captures only ~100 ms post-swap. T12 (group sequence carry across hot-swaps) is hard-asserted by the hang-tier counterpart.framesPerGroupis pinned to5in the test scenarios. Production defaults to50. The two haven't been reconciled against a single multi-rig benchmark — seeframespergroup-reconciliation.- I7 cycle 2 truncation:
moq-relay0.10.x truncates the second cycle of a publisher reconnect at ~1.0 s out of ~2.5 s. Tests assert≥ 2.5 sfloor which the truncated cycle still clears; a future upstream fix may let us tighten further. - I12 GOAWAY: not applicable to moq-lite-03; only the IETF moq-transport target (currently disabled) would exercise it.
Full open-issues list:
2026-05-06-cross-stack-interop-test-results.md § Pending follow-ups.
Debugging a flaking scenario
- Re-run the failing scenario in isolation (no
--testsfilter races):./gradlew :nestsClient:jvmTest \ --tests "*HangInteropTest.late_join_listener_still_decodes_tail" \ -DnestsHangInterop=true \ -DnestsHangInteropTraceRelay=true \ --rerun-tasks - Inspect
nestsClient/build/relay-logs/<methodName>-*.logforsubscribed started/encoding self=Subscribe/decoded result=SubscribeOk/subscribed cancelledevents. - Cross-reference with the speaker-side
Log.d("NestTx")lines captured in JUnit XML's<system-err>(nestsClient/build/test-results/jvmTest/TEST-*.xml) by timestamp. - The fastest diagnostic loop bypasses Browser/Chromium entirely — use
the diagnostic test:
./gradlew :nestsClient:jvmTest \ --tests "*KotlinSpeakerKotlinListenerThroughNativeRelayTest" \ -DnestsHangInteropDiagnostic=true
Sample pre/post-merge trace pair for the previously-flaking late-join
scenario lives at
plans/artefacts/2026-05-07-routing-race-disproven/
Why not in CI
Both suites are slow on a cold cache (~10–13 min each), and even on a warm cache the browser tier dominates the critical path at ~5-7 min. Running them on every PR doubles CI time for changes that don't touch audio / MoQ / QUIC.
History:
21947bc5re-added both jobs after the T16 closure roadmap closed Priority 1 (:quicpost-handshake bidi-drop fixed viaorigin/mainmerge) and Priority 2 (assertion hardening). 10/10 sweep × 22 tests = 220/220 hard-pass on the merged branch.- A subsequent maintainer review judged the wallclock cost too high for the change-pattern (most PRs don't touch the relevant code) and removed the jobs.
The suites still run locally per the rules above. If a PR DOES touch audio / MoQ / QUIC code paths and the author hasn't run them, ask in review.
Files
hang-interop/REV— pinned upstream versions (MOQ_RELAY_VERSION,MOQ_TOKEN_CLI_VERSION,HANG_VERSION,MOQ_LITE_VERSION,MOQ_NATIVE_VERSION). Bump deliberately.hang-interop/Cargo.toml+hang-{listen,publish}/,udp-loss-shim/— Rust sidecar workspace.browser-interop/package.json+src/+playwright.config.ts— bun + Playwright harness.nestsClient/src/jvmTest/kotlin/.../interop/native/:NativeMoqRelayHarness.kt— boots the relay subprocess + captures per-test trace.HangInteropTest.kt,HangInteropReverseTest.kt,HangInteropMultiListenerTest.kt,KotlinSpeakerKotlinListenerThroughNativeRelayTest.kt— hang-tier scenarios.BrowserInteropTest.kt,PlaywrightDriver.kt— browser-tier.
See also
2026-05-06-cross-stack-interop-test.md— original spec / Definition of Done.2026-05-07-t16-closure-roadmap.md— closure roadmap (Priorities 1, 2 closed; Priority 3 deferred).2026-05-07-cross-stack-interop-ci-gating.md— the deferred CI-gating plan (kept around in case the wallclock cost calculus changes).