From 6760ba450c3ff04cb006047435ebff46cdbb3e74 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 19:22:43 +0000 Subject: [PATCH] fix: FTS catch-up worker must not leak an uncaught exception at shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker's writer-mutex fast path can slip past its own scope cancellation and run one batch against a store that close() already freed. The resulting closed-connection exception escaped scope.launch under a SupervisorJob with no handler — harmless in production, but the kotlinx-coroutines-test global handler attributes it to whatever runTest starts next (seen on CI as EventSourceServerTest.countUsesSource failing with UncaughtExceptionsBeforeTest). Catch, log, and stop the pass: nothing depends on it — the pre-search drain covers search correctness. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46 --- .../nip01Core/relay/server/NostrServer.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt index ffbe105fde..1ec4386619 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt @@ -29,6 +29,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel @@ -113,7 +115,22 @@ class NostrServer( // the burst's final batch commit pokes again, and the // pre-search drain covers correctness regardless. while (!ingest.hasBacklog()) { - if (store.ftsCatchUp()) break + val done = + try { + store.ftsCatchUp() + } catch (e: Throwable) { + if (e is CancellationException) throw e + // A shutdown can close the store between this + // worker's cancellation and its last batch (the + // mutex fast path skips the cancellation check), + // and an uncaught throw here poisons unrelated + // runTest tests via the global handler. Nothing + // depends on this pass — the pre-search drain + // covers correctness — so log and stop. + Log.w("NostrServer") { "FTS catch-up batch failed: ${e.message}" } + break + } + if (done) break } } }