diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt index cf44d9280..409cbb6e3 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt @@ -463,11 +463,26 @@ class BrowserInteropTest { * session stays alive throughout (hot-swap is speaker-side only; * the listener's session is independent), and because the * speaker re-publishes the same broadcast suffix the page sees - * `Announce::Ended → Active` and stays subscribed. + * `Announce::Ended → Active`. * - * Mirror of the hang-tier `speaker_hot_swap_does_not_crash`. - * Asserts the FFT peak survives — group-sequence corruption - * across the swap (regression on T12) would shift it. + * **Soft assertion only.** Empirical post-`:quic`-merge sample + * counts vary 0–7.7 k samples (≤ 160 ms TOTAL) — Chromium's + * `@moq/lite` 0.2.x client doesn't re-attach across the + * `Active::Ended → Active` boundary, so any hard sample-count + * floor would fail-flake. T12 (group sequence carry across + * hot-swaps) is hard-asserted by the hang-tier counterpart + * `speaker_hot_swap_does_not_crash` in [HangInteropTest], which + * decodes the full post-swap window with the 440 Hz peak + * intact. The browser tier here only verifies: + * - the harness boots and the publisher hot-swap completes + * without crashing the test process, + * - Chromium's WebCodecs `AudioDecoder` doesn't fire a + * `decoderErrors > 0` event during the swap window. + * + * Tracked as the "browser hot-swap re-attach" deferred item in + * `nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md`. + * Promote to a hard floor once `@moq/lite` / `@moq/hang` gain + * a working subscribe-re-attach path. */ @Test fun chromium_listener_speaker_hot_swap_does_not_crash() = @@ -483,49 +498,7 @@ class BrowserInteropTest { "decoderErrors=$errors during hot-swap — expected 0.\n" + "playwright stdout:\n${out.stdout}", ) - val pcm = readFloat32Pcm(out.pcmFile) - val warmupSamples = AudioFormat.SAMPLE_RATE_HZ / 10 - // SOFT-PASS — kept intentionally on this one scenario. - // - // Empirical post-`:quic`-merge sample counts vary from - // 0 to ~7.7 k samples (≤ 160 ms TOTAL) across sweeps, - // straddling the 4.8 k warmupSamples threshold. The - // browser path's hot-swap response is fundamentally - // unreliable: Chromium's `@moq/lite` 0.2.x client tears - // down its catalog/audio subscriptions when it sees - // `Announce::Ended → Active` in rapid succession - // (T+2.5 s + ~ms swap window) instead of re-attaching - // to the new broadcast cycle. ANY hard floor on - // `pcm.size` that excludes the regression mode - // ("swap crashed the WT session entirely") fail-flakes - // because the steady state itself sits at the warmup - // window edge. - // - // The hang-tier counterpart (`speaker_hot_swap_does_not_crash` - // in HangInteropTest) hard-asserts the full post-swap - // window decodes the 440 Hz peak — that's the place - // T12 (group sequence carry across hot-swaps) - // regressions are caught. The browser tier here only - // verifies the harness boots, the publisher hot-swaps - // without crashing the test process, and Chromium - // doesn't fire a decoder error. - // - // Tracked in - // `2026-05-06-cross-stack-interop-test-results.md`'s - // "browser hot-swap re-attach" deferred item. Resolution - // requires a fix in `@moq/lite` / `@moq/hang`'s subscribe - // re-attach path — out of scope for this branch. - if (pcm.size <= warmupSamples) { - System.err.println( - "Browser hot-swap: captured ${pcm.size} samples (≤ warmupSamples). " + - "Soft-pass per documented browser-side @moq/lite re-attach " + - "limitation; T12 is asserted by the hang-tier counterpart.", - ) - return@runBlocking - } - // If we DID get past warmup, still skip the FFT — the - // post-warmup window may be too short (60 ms typical) to - // resolve a 440 Hz peak with halfWindowHz=5. + // No PCM sample-count or FFT assertion — see kdoc. } /** diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarness.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarness.kt index 3e75ca68a..b54b9bb6d 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarness.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarness.kt @@ -142,8 +142,9 @@ class NativeMoqRelayHarness private constructor( /** Monotonic counter used to disambiguate same-tag boots in one JVM. */ private val bootSequence = AtomicInteger(0) - private val LOG_TIMESTAMP_FMT = + private val logTimestampFmt = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS") + private val tagSanitiser = Regex("[^A-Za-z0-9._-]") fun isEnabled(): Boolean = System.getProperty(ENABLE_PROPERTY) == "true" @@ -253,7 +254,7 @@ class NativeMoqRelayHarness private constructor( if (relayLogDir != null) { relayLogDir.mkdirs() val seq = bootSequence.incrementAndGet().toString().padStart(3, '0') - val ts = LocalDateTime.now().format(LOG_TIMESTAMP_FMT) + val ts = LocalDateTime.now().format(logTimestampFmt) val tag = sanitiseTag(testTag ?: "boot") File(relayLogDir, "$tag-$seq-$ts.log") } else { @@ -337,7 +338,7 @@ class NativeMoqRelayHarness private constructor( */ private fun sanitiseTag(raw: String): String = raw - .replace(Regex("[^A-Za-z0-9._-]"), "_") + .replace(tagSanitiser, "_") .take(80) .ifBlank { "boot" } @@ -457,7 +458,30 @@ private class ProcessOutputDrainer( fun start() { thread = Thread({ - val writer = sinkFile?.bufferedWriter() + // Open the sink file inside the drain thread + tolerant + // of failure: if `bufferedWriter()` throws (parent dir + // gone, disk full, file-already-a-directory, …) the + // drainer must STILL pump the subprocess's + // stdout/stderr — otherwise the relay's pipe buffer + // fills (~64 KB on Linux) and the subprocess deadlocks + // on its next write. Per-test runs are unattended; a + // misconfigured log dir shouldn't take the whole + // suite down. + val writer: java.io.BufferedWriter? = + if (sinkFile != null) { + try { + sinkFile.bufferedWriter() + } catch (t: Throwable) { + System.err.println( + "NativeMoqRelayHarness-$name: failed to open trace log " + + "$sinkFile (${t::class.simpleName}: ${t.message}); " + + "continuing without per-test capture.", + ) + null + } + } else { + null + } try { process.inputStream.bufferedReader().useLines { lines -> for (line in lines) { @@ -467,6 +491,11 @@ private class ProcessOutputDrainer( runCatching { writer.write(line) writer.newLine() + // Per-line flush so a hung-test + // post-mortem still has the latest + // line on disk. Tests don't run + // long enough for the syscall cost + // to matter (max ~1k lines/test). writer.flush() } }