diff --git a/quartz/plans/2026-07-27-negentropy-notice-rejection-stall.md b/quartz/plans/2026-07-27-negentropy-notice-rejection-stall.md index 064d4b0d69..54543c91e6 100644 --- a/quartz/plans/2026-07-27-negentropy-notice-rejection-stall.md +++ b/quartz/plans/2026-07-27-negentropy-notice-rejection-stall.md @@ -112,6 +112,33 @@ purplepag.es answer ordinary REQs fine, so paging delivers the events). watchdog — which now fires correctly because the refusal chatter no longer resets it. +## Live validation across 30 public relays + +`NegentropyMultiRelayLiveTest` (gated `NEG_MULTI=1`) runs `negentropySyncOrFetch` +(`kinds:[0]`, `maxEvents=100`, `idleTimeoutMs=20s`) against 30 reachable relays and +fails if any HANGs. Result: **0 hangs, 0 errors** — 10 reconciled via native +negentropy, 20 fell over to paging. Nine+ relay softwares (strfry, ditto, +purplepag.es, nostr.wine, nostr-rs-relay, NFDB, rockstr, wot-relay, nostrcheck). + +Both fallback mechanisms fire as designed: + +- **NOTICE fast-path (~1-4 s):** `relay.primal.net` (`negentropy disabled`), + `purplepag.es` / `wot.utxo.one` (`unknown envelope`), `relay.nostrplebs.com` / + `relay.0xchat.com` (`negentropy error …`). +- **Idle-watchdog backstop (~20 s):** relays whose refusal wording the (deliberately + narrow) matcher skips still fail over because NOTICE/CLOSED no longer bump the + clock — `relay.damus.io` (silently ignores NEG-OPEN, no error at all), + `relay.snort.social` (`Unknown message type: NEG-OPEN`), `relay.momostr.pink`, + `relay.wellorder.net` (`could not parse command`), `relay.nostrcheck.me`, … +- **Overflow window-split (native):** `nos.lol`, `relay.ditto.pub`, + `nostr.oxtr.dev` returned `NEG-ERR "too many … records/results"` → split → + downloaded natively. + +Takeaway: the narrow NOTICE matcher plus the un-defeated idle watchdog is the right +split — the fast-path speeds up the relays whose wording is unambiguous, and the +watchdog safely (if more slowly) catches everything else, with no false aborts on +the 10 relays that genuinely speak NIP-77. + ## Follow-up audit (same PR) A read-through of the whole negentropy accessories package surfaced a few more diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyMultiRelayLiveTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyMultiRelayLiveTest.kt new file mode 100644 index 0000000000..1c72b53690 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyMultiRelayLiveTest.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.prodbench + +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySyncOrFetch +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.fail + +/** + * Broad live validation of the NIP-77 stall fix: run `negentropySyncOrFetch` against + * as many public relays as we can reach and assert NONE hangs — every relay must + * either reconcile via negentropy or fall over to paging, and the suspend fun must + * return without an external wall-clock rescue. + * + * Gated (opens live sockets to ~35 relays): + * NEG_MULTI=1 ./gradlew :quartz:jvmTest --tests "*.NegentropyMultiRelayLiveTest" --info + * + * The wall-clock [HARD_TIMEOUT_MS] is only a safety net set well above [IDLE_MS]: + * with the fix a refusing relay trips the idle watchdog (or the notice/closed + * fast-path) and returns; a relay that only returns because the wall clock fired is + * the bug this guards against and fails the test. + */ +class NegentropyMultiRelayLiveTest { + companion object { + const val MAX_EVENTS = 100 + const val IDLE_MS = 20_000L + const val HARD_TIMEOUT_MS = 90_000L + const val PARALLELISM = 6 + + val RELAYS = + listOf( + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", + "wss://relay.nostr.band", + "wss://nostr.wine", + "wss://purplepag.es", + "wss://relay.ditto.pub", + "wss://nostr.mom", + "wss://relay.nostr.bg", + "wss://offchain.pub", + "wss://nostr.oxtr.dev", + "wss://relay.nostrplebs.com", + "wss://nostr21.com", + "wss://relay.mostr.pub", + "wss://nostr.bitcoiner.social", + "wss://relay.nostrcheck.me", + "wss://nostr-pub.wellorder.net", + "wss://relay.snort.social", + "wss://relayable.org", + "wss://nostr.land", + "wss://relay.momostr.pink", + "wss://relay.0xchat.com", + "wss://nostr.fmt.wiz.biz", + "wss://wot.utxo.one", + "wss://nostr.data.haus", + "wss://eden.nostr.land", + "wss://atlas.nostr.land", + "wss://relay.nostr.com.au", + "wss://relay.wellorder.net", + "wss://relay.fountain.fm", + ) + } + + private class RelayProbe { + val negMsgs = AtomicInteger(0) + val negErrs = AtomicInteger(0) + + @Volatile var firstNegErr: String? = null + + @Volatile var firstNotice: String? = null + } + + private class Outcome( + val relay: String, + val label: String, + val downloaded: Int, + val ms: Long, + val detail: String, + ) + + @Test + fun everyReachableRelayReturnsNoHang() { + if (System.getenv("NEG_MULTI") == null && System.getProperty("negMulti") == null) { + println("NegentropyMultiRelayLiveTest skipped. Set NEG_MULTI=1 to run against live relays.") + return + } + + val httpClient = + OkHttpClient + .Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .build() + + val outcomes = + runBlocking { + val gate = Semaphore(PARALLELISM) + RELAYS + .map { relay -> + async(Dispatchers.IO) { + gate.withPermit { probeRelay(relay, httpClient) } + } + }.awaitAll() + } + + println("\n================ NIP-77 multi-relay results ================") + println("%-26s %-14s %10s %8s %s".format("relay", "outcome", "downloaded", "ms", "detail")) + outcomes.sortedBy { it.label }.forEach { + println( + "%-26s %-14s %10d %8d %s".format( + it.relay.removePrefix("wss://"), + it.label, + it.downloaded, + it.ms, + it.detail, + ), + ) + } + + val byLabel = outcomes.groupingBy { it.label }.eachCount() + println("\nsummary: $byLabel") + + val hangs = outcomes.filter { it.label == "HANG" } + if (hangs.isNotEmpty()) { + fail("These relays HUNG (no return within ${HARD_TIMEOUT_MS}ms) — the stall is not fixed for them:\n" + hangs.joinToString("\n") { " ${it.relay}: ${it.detail}" }) + } + } + + private suspend fun probeRelay( + relay: String, + httpClient: OkHttpClient, + ): Outcome { + val probe = RelayProbe() + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val client = NostrClient(BasicOkHttpWebSocket.Builder { httpClient }, scope) + + val listener = + object : RelayConnectionListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is NegMsgMessage -> probe.negMsgs.incrementAndGet() + is NegErrMessage -> { + probe.negErrs.incrementAndGet() + if (probe.firstNegErr == null) probe.firstNegErr = msg.reason + } + is NoticeMessage -> if (probe.firstNotice == null) probe.firstNotice = msg.message + else -> Unit + } + } + } + client.addConnectionListener(listener) + + val started = System.currentTimeMillis() + return try { + val downloaded = AtomicInteger(0) + val result = + withTimeoutOrNull(HARD_TIMEOUT_MS) { + client.negentropySyncOrFetch( + relay = relay, + filter = Filter(kinds = listOf(0)), + maxEvents = MAX_EVENTS, + idleTimeoutMs = IDLE_MS, + localEntries = emptyList(), + onEvent = { downloaded.incrementAndGet() }, + ) + } + val ms = System.currentTimeMillis() - started + val wire = + "negMsg=${probe.negMsgs.get()} negErr=${probe.negErrs.get()}" + + (probe.firstNegErr?.let { " err=\"${it.take(48)}\"" } ?: "") + + (probe.firstNotice?.let { " notice=\"${it.take(48)}\"" } ?: "") + + when { + result == null -> + Outcome(relay, "HANG", downloaded.get(), ms, "no return; $wire") + result.pagedFallback -> + Outcome(relay, "OK-PAGED", result.downloaded, ms, "fell back to paging; $wire") + result.downloaded > 0 -> + Outcome(relay, "OK-NEG", result.downloaded, ms, "native negentropy; $wire") + else -> + Outcome(relay, "OK-EMPTY", result.downloaded, ms, "returned, 0 events; $wire") + } + } catch (e: Throwable) { + val ms = System.currentTimeMillis() - started + Outcome(relay, "ERROR", 0, ms, "${e::class.simpleName}: ${e.message?.take(60)}") + } finally { + client.close() + } + } +}