Two production-path bugs surfaced during a full interop sweep
(`./gradlew :nestsClient:jvmTest -DnestsInterop=true` +
`-DnestsHangInterop=true`) plus the toolchain / harness friction that
was hiding them.
Production fixes:
- ReconnectingNestsSpeaker's hot-swap path opens publishers via
`openPublisherForHotSwap` instead of `startBroadcasting`, so the
underlying `MoqLiteNestsSpeaker`'s state machine never made the
Connected -> Broadcasting transition. The reconnect wrapper mirrors
that state, so callers waiting on Broadcasting (the VM,
`connectReconnectingNestsSpeaker` interop tests) hung on Connected
forever. Adds `HotSwappablePublisherSource.reportBroadcasting(isMuted)`,
implemented in `MoqLiteNestsSpeaker`, and the hot-swap pump now
calls it each iteration so a JWT-refreshed session re-enters
Broadcasting too.
- The stale-group filter on listener reconnect assumes monotonic
group lineage across publisher session-restarts (true for
ReconnectingNestsSpeaker, which seeds each new publisher with the
prior `nextSequence`; false for external publishers like
kixelated's hang-publish reference, which mints a fresh state on
every reconnect and restarts at 0). Without compensation, the
watermark from a session-1 max ~18 dropped the entire post-restart
stream from session 2. The collect now resets the watermark on the
first object of a new subscription when its group id arrives well
below the current watermark — a publisher-restart signal that a
relay-cache replay (which carries exactly the prior max) wouldn't
produce.
Test harness fixes — these are what blocked the suites from running
at all on a clean Apple-Silicon box:
- NostrNestsHarness: the cloned `docker-compose-moq.yml` declares no
`depends_on`, so on a cold `up -d` moq-relay raced moq-auth's Node
boot, got `Connection refused` on its JWKS GET, exited with no
retry, and every later QUIC handshake failed with "read loop exited
(socket closed or peer closed)". Gate on moq-auth's /health first,
then idempotent `up -d moq-relay` so the relay always boots against
a live auth sidecar.
- nestsClient/build.gradle.kts: cargo builds inside the hang-interop
task panic under CMake 4.x because `audiopus_sys` / rustls' aws-lc-sys
ship a `CMakeLists.txt` that predates CMake 4's minimum-version
floor. Set `CMAKE_POLICY_VERSION_MINIMUM=3.5` on each cargo Exec.
- JvmOpusEncoder + the hang-interop Test task: opus-java 1.1.1's
bundled `natives/darwin/libopus.dylib` is x86_64-only, so its
in-jar loader reports unsupported on Apple Silicon. Probe a small
set of canonical system libopus locations (`brew install opus` on
macOS, distro paths on linux) and `System.load` by absolute path so
the symbols are in the process when JNA's lazy `Opus.INSTANCE`
init falls back to RTLD_DEFAULT. Gradle also threads
`/opt/homebrew/opt/opus/lib` onto `jna.library.path` for
completeness; both paths are no-ops where the in-jar binary
already loads.
After these:
- :nestsClient:jvmTest -DnestsInterop=true — 312/312, 0 fail
- :nestsClient:jvmTest -DnestsHangInterop=true — 312/312, 0 fail
- quic/interop/run-matrix.sh -s aioquic — 19 passed,
0 failed, 3 unsupported (E/V2/CM are documented amethyst gaps).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`NativeMoqRelayHarness` gains a `testTag` parameter on `shared` /
`resetShared` and writes the relay subprocess's combined stdout/stderr
to `nestsClient/build/relay-logs/<tag>-<seq>-<ts>.log` whenever
`-DnestsHangInteropTraceRelay=true` is set. The subprocess runs with
`RUST_LOG=info,moq_relay=trace,moq_lite=trace,moq_native=debug` so the
per-broadcast subscribe-routing path investigated in
`nestsClient/plans/2026-05-07-moq-relay-routing-investigation.md`
(Step 1) becomes visible.
`HangInteropTest` and `BrowserInteropTest` now expose a JUnit 4
`TestName` rule and forward the method name into `resetShared`, so
each scenario's per-method log is easy to locate by name when
cross-referencing with the speaker-side `Log.d("NestTx")` lines
already captured in JUnit XML's `<system-err>`.
Mirrors the existing nestsClient/tests/hang-interop/ layout — all
T16 cross-stack interop test infrastructure (Rust sidecars + bun
browser harness) now lives under nestsClient/tests/.
Updates:
- nestsClient/build.gradle.kts: browserInteropDir path.
- PlaywrightDriver.kt + BrowserInteropTest.kt: kdoc comments.
- All plan docs in nestsClient/plans/ that referenced the old
path.
Compiles clean post-move. No CI changes (those jobs are
intentionally not wired per 2026-05-07-cross-stack-interop-ci-gating.md;
when re-added they'll reference the new path).
Lands the bun + Playwright + headless Chromium harness for the
T16 cross-stack interop suite, parallel to the existing Rust
hang-listen tier. New top-level `nestsClient-browser-interop/`
directory with `@moq/lite` + `@moq/hang` 0.2.x pinned, a bun
static + WebSocket back-channel server, and a Playwright runner
that opens `listen.html` against the same `NativeMoqRelayHarness`
moq-relay subprocess the Rust scenarios use.
Kotlin side: `PlaywrightDriver` shells out to `bun x playwright
test`, forwards the relay URL + leaf-cert SHA-256 (captured via
a custom `CertCapturingValidator` during the speaker's QUIC
handshake), and reads back Float32 LE PCM frames from a tempfile
the bun WS server appends to. `BrowserInteropTest` ships I1
forward — Amethyst Kotlin speaker → Chromium `@moq/lite`
listener, asserting FFT 440 Hz on the captured tail.
Why pin via `serverCertificateHashes` instead of
`--ignore-certificate-errors`: Chromium's flag does NOT bypass
QUIC cert validation (crbug.com/1190655). `serverCertificateHashes`
is the supported path; moq-relay's `--tls-generate` produces a
14-day ECDSA P-256 cert that satisfies the spec.
Two Gradle tasks added: `interopBuildBrowserHarness` (bun
install + bun build → dist/) and `interopInstallPlaywrightChromium`
(skipped when `PLAYWRIGHT_BROWSERS_PATH` already has a chromium
build, as on the agent runner).
Verification:
- `./gradlew :nestsClient:jvmTest --tests
com.vitorpamplona.nestsclient.interop.native.BrowserInteropTest
-DnestsHangInterop=true -DnestsBrowserInterop=true` green.
- I1 forward asserts ≥ 1 s of decoded PCM with 440 Hz FFT peak.
Looser sample-count bound than the hang-tier I1 because
Chromium cold-launch + WebTransport handshake (3–5 s) + the
publisher's `framesPerGroup = 5` per-subscriber cache cliff
means the page captures only the broadcast tail.
Phase 4.C (I2/I3/I4/I13/I14/I15) and 4.D (CI) are separate
follow-up commits per the plan's per-scenario commit guidance.
See: nestsClient/plans/2026-05-06-phase4-browser-harness.md
https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
Per maintainer request: the Rust sidecar workspace lives
under the module that owns it, parallel to the upcoming
nestsClient-browser-interop/ harness. The cargo workspace,
Gradle wiring, gitignore, CI YAML, plan docs, and harness
kdoc are all updated. cargo build --release still compiles;
HangInteropTest.amethyst_speaker_to_hang_listener_stereo_440_660
green at the new path.
Note: the live Phase 4 browser-harness agent (worktree
agent-a97a6be483ecee618) was branched from before this move
and references `cli/hang-interop` in its plan-doc imports.
It will need to rebase onto this commit before it pushes;
no code-level conflicts since the agent works exclusively
in nestsClient-browser-interop/ + a new
BrowserInteropTest.kt.
https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
Adds three more Phase 2 cross-stack scenarios on the existing
HangInteropTest harness:
- **I11** (`first_audio_frame_is_not_opus_codec_config`): hang-listen
gains `--dump-first-frame <path>` that writes the first audio
frame's post-Container-Legacy-strip codec payload. The test
asserts those bytes don't begin with `OpusHead` magic — catches
the T8 regression where Android's MediaCodec leaks
BUFFER_FLAG_CODEC_CONFIG bytes as a normal audio frame.
- **I2** (`late_join_listener_still_decodes_tail`): listener
attaches 2 s into a 5 s broadcast, asserts ≥1.5 s of decoded
audio with the 440 Hz peak still recoverable.
- **I3** (`mid_broadcast_mute_shortens_decoded_pcm`): speaker
mutes for 1 s mid-broadcast. Amethyst's broadcaster FINs the
open uni stream rather than pushing zeros, so the mute shows
up as a sample-count deficit (~3 s decoded for 4 s wallclock),
not an embedded silence window. Asserts the deficit is in
the right ballpark (a regression that pushed zeros instead
would produce normal-length PCM and fail this).
`runSpeakerToHangListen` extracted as a per-scenario helper so
the four Kotlin-speaker scenarios share setup. Each scenario
anchors `QuicWebTransportFactory.parentScope` to its per-test
pumpScope to avoid leaking UDP sockets / coroutine trees.
`KotlinSpeakerKotlinListenerThroughNativeRelayTest` (Phase 2's
Kotlin↔Kotlin diagnostic) now opts in via a separate
`-DnestsHangInteropDiagnostic=true` gate. It flakes when run
alongside HangInteropTest's 5 native-subprocess scenarios in
the same JVM (relay-side state accumulation), and its only
purpose is wire-format bisects — no need to ship it under the
default `-DnestsHangInterop` flag.
Verified: 3 sequential `./gradlew :nestsClient:jvmTest
-DnestsHangInterop=true --rerun-tasks` runs in a row green.
I4 (stereo) deferred — needs a non-trivial production-side
catalog change (`MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES`
hard-codes mono); out of scope for these test plumbing changes.
https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
Lands the test-side audio codec + the first end-to-end interop
scenario through the harness:
- JvmOpusEncoder / JvmOpusDecoder via club.minnced:opus-java 1.1.1
(JNA bindings + bundled libopus.so / .dylib / .dll natives).
Verified by JvmOpusRoundTripTest — sine 440 Hz survives encode →
decode with FFT peak preserved + ZCR within 5%.
- SineWaveAudioCapture now paces to real time (20 ms / frame)
rather than running open-loop. Mirrors how a real microphone
source blocks on hardware; without it the broadcaster floods
the relay at compute speed.
- HangInteropTest.rust_hang_publish_to_rust_hang_listener_round_trip_440
drives hang-publish + hang-listen as subprocesses through the
harness's moq-relay and asserts FFT peak / ZCR / sample-count
on the decoded PCM. Verified green on Linux x86_64.
- hang-publish gains --track-name (default "audio/data" matching
Amethyst's MoqLiteNestsListener.AUDIO_TRACK) and decouples
--relay-url from --broadcast so the URL path can be the
namespace and the broadcast can be a relative announce suffix.
- hang-listen's tail "cancelled" error is treated as EOF after
any frames have been collected, so a clean publisher shutdown
no longer surfaces as exit=1.
The forward-direction I1 scenario (Amethyst Kotlin speaker → hang
listener) is still gated by an open Amethyst-side wire issue: the
audio uni stream delivers Group control headers but no frame
payloads. Documented in
nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md
with concrete pickup steps for a follow-up session.
https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
Lands the load-bearing infra for the hang/Rust cross-stack interop
test plan (nestsClient/plans/2026-05-06-cross-stack-interop-test.md):
the cargo workspace, Gradle wiring to install moq-relay /
moq-token-cli + build sidecars, the Kotlin harness that boots a
real moq-relay subprocess on a random ephemeral UDP port with
self-signed TLS and unrestricted public auth, plus the signal-domain
PCM assertion library and deterministic sine-wave AudioCapture that
Phase 2 scenarios will assert against.
Phase-1 deviations from the spec are summarised in
nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md
— notably: --auth-public "" instead of the JWT minter (no
security-relevant difference for wire-format scenarios), --tls-generate
instead of in-Kotlin cert generation, cargo-install of upstream
binaries instead of an embedded kixelated/moq checkout, and
hang-listen / hang-publish / udp-loss-shim shipped as Phase-1 stubs
that compile + accept --flags but do nothing. Phase 2 fills those
in (and adds JVM Opus encoder/decoder).
Verified green via:
./gradlew :nestsClient:jvmTest \
--tests "com.vitorpamplona.nestsclient.interop.native.*" \
--tests "com.vitorpamplona.nestsclient.audio.PcmAssertionsTest" \
-DnestsHangInterop=true
Default mode (no flag) cleanly skips the harness-dependent test.
https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
Four progressively-narrower JVM tests that drive the production
connectNestsSpeaker / connectNestsListener / OkHttpNestsClient /
QuicWebTransportFactory / NestMoqLiteBroadcaster code paths against
the real moq.nostrnests.com + moq-auth.nostrnests.com infrastructure
(no Docker harness, no Nostr events) so we can isolate why audio is
not flowing between two real users:
1. auth_minting_works_for_publish_and_listen_paths
2. same_user_speaker_and_listener_round_trip
3. two_users_speaker_publishes_listener_subscribes
4. sustained_real_time_cadence_two_users (~2s @ 20ms cadence)
Skipped by default; opt in with -DnestsProd=true and (optionally)
override URLs via -DnestsProdEndpoint=... / -DnestsProdAuth=...
Adds a "bring your own stack" path to NostrNestsHarness so the interop
tests can run without a Docker daemon. With `-DnestsInteropExternal=true`:
- Skip the `docker compose up` that builds + boots
moq-auth + moq-relay
- Port-probe + /health-check the same 8090 / 4443 endpoints the
Docker path uses
- close() is a no-op — the caller owns the lifecycle
Useful in two situations:
1. Sandboxes / restricted CI without a Docker daemon (just run
`cargo install moq-relay` + `node moq-auth/dist/index.js`
yourself, then run gradle with the flag)
2. Fast iteration — the Docker path takes ~30 s to compile
moq-relay on first run; with this, you keep both processes
alive across many test invocations
Forward `nestsInteropExternal` (and `nestsInteropDebug`,
`nestsInteropMoqRev`) through the Test task so the property reaches
test workers; without that, the gate stays off in the worker JVM.
Also: `assertSpeakerReached` / `assertListenerReached` now log a "✘"
checkpoint with the rich state description before calling fail().
JUnit captures the assertion message in a separate section, but the
"standard output" tab is what most people read first when scanning
for a cause — so the chained-cause string now lands in both places.
Without this, `-DnestsInterop=true` on the Gradle command line was
only set on the Gradle JVM, not on the test executor JVM that
NostrNestsHarness.isEnabled() reads via System.getProperty. Result:
every interop test silently skipped no matter how the run was
invoked.
Also forwards `nestsInteropRev` (used by the harness to pin the
nostrnests revision in the cache).
Verified with `./gradlew :nestsClient:jvmTest --tests
NostrNestsAuthInteropTest -DnestsInterop=true` that the harness
now actually runs (the run only fails inside the test because the
sandbox blocks outbound traffic to GHCR / Docker Hub — both
registries return 503 at the auth-token endpoint, so we can't
pull strfry / build moq images here. On a network-enabled host
the harness will bring up the stack normally).
Create the new :quic Gradle module (KMP, api(project(":quartz"))) and migrate
the QUIC varint codec out of :nestsClient where it was incidentally living.
Add the connection-ID, packet-number-space, and UDP socket primitives that
the rest of the QUIC client will build on.
Layer-by-layer plan in docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md.
- New :quic module wired into settings.gradle, with commonMain + jvmAndroid
source sets mirroring :quartz's structure.
- Varint moves from com.vitorpamplona.nestsclient.moq to com.vitorpamplona.quic;
MoqBuffer/MoqCodec updated to import the new path.
- ConnectionId enforces the 0..20 byte length range and ships a randomizer
backed by Quartz's RandomInstance.
- PacketNumberSpaceState tracks per-space outbound allocation + largest-received
tracking, and implements the RFC 9000 §A.3 truncated-PN decode formula plus
the §17.1 minimum encode-length picker.
- UdpSocket is an expect class with a connected DatagramChannel actual on
jvmAndroid using Dispatchers.IO (no Selector — one socket per connection).
All 12 tests pass on jvmTest. RFC 9000 §A.1 varint vectors and §A.3 truncated-PN
vector match bit-for-bit.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Phase 3b-2 attempt. The honest version: I do not have network access
to verify Kwik's current Maven coordinates or test against a live
nests server, so committing speculative QUIC + Extended CONNECT code
risks breaking the build for everyone else without giving us
audible-audio-on-device confidence in return.
What I did instead: expanded the docstring on KwikWebTransportFactory
into a full integration playbook that the next person picking this
up can execute directly. It covers:
- Maven coordinates to verify (`tech.kwik:kwik-core` + `tech.kwik:flupke`,
with `net.luminis.quic:kwik` as the legacy fallback group).
- The exact `gradle/libs.versions.toml` + `nestsClient/build.gradle.kts`
edits to drop in once coords are confirmed.
- Step-by-step handshake sequence with the right HTTP/3 setting IDs:
* SETTINGS_ENABLE_CONNECT_PROTOCOL=1 (RFC 8441, 0x08)
* SETTINGS_ENABLE_WEBTRANSPORT=1 (WT-H3 draft, 0x2b603742)
* SETTINGS_H3_DATAGRAM=1 (RFC 9297, 0x33)
- Extended CONNECT pseudo-header set, including the legacy
`sec-webtransport-http3-draft02 = 1` for older server compat.
- WebTransport stream-type prefix bytes (0x41 for client bidi, 0x54
for client uni) + WT capsule type for graceful close (0x2843).
- Test strategy: validate against local nests-rs first, then the
production `nostrnests.com`.
- Open questions (Flupke Extended CONNECT maturity, draft churn,
dev-only self-signed-cert support, Android API surface).
Behavior unchanged: connect() still throws WebTransportException
with Kind.NotImplemented and a message pointing at this docstring.
When the real implementation lands, no upstream caller needs to
change — the AudioRoomConnectionViewModel from Phase 3d-3 will
auto-light up the connection chip from "Failed: NotImplemented" to
"Connected" and start producing audio through the Phase 3d-1
pipeline that's already wired.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 3a of the Clubhouse/nests integration. Adds a new KMP module
`nestsClient` (Android + JVM targets) that owns the HTTP control plane
for talking to a nests audio-room backend. No transport/audio yet —
that arrives in 3b with WebTransport + MoQ.
Surface:
- `NestsAuth.header(signer, url, method)` signs a kind 27235 event via
Quartz's existing HTTPAuthorizationEvent and returns a ready-to-use
`Authorization: Nostr <base64>` header value.
- `NestsRoomInfo` data class + tolerant JSON parser (ignores unknown
fields so newer nests server revisions don't break older clients).
- `NestsClient.resolveRoom(serviceBase, roomId, signer)` calls
`<serviceBase>/<roomId>` with the signed NIP-98 header and returns
the MoQ endpoint + token the audio layer will need.
- `OkHttpNestsClient` (jvmAndroid) is the default implementation
shared between Android and desktop JVM.
Tests:
- `NestsRoomInfoTest` (commonTest) covers full/minimal payloads,
unknown-field tolerance, missing-endpoint rejection, and URL
construction edge cases.
- `NestsAuthTest` (jvmTest) signs a real 27235 event, decodes the
base64 back through Quartz's JacksonMapper, and asserts the
signature verifies and the url+method tags bind correctly.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D