diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts index 1c1e8b52b..3cb05ae46 100644 --- a/nestsClient/build.gradle.kts +++ b/nestsClient/build.gradle.kts @@ -164,6 +164,10 @@ val interopInstallMoqRelay by tasks.registering(Exec::class) { "--root", hangInteropCacheDir.asFile.absolutePath, "--locked", ) + // See `interopBuildSidecars` — a transitive `*-sys` crate (rustls' + // aws-lc-sys) builds a C library via CMake whose CMakeLists.txt + // predates CMake 4's minimum-version floor. + environment("CMAKE_POLICY_VERSION_MINIMUM", "3.5") val installed = hangInteropCacheDir.dir("bin").file( if (org.gradle.internal.os.OperatingSystem.current().isWindows) "moq-relay.exe" else "moq-relay", @@ -184,6 +188,9 @@ val interopInstallMoqTokenCli by tasks.registering(Exec::class) { "--root", hangInteropCacheDir.asFile.absolutePath, "--locked", ) + // See `interopBuildSidecars` — CMake 4 vs. a stale bundled + // CMakeLists.txt in a transitive `*-sys` crate. + environment("CMAKE_POLICY_VERSION_MINIMUM", "3.5") val installed = hangInteropCacheDir.dir("bin").file( if (org.gradle.internal.os.OperatingSystem.current().isWindows) "moq-token-cli.exe" else "moq-token-cli", @@ -199,6 +206,13 @@ val interopBuildSidecars by tasks.registering(Exec::class) { group = "interop" workingDir = hangInteropDir.asFile commandLine("cargo", "build", "--release") + // `audiopus_sys` bundles libopus, whose CMakeLists.txt declares + // `cmake_minimum_required(VERSION <3.5)`. CMake 4.x removed + // compatibility with that, so the build script panics. CMake reads + // this env var (added in 3.31) as the floor policy version, which + // lets the stale libopus config run unchanged. Harmless on older + // CMake that predates CMake 4's removal. + environment("CMAKE_POLICY_VERSION_MINIMUM", "3.5") // Track only manifests + sources; the `target/` subtree is the // output, including it as an input would mark the task always // out-of-date. @@ -227,6 +241,21 @@ tasks.withType().configureEach { val cargoBin = hangInteropCacheDir.dir("bin").asFile systemProperty("nestsHangInteropSidecarsDir", sidecarRelease.absolutePath) systemProperty("nestsHangInteropCargoBinDir", cargoBin.absolutePath) + // `club.minnced:opus-java` 1.1.1 ships only an x86_64 darwin dylib, + // so its in-jar loader reports unsupported on Apple Silicon. Hand + // JNA an extra search directory so a Homebrew-installed arm64 + // libopus (`brew install opus`, /opt/homebrew/opt/opus/lib) wins + // over the bundled binary. Append rather than replace to preserve + // any caller-supplied path; the existing system search still covers + // other platforms. Silently skipped if Homebrew isn't installed — + // tests on Intel mac / linux take the in-jar path unchanged. + val brewOpusLib = file("/opt/homebrew/opt/opus/lib") + if (brewOpusLib.exists()) { + val existing = System.getProperty("jna.library.path") + val combined = + if (existing.isNullOrEmpty()) brewOpusLib.absolutePath else "$existing:${brewOpusLib.absolutePath}" + systemProperty("jna.library.path", combined) + } // Per-method moq-relay trace log dir for the routing-race // investigation (plan 2026-05-07-moq-relay-routing-investigation.md). // Off by default; opt in via -DnestsHangInteropTraceRelay=true so a diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/HotSwappablePublisherSource.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/HotSwappablePublisherSource.kt index 1a895d238..7d7eb0626 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/HotSwappablePublisherSource.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/HotSwappablePublisherSource.kt @@ -56,6 +56,27 @@ internal interface HotSwappablePublisherSource { startSequence: Long = 0L, ): MoqLitePublisherHandle + /** + * Flip the speaker's outward state to [NestsSpeakerState.Broadcasting]. + * + * The hot-swap pump owns its own long-lived broadcaster and opens + * publishers via [openPublisherForHotSwap] instead of going through + * [MoqLiteNestsSpeaker.startBroadcasting], so the speaker's state + * machine never sees the Connected → Broadcasting transition that + * `startBroadcasting` performs. Without this call the reconnect + * wrapper — which mirrors the underlying speaker's state — stays + * stuck on Connected forever and callers waiting on Broadcasting + * (the production VM, interop tests) time out. + * + * Called once per session iteration after the audio publisher is + * installed, so a freshly-swapped session re-enters Broadcasting + * too. No-op when the speaker is already terminal. + * + * @param isMuted the user's current mute intent, replayed onto the + * new state so the swap doesn't reset the mute indicator. + */ + fun reportBroadcasting(isMuted: Boolean) + /** * Surface a broadcast-pipeline terminal failure (e.g. sustained * `publisher.send` errors past diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt index 74c0766af..132bbccab 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt @@ -229,6 +229,37 @@ class MoqLiteNestsSpeaker internal constructor( } } + /** + * [HotSwappablePublisherSource] implementation. The hot-swap pump + * drives publishing through [openPublisherForHotSwap] rather than + * [startBroadcasting], so it has to flip the state machine into + * Broadcasting itself. Connected → Broadcasting on first call; + * re-applies the mute intent if already Broadcasting (a session + * swap re-enters this with the new session still in Connected, so + * the common case is the Connected branch). No-op once terminal. + */ + override fun reportBroadcasting(isMuted: Boolean) { + val current = mutableState.value + mutableState.value = + when (current) { + is NestsSpeakerState.Connected -> { + NestsSpeakerState.Broadcasting( + room = current.room, + negotiatedMoqVersion = current.negotiatedMoqVersion, + isMuted = isMuted, + ) + } + + is NestsSpeakerState.Broadcasting -> { + current.copy(isMuted = isMuted) + } + + else -> { + return + } + } + } + /** * Called from the broadcaster's `onTerminalFailure` callback (off * the speaker's coroutine). Transitions the speaker to `Failed` so diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt index c4e6b3602..a2e894fd3 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt @@ -458,8 +458,33 @@ private class ReconnectingHandle( var emitted = 0L var droppedStale = 0L var handleHighestGroup = -1L + var firstObjectOnHandle = true try { handle.objects.collect { obj -> + if (firstObjectOnHandle) { + firstObjectOnHandle = false + // Publisher-side session restart detection. + // The watermark assumes monotonic group + // lineage across publisher sessions, which + // holds for ReconnectingNestsSpeaker (it + // seeds each new publisher with the prior + // nextSequence). External publishers — the + // kixelated/hang-publish reference, any + // moq-lite publisher that mints a fresh + // state per reconnect — restart at 0. If + // the first group on a fresh subscription + // arrives well below the watermark, the + // publisher restarted, NOT the relay + // replaying its cache (a replay carries + // exactly the prior max, not a value + // many groups below). Reset the watermark + // so the entire post-restart stream isn't + // dropped as stale. + val watermark = priorGroupWatermark.get() + if (watermark > 0L && obj.groupId < watermark - 1L) { + priorGroupWatermark.set(-1L) + } + } if (obj.groupId <= priorGroupWatermark.get()) { // Stale group re-served from the relay // cache after a re-subscribe — drop so diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt index 8cfd57665..df0679e27 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt @@ -598,6 +598,14 @@ private class ReissuingBroadcastHandle( if (old != null) runCatching { old.close() } } + // The audio publisher is now live on this session. Flip the + // underlying speaker's state machine into Broadcasting — the + // hot-swap path bypasses `startBroadcasting`, so without this + // the speaker (and the reconnect wrapper that mirrors it) would + // sit on Connected forever. Done every iteration so a swapped + // session — which arrives in Connected — re-enters Broadcasting. + hotSwap.reportBroadcasting(desiredMuted) + // Catalog track on the new session. Keeps the broadcast // discoverable to standards-aligned moq-lite watchers (the // kixelated/moq browser reference) across JWT refresh — without diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/JvmOpusEncoder.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/JvmOpusEncoder.kt index 2f6554b93..ed49fcadc 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/JvmOpusEncoder.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/audio/JvmOpusEncoder.kt @@ -99,10 +99,40 @@ class JvmOpusEncoder( if (nativesLoaded) return synchronized(loadLock) { if (nativesLoaded) return - check(OpusLibrary.isSupportedPlatform()) { - "club.minnced:opus-java natives not available for this platform" + if (OpusLibrary.isSupportedPlatform()) { + check(OpusLibrary.loadFromJar()) { "OpusLibrary.loadFromJar() returned false" } + } else { + // opus-java 1.1.1's bundled `natives/darwin/libopus.dylib` + // is x86_64-only, so `isSupportedPlatform()` returns + // false on Apple Silicon (and on any host the jar + // doesn't ship a binary for). Fall back to a + // system-installed libopus: probe a small set of + // canonical locations and `System.load` it directly + // so its symbols are in the process. JNA's lazy + // [Opus.INSTANCE] init falls back to RTLD_DEFAULT + // for symbol lookup when `jna.library.path` doesn't + // resolve the bare name, so preloading by absolute + // path is the most reliable cross-host approach + // (`brew install opus` on macOS, `apt install libopus0` + // / `yum install opus` on linux). After the load, touch + // [Opus.INSTANCE] so any remaining linkage issue + // surfaces here rather than at the first encode call. + val candidates = + listOf( + "/opt/homebrew/opt/opus/lib/libopus.dylib", + "/usr/local/opt/opus/lib/libopus.dylib", + "/usr/lib/x86_64-linux-gnu/libopus.so.0", + "/usr/lib/aarch64-linux-gnu/libopus.so.0", + "/usr/lib64/libopus.so.0", + ) + val found = candidates.firstOrNull { java.io.File(it).exists() } + checkNotNull(found) { + "club.minnced:opus-java natives not available for this platform " + + "and no system libopus found at any of: $candidates" + } + System.load(found) + Opus.INSTANCE } - check(OpusLibrary.loadFromJar()) { "OpusLibrary.loadFromJar() returned false" } nativesLoaded = true } } diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt index 5aae2cabc..2029b2c14 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt @@ -195,7 +195,6 @@ class NostrNestsHarness private constructor( runDocker(workDir, "up", "-d") try { waitForPort("127.0.0.1", AUTH_HOST_PORT, PORT_READY_TIMEOUT_MS) - waitForPort("127.0.0.1", MOQ_HOST_PORT, PORT_READY_TIMEOUT_MS) // moq-auth's Node runtime opens the listen socket // before its handlers are wired, so the first POST that // arrives in the gap can RST-on-write. Wait for /health @@ -203,6 +202,19 @@ class NostrNestsHarness private constructor( // pipeline is live and avoids the SocketException that // otherwise hits the first test of the second class. waitForHealth("http://127.0.0.1:$AUTH_HOST_PORT/health", PORT_READY_TIMEOUT_MS) + // moq-relay fetches its JWK set from moq-auth at startup + // and exits — with no retry — if that GET is refused. + // The compose file declares no `depends_on`, so on a cold + // `up -d` moq-relay races moq-auth's Node boot, loses, + // logs "failed to GET JWKS … Connection refused", and + // dies; every later QUIC handshake then fails with + // "read loop exited (socket closed or peer closed)". + // Now that moq-auth is confirmed healthy, (re)start + // moq-relay: this is a no-op if it survived the race and + // a clean restart if it didn't, so it always comes up + // against a live auth sidecar. + runDocker(workDir, "up", "-d", "moq-relay") + waitForPort("127.0.0.1", MOQ_HOST_PORT, PORT_READY_TIMEOUT_MS) } catch (t: Throwable) { // Capture container state + recent logs BEFORE tearing // down so the failure message is actually actionable.