docs(nests): refresh T16 results plan — Phase 3 + I4–I11 + sister branches

Brings the cross-stack interop results plan up to date with what's
actually shipped:

- Top-level scenario inventory table covering all 13 scenarios
  (I1–I11 + Rust↔Rust + Phase 4) with their committed branches.
- Phase 3 section documenting I5 hot-swap, I9 packet loss, and
  I10 long broadcast — all landed on this branch.
- I4 stereo (forward + reverse) and I8 SubscribeDrop documented
  under Phase 2.E follow-ups.
- Phase 4 (browser harness) and Phase 5 (browser-only scenarios)
  status pulled out of the bottom-of-file deferred list and
  documented properly.
- Stability section explaining the per-method relay reset + catalog
  retry fix for full-suite ordering flakes.
- CI integration section noting the live hang-interop job.
- Pending follow-ups list (framesPerGroup reconciliation, goAway
  production surface, post-reconnect listener cliff).

No code change.
This commit is contained in:
Claude
2026-05-07 00:38:14 +00:00
parent 706ccda677
commit fd193d6e8b
@@ -1,6 +1,28 @@
# Plan: cross-stack interop test (T16) — Phase 1 + Phase 2 results
# Plan: cross-stack interop test (T16) — Phases 13 results
**Status:** Phase 1 + most of Phase 2 landed. Phases 35 still deferred.
**Status:** Phases 13 (and Phase 2.E follow-ups) landed. Phase 4 (browser
harness) and Phase 5 (browser-only scenarios) are running in parallel
agent branches; not yet merged. CI gating (the `hang-interop` job in
`.github/workflows/build.yml`) is live and Linux-only.
**Scenario inventory (committed in this branch + sister branches):**
| ID | Scenario | Branch | Status |
|---|---|---|---|
| I1 | Amethyst speaker → hang-listen (mono 440 Hz) | this branch | green |
| I2 | Late-join listener decodes tail | this branch | green |
| I3 | Mid-broadcast mute shortens PCM | this branch | green |
| I4 fwd | Stereo 440/660 — Amethyst speaker → hang-listen | merged on main (#2755) + test in this branch | green |
| I4 rev | Stereo — hang-publish → Kotlin listener | this branch | green |
| I5 | Speaker hot-swap mid-broadcast | this branch | green |
| I6 | Multi-listener fan-out (1 speaker, 3 listeners) | `feat/nests-i6-multi-listener` `c28145a0b` | green |
| I7 | Publisher reconnect (Rust hang-publish session cycle) | `feat/nests-i7-publisher-reconnect` `dbfeeb6d5` | green |
| I8 | SubscribeDrop for unknown track | this branch | green |
| I9 | 1% packet loss via udp-loss-shim | this branch | green |
| I10 | 60-second long broadcast | this branch | green |
| I11 | First audio frame is not OpusHead codec-config | this branch | green |
| Rust↔Rust | hang-publish → hang-listen round-trip | this branch | green |
| Phase 4 | Browser (Chromium) listen + publish via Playwright | `feat/nests-browser-interop` (agent in flight) | pending |
## Phase 2 update
@@ -305,29 +327,125 @@ HangInteropTest -DnestsHangInterop=true --rerun-tasks` runs green
on a JVM with the agents-running load + post-merge state. CI
should be stable now.
## Phase 2.E deferred
## Phase 3 — landed
- **I4 stereo** — needs a non-trivial production change in
`MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES` (which
hard-codes mono). Out of scope for these test plumbing changes;
ship as a separate production-side patch.
- **I8 SubscribeDrop**, **I10 long broadcast**, **I12 Goaway** —
next batch of P0 scenarios on the existing harness.
Phase 3 (transport robustness) shipped as part of the same
`HangInteropTest` class to keep the harness wiring single-sourced:
## Phase 3 + 4 + 5 deferred
- **I5 hot-swap** (`speaker_hot_swap_does_not_crash`) — the speaker
re-runs `connectReconnectingSpeaker` mid-broadcast (token rotation
trigger) while a single hang-listen subscriber is attached. The
listener doesn't see a broadcast end; it sees the post-swap
segment. Asserts the post-swap window has audio + the 440 Hz peak.
- **I9 packet loss** (`packet_loss_1pct_does_not_kill_audio`) —
drives the QUIC client through `udp-loss-shim` with
`--loss-rate 0.01`. Asserts the listener still recovers ≥ 60% of
expected samples and the FFT peak remains within ±5 Hz of 440.
- **I10 long broadcast** (`long_broadcast_60s_tone_round_trips`) —
60 s mono tone, no other variations. Asserts the full sample
count and the peak.
Untouched in Phase 1:
Production-side **I12 Goaway** is deferred — the `goAway` API is
not currently surfaced on `MoqLiteNestsSpeaker`; a separate
production patch is required before a test can drive it.
- Phase 3 transport robustness (`udp-loss-shim` body, hot-swap,
long-broadcast).
- Phase 4 browser harness (`nestsClient-browser-interop/` directory,
Playwright driver).
- Phase 5 browser-only scenarios.
## Phase 2.E follow-ups — landed
CI integration (the GitHub Actions workflow updates the spec
shows) is also pending — until Phase 2 lands a real test, there's
nothing in `-DnestsHangInterop=true` worth gating CI on except
the smoke test.
- **I4 stereo (forward)** — production change merged via PR #2755
(`refactor(nests): per-stream channel count + AudioBroadcastConfig`).
`MoqLiteHangCatalog` now derives the catalog JSON from the
configured channel count instead of hard-coding mono.
Test: `amethyst_speaker_to_hang_listener_stereo_440_660` —
drives `SineWaveAudioCapture` with `channelCount = 2,
freqHzPerChannel = intArrayOf(440, 660)`, asserts each channel's
FFT peak independently via `assertFftPeakPerChannel`.
- **I4 stereo (reverse)** — `rust_hang_publish_stereo_to_kotlin_listener_440_660`.
hang-publish gained `--channels 2 --freq-hz-l 440 --freq-hz-r 660`
(per-channel sine generator with separate phase accumulators) and
the JVM listener uses `AudioFormat(channelCount = 2)` end-to-end.
- **I8 SubscribeDrop** — `subscribe_drop_for_unknown_track`. Asks
hang-listen to subscribe to a track that the catalog doesn't
publish; expects a clean Drop frame (non-zero exit code, no panic).
## Phase 4 — browser harness
Running in agent worktree (`feat/nests-browser-interop`). Adds:
- `nestsClient-browser-interop/` — TypeScript + Vite project shipping
the upstream `@kixelated/moq` and `@kixelated/hang-wasm` consumers/
publishers, bundled into static `listen.html` / `publish.html`
pages.
- `interopBuildBrowserHarness` Gradle task — runs `bun install` +
`bun build` over the directory; cached against source changes.
- `interopInstallPlaywrightChromium` — `bun playwright install
chromium` into a host cache directory; reused across runs.
- `BrowserInteropTest` — Playwright-driven JUnit scenarios
(`amethyst_speaker_to_chromium_listener`, etc.). Gated behind
`-DnestsBrowserInterop=true` (independent of `nestsHangInterop`).
Branch will land via separate PR when the agent reports green.
## Phase 5 — browser-only scenarios
To follow Phase 4. Plan covers two-browser fan-out (multiple
Chromium listeners on one Amethyst speaker), browser publisher →
Kotlin listener, and the catalog negotiation differences between
`@kixelated/hang-wasm` and Amethyst's catalog publisher.
## Test stability notes
The 11-scenario `HangInteropTest` shares a single `NativeMoqRelayHarness`
across the suite. Two stability fixes landed for full-suite runs:
1. **Per-method relay reset** (`706ccda67`) — `@BeforeTest gate()`
calls `NativeMoqRelayHarness.resetShared()` before each scenario
so accumulated relay-side state (forward queues, MAX_STREAMS_UNI
credit, attached subscriber list) doesn't leak between scenarios.
Adds ~500 ms × 11 ≈ 5.5 s to a full suite run, well within the
CI budget.
2. **Catalog read retry** in hang-listen (`f9be7889a`) — bumped
per-attempt timeout 500 ms → 2 s, with up to 3 attempts, total
worst-case wallclock 6 s. Each retry creates a fresh
`subscribe_track(catalog.json)` bidi which re-fires the speaker's
`setOnNewSubscriber` hook.
I3 mute-window lower bound was also relaxed (2.5 s → 1.8 s) since
the mute manifests as a sample deficit and the deficit varies with
relay-side timing under load.
## CI integration
`.github/workflows/build.yml` now has a `hang-interop` job:
- Linux-only (Rust toolchain + libopus available out of the box)
- Caches `~/.cargo` and `~/.cache/amethyst-nests-interop/` between
runs so `cargo install moq-relay` and the workspace `cargo build`
are warm on the second run
- Runs `:nestsClient:jvmTest -DnestsHangInterop=true`
- Depends on the existing `lint` job (only runs after spotless +
ktlint pass)
Browser interop will land its own job once `feat/nests-browser-interop`
merges; that job adds `bun install` + Playwright Chromium caching.
## Pending follow-ups
Tracked in branch comments / kdoc but not blocking:
- **Production `framesPerGroup` reconciliation** — see the I1
section above. The interop tests pin 5; production code keeps
50. A maintainer with both rigs (the `--auth-public` minimal
relay AND the nostrnests production deployment) needs to vary
`framesPerGroup` per environment or pick a value that survives
both cliffs.
- **Production `goAway` surface on `MoqLiteNestsSpeaker`** —
required before I12 can ship as a test.
- **Post-reconnect listener cliff** (documented in the I7 commit
message) — moq-relay 0.10.x truncates the second cycle of a
hang-publish session-cycle reconnect at ~1.0 s out of ~2.5 s.
May be listener-side `MAX_STREAMS_UNI` credit or relay-side
per-broadcast forward queue. Worth a targeted bug if reproduced
outside the harness.
## Files
@@ -351,9 +469,16 @@ nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/
└── interop/native/
├── NativeMoqRelayHarness.kt # boots moq-relay subprocess
├── NativeMoqRelayHarnessSmokeTest.kt
├── HangInteropTest.kt # I1 + I2 + I3 + I11 + Rust↔Rust
├── HangInteropTest.kt # I1, I2, I3, I4 fwd+rev, I5,
│ # I8, I9, I10, I11, Rust↔Rust
└── KotlinSpeakerKotlinListenerThroughNativeRelayTest.kt
# diagnostic, gated separately
# In sister branches (not yet merged):
# feat/nests-i6-multi-listener -> HangInteropMultiListenerTest.kt (I6)
# feat/nests-i7-publisher-reconnect -> HangInteropReverseTest.kt (I7)
# feat/nests-browser-interop -> nestsClient-browser-interop/ +
# BrowserInteropTest.kt (Phase 4)
nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md # this file
```