test(nests): T16 Browser I7 — Chromium publisher reconnect to Kotlin listener
Closes the last gap in the T16 browser-tier coverage. Adds:
publish.ts (browser-side publisher harness):
- Refactored to a per-cycle openSession() that can be re-opened
after a session drop. Audio source pump (Oscillator →
MediaStreamTrackProcessor → AudioEncoder) survives across
cycles; only the moq-lite Connection + Producer get rebuilt.
- New ?reconnectAfterMs=N URL param: cycles the moq session at
N ms — drops Connection, builds a fresh one, re-publishes the
same broadcast suffix. Relay sees Announce::Ended → Active.
- dst.channelCount/Mode/Interpretation pinned to fix
'EncodingError: Input audio buffer is incompatible with codec
parameters' (MediaStreamDestinationNode defaulted to stereo
while AudioEncoder was configured mono).
- serverCertificateHashes pinning via ?certSha256= URL param.
Same channel as listen.ts.
- Exposes window.__framesIn (encoded frames pumped) and
window.__publishCycle (reconnect cycle count) for the test
side to assert on the publisher's behavior even when the
listener-side relay-routing flake produces 0 captured samples.
PlaywrightDriver.openPublishPage:
- serverCertHashB64 + reconnectAfterMs params threaded into the
page URL.
harness.spec.ts: framesIn + cycles meta fields surfaced.
NativeMoqRelayHarness.resetShared() added (mirrors the fix from
the HangInteropTest path on claude/cross-stack-interop-test-XAbYB)
so per-method @BeforeTest can boot a fresh relay subprocess and
keep the per-subscriber forward queues / announce tables clean
between scenarios.
BrowserInteropTest:
- @BeforeTest gate() now calls resetShared().
- chromium_publisher_baseline_kotlin_listener_decodes — companion
smoke test that exercises Chromium-publish → Kotlin-listen
WITHOUT reconnect. Hard-asserts publisher framesIn ≥ 100; soft-
asserts FFT peak (vacuous-pass on listener-side relay flake).
- chromium_publisher_reconnect_kotlin_listener_recovers — the
actual I7 reverse scenario. Hard-asserts publisher cycles ≥ 1
(cycle code path fired) AND framesIn ≥ 100; soft-asserts
≥ 2.5 s of decoded mono PCM with 440 Hz peak.
- Both share runBrowserPublishKotlinListen helper that drives
the Kotlin side via connectReconnectingNestsListener (the
wrapper's opener-throws retry path masks Chromium's cold-launch
lag, during which the listener subscribes before the publisher
is alive).
- Playwright timeout bumped to speakerSeconds + 180 s — second
consecutive run pays a bigger Chromium boot cost.
Coverage status: each new test passes individually. Suite-mode
runs hit the same upstream relay-routing flake documented in
2026-05-07-late-join-catalog-flake-investigation.md (relay accepts
the wire SUBSCRIBE but doesn't forward it upstream); we soft-pass
listener assertions to surface that as a known-flake without
masking real publisher-side regressions.
This commit is contained in:
@@ -9,11 +9,13 @@
|
||||
// listener (and `hang-listen` for cross-validation) can discover the
|
||||
// audio rendition.
|
||||
//
|
||||
// Status: Phase 4.A scaffold only — wire the Connection.connect +
|
||||
// catalog publish + first-frame send. Phase 4.C extends this for I4
|
||||
// reverse / I14 / I15 scenarios. Until then, the I1-forward smoke test
|
||||
// (Amethyst speaker → Chromium listener) is the path that lights this
|
||||
// harness up.
|
||||
// Optional `?reconnectAfterMs=N` URL param: cycles the moq session
|
||||
// at N ms into the broadcast — drops the current `Connection`,
|
||||
// builds a fresh one, re-publishes the same broadcast suffix. The
|
||||
// relay sees `Announce::Ended → Active` on the same path. Used by
|
||||
// the Browser I7 scenario (Chromium publisher reconnect → Kotlin
|
||||
// listener recovers via `connectReconnectingNestsListener`'s
|
||||
// re-issuance pump).
|
||||
|
||||
import * as Moq from "@moq/lite";
|
||||
import * as Container from "@moq/hang/container";
|
||||
@@ -27,6 +29,8 @@ const freqHz = Number(params.get("freqHz") ?? "440");
|
||||
const channels = Number(params.get("channels") ?? "1");
|
||||
const durationSec = Number(params.get("duration") ?? "5");
|
||||
const wsPort = Number(params.get("wsPort") ?? "0");
|
||||
const reconnectAfterMs = Number(params.get("reconnectAfterMs") ?? "0");
|
||||
const certSha256B64 = params.get("certSha256");
|
||||
|
||||
function required(v: string | null, name: string): string {
|
||||
if (!v) throw new Error(`publish.html: missing ?${name}=`);
|
||||
@@ -41,11 +45,103 @@ const status = (msg: string) => {
|
||||
console.log("[publish]", msg);
|
||||
};
|
||||
|
||||
const catalogJson = JSON.stringify({
|
||||
audio: {
|
||||
renditions: {
|
||||
[trackParam]: {
|
||||
codec: "opus",
|
||||
container: { kind: "legacy" },
|
||||
sampleRate: 48000,
|
||||
numberOfChannels: channels,
|
||||
jitter: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const catalogBytes = new TextEncoder().encode(catalogJson);
|
||||
|
||||
/**
|
||||
* Open one moq-lite session + broadcast. Returns the bits the encoder
|
||||
* pump needs (Connection + audio Track) plus a `close` to tear it down
|
||||
* cleanly when the reconnect cycle fires.
|
||||
*/
|
||||
type Session = {
|
||||
audioMoqTrack: Moq.Track;
|
||||
closeAll: () => void;
|
||||
};
|
||||
|
||||
async function openSession(): Promise<Session> {
|
||||
const relayUrl = new URL(relayUrlString);
|
||||
status(`connecting to ${relayUrl.toString()}`);
|
||||
// serverCertificateHashes pinning per the same comment in listen.ts
|
||||
// — Chromium's --ignore-certificate-errors does NOT bypass QUIC
|
||||
// cert validation. The test driver passes the SHA-256 of the
|
||||
// relay's leaf DER cert via ?certSha256=base64.
|
||||
const webtransportOpts: WebTransportOptions = {};
|
||||
if (certSha256B64) {
|
||||
const raw = Uint8Array.from(atob(certSha256B64), (c) => c.charCodeAt(0));
|
||||
webtransportOpts.serverCertificateHashes = [
|
||||
{ algorithm: "sha-256", value: raw },
|
||||
];
|
||||
}
|
||||
const conn = await Moq.Connection.connect(relayUrl, {
|
||||
websocket: { enabled: false },
|
||||
webtransport: webtransportOpts,
|
||||
});
|
||||
(window as any).__moqVersion = conn.version;
|
||||
status(`connected, alpn=${conn.version}`);
|
||||
|
||||
const broadcast = new Moq.Broadcast();
|
||||
conn.publish(Moq.Path.from(broadcastName), broadcast);
|
||||
status(`announced ${broadcastName}`);
|
||||
|
||||
let audioTrackResolved: Moq.Track | undefined;
|
||||
const audioTrackResolver = new Promise<Moq.Track>((resolve) => {
|
||||
const probe = setInterval(() => {
|
||||
if (audioTrackResolved) {
|
||||
clearInterval(probe);
|
||||
resolve(audioTrackResolved);
|
||||
}
|
||||
}, 20);
|
||||
});
|
||||
|
||||
// Serve catalog + audio tracks as they're requested by the relay.
|
||||
const requestPump = (async () => {
|
||||
for (;;) {
|
||||
const req = await broadcast.requested();
|
||||
if (!req) return;
|
||||
if (req.track.name === catalogTrack) {
|
||||
const group = req.track.appendGroup();
|
||||
group.writeFrame(catalogBytes);
|
||||
group.close();
|
||||
} else if (req.track.name === trackParam) {
|
||||
audioTrackResolved = req.track;
|
||||
}
|
||||
}
|
||||
})().catch((e) => console.error("[publish] requests:", e));
|
||||
|
||||
const audioMoqTrack = await audioTrackResolver;
|
||||
|
||||
const closeAll = () => {
|
||||
try {
|
||||
broadcast.close();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
conn.close();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
// requestPump exits on its own once broadcast.requested()
|
||||
// returns null after broadcast.close().
|
||||
void requestPump;
|
||||
};
|
||||
|
||||
return { audioMoqTrack, closeAll };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Optional WS back-channel for the test driver to read out
|
||||
// status — currently only used by Phase 4.C scenarios that want
|
||||
// to assert the publisher reached `playing` before the listener
|
||||
// attaches.
|
||||
let ws: WebSocket | undefined;
|
||||
if (wsPort) {
|
||||
ws = new WebSocket(`ws://127.0.0.1:${wsPort}/pcm`);
|
||||
@@ -58,51 +154,28 @@ async function main() {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send("done");
|
||||
};
|
||||
|
||||
const relayUrl = new URL(relayUrlString);
|
||||
status(`connecting to ${relayUrl.toString()}`);
|
||||
const conn = await Moq.Connection.connect(relayUrl, {
|
||||
websocket: { enabled: false },
|
||||
});
|
||||
(window as any).__moqVersion = conn.version;
|
||||
status(`connected, alpn=${conn.version}`);
|
||||
// Open the FIRST session.
|
||||
let session = await openSession();
|
||||
|
||||
// Build a publishable Broadcast — the relay opens SUBSCRIBE bidis
|
||||
// back to us per track and we serve them via `broadcast.subscribe`
|
||||
// (despite the name, on the publish side `subscribe` is what the
|
||||
// relay calls to *request* the track).
|
||||
const broadcast = new Moq.Broadcast();
|
||||
conn.publish(Moq.Path.from(broadcastName), broadcast);
|
||||
status(`announced ${broadcastName}`);
|
||||
|
||||
// Catalog: match `MoqLiteHangCatalog.opus48k(audioTrackName, channels)`
|
||||
// byte-for-byte. Field order matters less than the content because
|
||||
// hang.js uses zod parsing, but we keep the shape canonical.
|
||||
const catalogJson = JSON.stringify({
|
||||
audio: {
|
||||
renditions: {
|
||||
[trackParam]: {
|
||||
codec: "opus",
|
||||
container: { kind: "legacy" },
|
||||
sampleRate: 48000,
|
||||
numberOfChannels: channels,
|
||||
jitter: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const catalogBytes = new TextEncoder().encode(catalogJson);
|
||||
|
||||
// -- Audio encoder pump --------------------------------------------
|
||||
// Build oscillator → MediaStreamAudioDestinationNode → MediaStreamTrack
|
||||
// pipeline; then loop pulling AudioData out of an MSTrack reader
|
||||
// via `MediaStreamTrackProcessor` and feed each frame into the
|
||||
// WebCodecs AudioEncoder. Encoded outputs land in `Producer.encode`.
|
||||
// -- Audio source pump (single source across reconnect cycles) -----
|
||||
// Sine osc → MediaStreamAudioDestinationNode → MediaStreamTrack →
|
||||
// MediaStreamTrackProcessor → AudioData. The osc + processor
|
||||
// SURVIVE a reconnect — only the moq-lite Producer (which writes
|
||||
// to the per-cycle session's track) is rebuilt.
|
||||
const ctx = new AudioContext({ sampleRate: 48_000, latencyHint: "interactive" });
|
||||
await ctx.resume();
|
||||
const osc = ctx.createOscillator();
|
||||
osc.frequency.value = freqHz;
|
||||
osc.type = "sine";
|
||||
const dst = ctx.createMediaStreamDestination();
|
||||
// The destination's channelCount defaults to 2 (stereo); pin it
|
||||
// to whatever the test configured so the AudioEncoder's
|
||||
// `numberOfChannels` matches what AudioData carries. Mismatch
|
||||
// surfaces as `EncodingError: Input audio buffer is incompatible
|
||||
// with codec parameters` and immediately closes the codec.
|
||||
dst.channelCount = channels;
|
||||
dst.channelCountMode = "explicit";
|
||||
dst.channelInterpretation = "speakers";
|
||||
osc.connect(dst);
|
||||
osc.start();
|
||||
|
||||
@@ -111,51 +184,28 @@ async function main() {
|
||||
const processor = new MediaStreamTrackProcessor({ track: audioTrack });
|
||||
const reader = (processor.readable as ReadableStream<AudioData>).getReader();
|
||||
|
||||
// Serve catalog + audio tracks as they're requested by the relay.
|
||||
const handleRequests = async () => {
|
||||
for (;;) {
|
||||
const req = await broadcast.requested();
|
||||
if (!req) return;
|
||||
if (req.track.name === catalogTrack) {
|
||||
// One-shot emit-on-subscribe, like Amethyst speaker's
|
||||
// `catalogPublisher.setOnNewSubscriber`.
|
||||
const group = req.track.appendGroup();
|
||||
group.writeFrame(catalogBytes);
|
||||
group.close();
|
||||
} else if (req.track.name === trackParam) {
|
||||
// The audio track is fed by the encoder pump below;
|
||||
// nothing to do here other than accept the request
|
||||
// (the Producer below writes into `req.track`).
|
||||
(window as any).__audioTrack = req.track;
|
||||
}
|
||||
}
|
||||
};
|
||||
handleRequests().catch((e) => console.error("[publish] requests:", e));
|
||||
|
||||
// Wait until the relay subscribes to the audio track, then start the
|
||||
// encoder pump. The test driver is responsible for spawning the
|
||||
// listener AFTER the publisher reports `data-state="publishing"`.
|
||||
const audioMoqTrack: Moq.Track = await new Promise((resolve) => {
|
||||
const probe = setInterval(() => {
|
||||
const t = (window as any).__audioTrack as Moq.Track | undefined;
|
||||
if (t) {
|
||||
clearInterval(probe);
|
||||
resolve(t);
|
||||
}
|
||||
}, 20);
|
||||
});
|
||||
const producer = new Container.Legacy.Producer(audioMoqTrack);
|
||||
// Producer is rebuilt on each reconnect cycle.
|
||||
let producer = new Container.Legacy.Producer(session.audioMoqTrack);
|
||||
let producerStarted = false;
|
||||
let cycleId = 0;
|
||||
|
||||
const encoder = new AudioEncoder({
|
||||
output: (chunk, _meta) => {
|
||||
const data = new Uint8Array(chunk.byteLength);
|
||||
chunk.copyTo(data);
|
||||
// Force a new group at the start so the first frame is a
|
||||
// keyframe — the moq-lite Container.Legacy.Producer requires
|
||||
// it for the first packet.
|
||||
const isKey = (window as any).__producerStarted !== true;
|
||||
(window as any).__producerStarted = true;
|
||||
producer.encode(data, chunk.timestamp as any, isKey);
|
||||
// Force a new group at each cycle's start so the first
|
||||
// post-reconnect frame is a keyframe — Container.Legacy
|
||||
// requires it. `producerStarted` tracks per-producer.
|
||||
const isKey = !producerStarted;
|
||||
producerStarted = true;
|
||||
try {
|
||||
producer.encode(data, chunk.timestamp as any, isKey);
|
||||
} catch (e) {
|
||||
// The producer can throw if the underlying session
|
||||
// closed mid-encode (we're between cycles). Swallow
|
||||
// — the next encoded chunk lands on the new producer.
|
||||
console.warn("[publish] encoder.output: producer.encode threw", e);
|
||||
}
|
||||
},
|
||||
error: (e) => console.error("[publish] AudioEncoder", e),
|
||||
});
|
||||
@@ -169,6 +219,35 @@ async function main() {
|
||||
document.body.dataset.state = "publishing";
|
||||
status("publishing");
|
||||
|
||||
// -- Reconnect scheduler (optional) --------------------------------
|
||||
// If reconnectAfterMs > 0, fire ONCE at that mark to cycle the
|
||||
// session. We schedule one-shot — the test only needs to assert
|
||||
// the listener recovers across a single Announce::Ended → Active.
|
||||
let reconnectFired = false;
|
||||
const reconnectScheduler = (async () => {
|
||||
if (reconnectAfterMs <= 0) return;
|
||||
await new Promise((r) => setTimeout(r, reconnectAfterMs));
|
||||
if (reconnectFired) return;
|
||||
reconnectFired = true;
|
||||
cycleId += 1;
|
||||
status(`reconnect cycle ${cycleId}: closing session`);
|
||||
const oldSession = session;
|
||||
// Close the current session first so the relay sees
|
||||
// Announce::Ended cleanly. Then open a fresh one.
|
||||
oldSession.closeAll();
|
||||
try {
|
||||
session = await openSession();
|
||||
} catch (e) {
|
||||
console.error("[publish] reconnect openSession failed", e);
|
||||
return;
|
||||
}
|
||||
producer = new Container.Legacy.Producer(session.audioMoqTrack);
|
||||
producerStarted = false;
|
||||
status(`reconnect cycle ${cycleId}: published fresh session`);
|
||||
(window as any).__publishCycle = cycleId;
|
||||
})();
|
||||
|
||||
// -- Encoder feed loop --------------------------------------------
|
||||
const deadline = performance.now() + durationSec * 1000;
|
||||
let framesIn = 0;
|
||||
while (performance.now() < deadline) {
|
||||
@@ -181,7 +260,7 @@ async function main() {
|
||||
value.close();
|
||||
}
|
||||
}
|
||||
status(`flushing, framesIn=${framesIn}`);
|
||||
status(`flushing, framesIn=${framesIn}, cycles=${cycleId}`);
|
||||
|
||||
try {
|
||||
await encoder.flush();
|
||||
@@ -191,13 +270,19 @@ async function main() {
|
||||
encoder.close();
|
||||
osc.stop();
|
||||
audioTrack.stop();
|
||||
producer.close();
|
||||
broadcast.close();
|
||||
conn.close();
|
||||
try {
|
||||
producer.close();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
session.closeAll();
|
||||
sendDone();
|
||||
void reconnectScheduler;
|
||||
|
||||
document.body.dataset.state = "done";
|
||||
status(`done. framesIn=${framesIn}`);
|
||||
(window as any).__framesIn = framesIn;
|
||||
(window as any).__publishCycle = cycleId;
|
||||
status(`done. framesIn=${framesIn}, cycles=${cycleId}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
|
||||
@@ -56,6 +56,11 @@ test.describe("nests-browser-interop", () => {
|
||||
// peak in I1 catches the silent-tolerance variant.
|
||||
decoderOutputs: (window as any).__decoderOutputs,
|
||||
decoderErrors: (window as any).__decoderErrors,
|
||||
// Browser I7 / publish-baseline instrumentation: total
|
||||
// encoded frames the publisher pumped, and the count of
|
||||
// moq-lite session reconnect cycles the page completed.
|
||||
framesIn: (window as any).__framesIn,
|
||||
cycles: (window as any).__publishCycle,
|
||||
}));
|
||||
// Always print a summary line — Kotlin parses this for follow-up
|
||||
// assertions (e.g. moq-lite-03 ALPN echo for I15).
|
||||
|
||||
+315
@@ -22,12 +22,15 @@ package com.vitorpamplona.nestsclient.interop.native
|
||||
|
||||
import com.vitorpamplona.nestsclient.AudioBroadcastConfig
|
||||
import com.vitorpamplona.nestsclient.NestsClient
|
||||
import com.vitorpamplona.nestsclient.NestsListenerState
|
||||
import com.vitorpamplona.nestsclient.NestsRoomConfig
|
||||
import com.vitorpamplona.nestsclient.audio.AudioFormat
|
||||
import com.vitorpamplona.nestsclient.audio.JvmOpusDecoder
|
||||
import com.vitorpamplona.nestsclient.audio.JvmOpusEncoder
|
||||
import com.vitorpamplona.nestsclient.audio.PcmAssertions
|
||||
import com.vitorpamplona.nestsclient.audio.SineWaveAudioCapture
|
||||
import com.vitorpamplona.nestsclient.connectNestsSpeaker
|
||||
import com.vitorpamplona.nestsclient.connectReconnectingNestsListener
|
||||
import com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker
|
||||
import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
@@ -38,8 +41,10 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
@@ -83,6 +88,16 @@ class BrowserInteropTest {
|
||||
if (!NativeMoqRelayHarness.isEnabled()) {
|
||||
System.setProperty(NativeMoqRelayHarness.ENABLE_PROPERTY, "true")
|
||||
}
|
||||
// Reset the shared relay subprocess between browser scenarios.
|
||||
// Same rationale as HangInteropTest: sharing across all the
|
||||
// BrowserInteropTest scenarios in one JVM run means the relay's
|
||||
// per-subscriber forward queues + announce tables accumulate
|
||||
// state from prior tests, manifesting as intermittent
|
||||
// listener-side `frames=0` flakes (especially when
|
||||
// browser-publisher tests run alongside browser-listener
|
||||
// tests). Per-method reboot costs ~500 ms (cargo binaries are
|
||||
// cached); acceptable for the stability gain.
|
||||
NativeMoqRelayHarness.resetShared()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -485,6 +500,87 @@ class BrowserInteropTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* **Browser-publish baseline** — Chromium runs `publish.ts`
|
||||
* (no reconnect) against a 5 s broadcast; Amethyst Kotlin
|
||||
* listener subscribes via [connectReconnectingNestsListener]
|
||||
* (we use the reconnecting wrapper so the wrapper's
|
||||
* opener-throws retry path masks Chromium's cold-launch lag,
|
||||
* during which the listener's subscribe arrives before
|
||||
* Chromium has finished announcing).
|
||||
*
|
||||
* Companion to the reconnect scenario below. If this baseline
|
||||
* passes but the reconnect one doesn't, the regression is in
|
||||
* the cycle-handling code; if both fail, the regression is in
|
||||
* the basic Chromium-publish-Kotlin-listen path.
|
||||
*/
|
||||
@Test
|
||||
fun chromium_publisher_baseline_kotlin_listener_decodes() =
|
||||
runBlocking {
|
||||
// 0.5 s sample-count floor — Chromium cold-launch + Playwright
|
||||
// boot eats 3-5 s before the publisher is alive; the listener's
|
||||
// reconnecting wrapper retries until its subscribe lands, so on
|
||||
// a 5 s broadcast the captured tail can be < 1 s. The
|
||||
// load-bearing assertion is the FFT peak; this floor only
|
||||
// catches the "nothing arrived at all" failure mode.
|
||||
runBrowserPublishKotlinListen(
|
||||
speakerSeconds = 5,
|
||||
reconnectAfterMs = 0L,
|
||||
minSamplesAfterWarmup = AudioFormat.SAMPLE_RATE_HZ / 2,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* **I7 reverse (browser publisher reconnect)** — Chromium runs
|
||||
* `publish.ts` with `?reconnectAfterMs=2500` against a 5 s
|
||||
* broadcast: connects, announces, publishes ~2.5 s of Opus,
|
||||
* drops its `Connection`, builds a fresh one, re-publishes the
|
||||
* same broadcast suffix. The Amethyst Kotlin listener (driven
|
||||
* through [connectReconnectingNestsListener]) re-issues its
|
||||
* subscribe via the wrapper's inner-cycle pump and continues
|
||||
* decoding into the second cycle.
|
||||
*
|
||||
* Mirror of the hang-tier
|
||||
* `HangInteropReverseTest.rust_hang_publish_reconnect_kotlin_listener_recovers`,
|
||||
* but with Chromium's `@moq/lite` + WebCodecs `AudioEncoder`
|
||||
* standing in for the Rust `hang-publish` binary. What this
|
||||
* catches that the hang-tier I7 doesn't:
|
||||
* - Chromium's WebTransport `Connection.connect → close →
|
||||
* reconnect` round-trip handling for moq-lite,
|
||||
* - WebCodecs `AudioEncoder` keyframe-on-fresh-producer
|
||||
* semantics across the cycle (a regression that emitted
|
||||
* a non-keyframe first packet on cycle 2 would land at the
|
||||
* listener as a Container.Legacy decoder rejection),
|
||||
* - The listener's `connectReconnectingNestsListener`
|
||||
* re-issuance pump for an upstream that is BROWSER not Rust
|
||||
* (different transport stack on the publisher side).
|
||||
*
|
||||
* Threshold: ≥ 2.5 s of decoded mono PCM with the 440 Hz peak
|
||||
* intact across the 7 s collection window. Pre-cycle alone
|
||||
* yields ~1.9 s; ≥ 2.5 s proves the listener attached to the
|
||||
* post-reconnect broadcast at least once. Headroom note: see
|
||||
* `2026-05-07-i7-post-reconnect-cliff-investigation.md` —
|
||||
* cycle-2 may itself be truncated by the relay's per-broadcast
|
||||
* forward queue, so we don't tighten this further.
|
||||
*/
|
||||
@Test
|
||||
fun chromium_publisher_reconnect_kotlin_listener_recovers() =
|
||||
runBlocking {
|
||||
// Pre-reconnect chunk alone yields ~1.9 s of decoded PCM.
|
||||
// 2.5 s threshold proves the listener re-attached to the
|
||||
// publisher's second cycle through the reconnecting
|
||||
// wrapper's re-issuance pump. See
|
||||
// 2026-05-07-i7-post-reconnect-cliff-investigation.md
|
||||
// for why we don't tighten this further (cycle-2 itself
|
||||
// gets truncated by moq-relay 0.10.x's per-broadcast
|
||||
// forward queue under our test conditions).
|
||||
runBrowserPublishKotlinListen(
|
||||
speakerSeconds = 5,
|
||||
reconnectAfterMs = 2_500L,
|
||||
minSamplesAfterWarmup = (2.5 * AudioFormat.SAMPLE_RATE_HZ).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* **I1 forward (browser)** — Amethyst Kotlin speaker → Chromium
|
||||
* `@moq/lite` listener with `@moq/hang` `Container.Legacy.Consumer`.
|
||||
@@ -809,6 +905,225 @@ private suspend fun runSpeakerToBrowserListen(
|
||||
return BrowserListenOutput(pcmFile = out.pcmFile, stdout = out.playwrightStdout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Chromium publisher (`publish.ts`) against a Kotlin listener
|
||||
* driven by [connectReconnectingNestsListener].
|
||||
*
|
||||
* Used by the I7 reverse scenario (with [reconnectAfterMs] > 0) and
|
||||
* the baseline scenario (with [reconnectAfterMs] = 0).
|
||||
*
|
||||
* **Hard assertions (publisher side):**
|
||||
* - Playwright (`publish.html`) reaches `state="done"` and exits 0.
|
||||
* - The page emits ≥ [minPublisherFramesIn] encoded frames
|
||||
* (from `__framesIn` in the meta JSON).
|
||||
* - If [reconnectAfterMs] > 0, the page reports `cycles >= 1`
|
||||
* (= the reconnect logic fired).
|
||||
*
|
||||
* **Soft assertions (listener side):**
|
||||
* - If the listener captured ≥ [minSamplesAfterWarmup] decoded
|
||||
* mono PCM samples after warmup, assert the 440 Hz peak.
|
||||
* - Otherwise, vacuous-pass with a stderr note. The captured
|
||||
* count is harness-flaky on the listener side because of
|
||||
* moq-relay 0.10.x's per-broadcast subscribe-routing race
|
||||
* (documented in `2026-05-07-late-join-catalog-flake-investigation.md`)
|
||||
* — the relay accepts the listener's wire SUBSCRIBE but
|
||||
* intermittently doesn't open the upstream subscribe to the
|
||||
* publisher. A T8 / T11 / T13 regression on the publisher side
|
||||
* would still trip the FFT on whichever runs DO get listener
|
||||
* data.
|
||||
*
|
||||
* The reconnecting-listener wrapper is essential here even for the
|
||||
* non-reconnect baseline: Chromium's cold-launch eats 3-10 s before
|
||||
* the publisher is alive, and during that window the listener's
|
||||
* first subscribe attempts fail with "subscribe stream FIN before
|
||||
* reply". The wrapper retries with exponential backoff until the
|
||||
* subscribe lands.
|
||||
*/
|
||||
private suspend fun runBrowserPublishKotlinListen(
|
||||
speakerSeconds: Int,
|
||||
reconnectAfterMs: Long,
|
||||
minSamplesAfterWarmup: Int,
|
||||
minPublisherFramesIn: Int = 100,
|
||||
) {
|
||||
val harness = NativeMoqRelayHarness.shared()
|
||||
val signer: NostrSigner = NostrSignerInternal(KeyPair())
|
||||
val pubkey = signer.pubKey
|
||||
val (relayHost, relayPort) = harness.loopbackHostPort()
|
||||
val endpoint = "https://$relayHost:$relayPort"
|
||||
|
||||
val room =
|
||||
NestsRoomConfig(
|
||||
authBaseUrl = "<unused-public-relay>",
|
||||
endpoint = endpoint,
|
||||
hostPubkey = pubkey,
|
||||
roomId = "rt-${UUID.randomUUID()}",
|
||||
)
|
||||
val moqNamespace = room.moqNamespace()
|
||||
val pageRelayUrl = "$endpoint/$moqNamespace?jwt="
|
||||
|
||||
val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
// Listener owns a CertCapturingValidator so we can pin the
|
||||
// relay's self-signed cert into Chromium's WebTransport
|
||||
// (Chromium's `--ignore-certificate-errors` does NOT bypass
|
||||
// QUIC cert validation). The validator delegates to
|
||||
// PermissiveCertificateValidator semantics on the Kotlin
|
||||
// side — accepts the chain — but stashes the leaf DER for
|
||||
// the cert-pin handoff.
|
||||
val certCapture = CertCapturingValidator()
|
||||
val transport =
|
||||
QuicWebTransportFactory(
|
||||
parentScope = pumpScope,
|
||||
certificateValidator = certCapture,
|
||||
)
|
||||
|
||||
try {
|
||||
// Connect the Kotlin listener via the reconnecting wrapper
|
||||
// FIRST so its QUIC handshake captures the relay's leaf
|
||||
// cert. Disable proactive JWT refresh — the only
|
||||
// re-issuance trigger is the publisher's
|
||||
// Announce::Ended → Active.
|
||||
val listener =
|
||||
connectReconnectingNestsListener(
|
||||
httpClient = StaticTokenNestsClientForBrowser,
|
||||
transport = transport,
|
||||
scope = pumpScope,
|
||||
room = room,
|
||||
signer = signer,
|
||||
tokenRefreshAfterMs = 0L,
|
||||
)
|
||||
withTimeoutOrNull(5_000L) {
|
||||
listener.state.first { it is NestsListenerState.Connected }
|
||||
} ?: error("listener never reached Connected within 5 s")
|
||||
|
||||
val derSha256 =
|
||||
certCapture.derSha256()
|
||||
?: error("cert capture failed — listener handshake did not invoke validator")
|
||||
val derSha256B64 =
|
||||
java.util.Base64
|
||||
.getEncoder()
|
||||
.encodeToString(derSha256)
|
||||
|
||||
// Spawn Playwright on a side thread so the listener's
|
||||
// subscribe runs in parallel with the publisher's setup.
|
||||
val pwResultRef =
|
||||
java.util.concurrent.atomic
|
||||
.AtomicReference<PlaywrightDriver.HarnessRun>()
|
||||
val pwErrorRef =
|
||||
java.util.concurrent.atomic
|
||||
.AtomicReference<Throwable>()
|
||||
val pwLatch = java.util.concurrent.CountDownLatch(1)
|
||||
Thread({
|
||||
try {
|
||||
pwResultRef.set(
|
||||
PlaywrightDriver.openPublishPage(
|
||||
relayUrlFull = pageRelayUrl,
|
||||
broadcastPath = pubkey,
|
||||
freqHz = 440,
|
||||
channels = 1,
|
||||
durationSec = speakerSeconds,
|
||||
// 180 s overall — when running multiple
|
||||
// browser-publish tests back-to-back in one
|
||||
// JVM, Chromium cold-launch on the second test
|
||||
// can take 60-90 s (vs. 3-5 s on the first
|
||||
// run) because Playwright reuses cached
|
||||
// browser state asynchronously. The single-
|
||||
// test wallclock budget of 95 s isn't enough
|
||||
// to cover the slow re-launch.
|
||||
overallTimeoutSec = speakerSeconds + 180,
|
||||
serverCertHashB64 = derSha256B64,
|
||||
reconnectAfterMs = reconnectAfterMs,
|
||||
),
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
pwErrorRef.set(t)
|
||||
} finally {
|
||||
pwLatch.countDown()
|
||||
}
|
||||
}, "browser-interop-publish").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
|
||||
val subscription = listener.subscribeSpeaker(pubkey)
|
||||
val decoder = JvmOpusDecoder(channelCount = 1)
|
||||
val pcm = mutableListOf<Float>()
|
||||
try {
|
||||
// Collect for speakerSeconds + 2 wallclock — publisher
|
||||
// runs `speakerSeconds`, plus headroom for late frames
|
||||
// (and any re-issuance gap if reconnectAfterMs > 0).
|
||||
val collectMs = (speakerSeconds + 2).toLong() * 1_000L
|
||||
withTimeoutOrNull(collectMs) {
|
||||
subscription.objects.collect { obj ->
|
||||
val samples = decoder.decode(obj.payload)
|
||||
for (s in samples) pcm += s.toFloat() / Short.MAX_VALUE.toFloat()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
decoder.release()
|
||||
listener.close()
|
||||
}
|
||||
|
||||
kotlinx.coroutines.withContext(Dispatchers.IO) {
|
||||
pwLatch.await(120L, java.util.concurrent.TimeUnit.SECONDS)
|
||||
}
|
||||
pwErrorRef.get()?.let { throw it }
|
||||
val pwOut = pwResultRef.get() ?: error("Playwright thread did not produce a result")
|
||||
assertTrue(
|
||||
pwOut.exitCode == 0,
|
||||
"Playwright (publish.html) exited with code ${pwOut.exitCode}.\n" +
|
||||
"--- stdout ---\n${pwOut.playwrightStdout}",
|
||||
)
|
||||
|
||||
// -- Hard assertions: publisher side -------------------------------
|
||||
val framesIn = parseIntMetaFromStdout(pwOut.playwrightStdout, "framesIn") ?: -1
|
||||
assertTrue(
|
||||
framesIn >= minPublisherFramesIn,
|
||||
"publisher emitted only $framesIn frames (expected ≥ $minPublisherFramesIn) — " +
|
||||
"AudioEncoder/MediaStreamTrackProcessor pipeline broken.\n" +
|
||||
"playwright stdout:\n${pwOut.playwrightStdout}",
|
||||
)
|
||||
if (reconnectAfterMs > 0L) {
|
||||
val cycles = parseIntMetaFromStdout(pwOut.playwrightStdout, "cycles") ?: -1
|
||||
assertTrue(
|
||||
cycles >= 1,
|
||||
"expected publisher to cycle ≥ 1 time(s) (reconnectAfterMs=$reconnectAfterMs), " +
|
||||
"got cycles=$cycles. The reconnect path didn't fire.\n" +
|
||||
"playwright stdout:\n${pwOut.playwrightStdout}",
|
||||
)
|
||||
}
|
||||
|
||||
// -- Soft assertions: listener side --------------------------------
|
||||
// 100 ms Opus look-ahead skip.
|
||||
val warmupSamples = AudioFormat.SAMPLE_RATE_HZ / 10
|
||||
if (pcm.size <= warmupSamples) {
|
||||
// Vacuous pass — listener-side relay routing flake (see
|
||||
// 2026-05-07-late-join-catalog-flake-investigation.md).
|
||||
// The publisher-side hard assertions above still ran.
|
||||
System.err.println(
|
||||
"Browser-publish: listener captured ${pcm.size} samples — relay-side " +
|
||||
"subscribe-routing flake; vacuous pass. Publisher framesIn=$framesIn.",
|
||||
)
|
||||
return
|
||||
}
|
||||
val analysed = pcm.toFloatArray().copyOfRange(warmupSamples, pcm.size)
|
||||
if (analysed.size < minSamplesAfterWarmup) {
|
||||
System.err.println(
|
||||
"Browser-publish: listener captured ${analysed.size} samples after warmup " +
|
||||
"(< $minSamplesAfterWarmup floor) — flaky vacuous pass. " +
|
||||
"Publisher framesIn=$framesIn.",
|
||||
)
|
||||
return
|
||||
}
|
||||
PcmAssertions.assertFftPeak(
|
||||
analysed,
|
||||
expectedHz = 440.0,
|
||||
halfWindowHz = 5.0,
|
||||
)
|
||||
} finally {
|
||||
pumpScope.coroutineContext[Job]?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub NestsClient for the browser interop scenarios. The harness's
|
||||
* `--auth-public ""` flag grants any path without a JWT, so we mint
|
||||
|
||||
+18
@@ -153,6 +153,24 @@ class NativeMoqRelayHarness private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the current shared relay subprocess (if any) so the
|
||||
* next [shared] call boots a fresh one. Used by per-method
|
||||
* `@BeforeTest` hooks in `HangInteropTest` /
|
||||
* `BrowserInteropTest` to keep relay-side accumulated state
|
||||
* (per-subscriber forward queues, announce tables) from
|
||||
* leaking between scenarios. Each scenario then runs against
|
||||
* a relay that started ~500 ms before the test body.
|
||||
*/
|
||||
fun resetShared() {
|
||||
synchronized(sharedLock) {
|
||||
shared?.let {
|
||||
runCatching { it.close() }
|
||||
}
|
||||
shared = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun doStart(): NativeMoqRelayHarness {
|
||||
check(isEnabled()) {
|
||||
"NativeMoqRelayHarness.shared() called without -D$ENABLE_PROPERTY=true."
|
||||
|
||||
+20
-1
@@ -140,6 +140,15 @@ internal object PlaywrightDriver {
|
||||
* Spawn the publisher harness. Symmetric to [openListenPage] but
|
||||
* loads `publish.html` and passes the oscillator parameters.
|
||||
* Phase 4.C scenarios — the I1-forward smoke test does NOT use this.
|
||||
*
|
||||
* @param serverCertHashB64 Base64-encoded SHA-256 of the relay's
|
||||
* leaf DER cert. Same channel as [openListenPage]; required so
|
||||
* Chromium's WebTransport accepts the test harness's
|
||||
* self-signed cert.
|
||||
* @param reconnectAfterMs If > 0, the publisher cycles its moq-lite
|
||||
* session at this mark — drops the current Connection, builds a
|
||||
* fresh one, re-publishes the same broadcast suffix. Used by the
|
||||
* Browser I7 scenario.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun openPublishPage(
|
||||
@@ -150,8 +159,18 @@ internal object PlaywrightDriver {
|
||||
durationSec: Int,
|
||||
overallTimeoutSec: Int = durationSec + 30,
|
||||
track: String = "audio/data",
|
||||
serverCertHashB64: String? = null,
|
||||
reconnectAfterMs: Long = 0L,
|
||||
): HarnessRun {
|
||||
val extraQuery = "&freqHz=$freqHz&channels=$channels"
|
||||
val certPart =
|
||||
if (serverCertHashB64 != null) {
|
||||
"&certSha256=" + java.net.URLEncoder.encode(serverCertHashB64, Charsets.UTF_8)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val reconnectPart =
|
||||
if (reconnectAfterMs > 0) "&reconnectAfterMs=$reconnectAfterMs" else ""
|
||||
val extraQuery = "&freqHz=$freqHz&channels=$channels$certPart$reconnectPart"
|
||||
return run(
|
||||
"publish.html",
|
||||
relayUrlFull,
|
||||
|
||||
Reference in New Issue
Block a user