From c3c20c661574ea833dfabc4f5d9e00f9e7c90cdb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 1 Aug 2026 09:40:39 -0400 Subject: [PATCH] Let a monitor publish what it learned without dialling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RelayObserver is a RelayConnectionListener, so on its own it can only report on relays something opened a websocket to. On a large fan-out that is a small minority, and it is the wrong minority: the cheap checks that decide NOT to dial — a TCP probe, a DNS failure, a host struck out after repeated silence — are precisely the ones that learn a relay is gone, and their findings had nowhere to go. Measured on a 16,507-relay list: 104 records published. Everything else was ruled out before the client ever saw it, so the monitor had nothing to say about 99% of the relays it had just formed an opinion on. record() takes those findings. Same rules as the connection path — a relay that answered is not demoted by one failed probe, and a reachable relay with no measured time is published with no time rather than a fabricated zero. Co-Authored-By: Claude Opus 5 (1M context) --- .../reachability/RelayObserver.kt | 38 ++++++++++++++ .../reachability/RelayObserverTest.kt | 50 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt index 05f869ce32..bde310489c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt @@ -239,6 +239,44 @@ class RelayObserver : RelayConnectionListener { } } + /** + * Record a measurement taken OUTSIDE the websocket client — a TCP probe, a + * DNS failure, a host struck out after repeated silence. + * + * This class is a [RelayConnectionListener], so on its own it can only report + * on relays something opened a websocket to. On a large fan-out that is a + * small minority, and it is the wrong minority: the cheap checks that decide + * NOT to dial are precisely the ones that learn a relay is gone, and their + * findings had nowhere to go. Measured on a 16,507-relay list — 104 records + * published, because everything else was ruled out before the client saw it. + * + * A monitor that only reports what it happened to connect to is not a census. + * + * [rttOpenMs] is whatever was actually measured; null means reachable with no + * timing, and no timing is ever invented. + */ + fun record( + relay: NormalizedRelayUrl, + reachable: Boolean, + rttOpenMs: Long? = null, + error: String? = null, + ) { + val o = seen.getOrPut(relay) { Observation(relay) } + if (reachable) { + o.reachable = true + o.error = null + // Kept on the Observation, which is never removed — only marked + // reported — so a measurement survives every later flush. + rttOpenMs?.let { o.rttOpenMs = it } + } else { + // Same rule as onCannotConnect: a relay that answered earlier is not + // demoted by one failed probe. The writer decides what record that + // becomes, and "answered, then a probe failed" is not "dead". + o.error = (error ?: "unreachable").take(MAX_TEXT) + } + o.touch() + } + /** * Everything observed since the last call, marked reported as it is read. * diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt index f1192871f7..dbda038e0f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt @@ -200,6 +200,56 @@ class RelayObserverTest { assertEquals(first.rttOpenMs, second.rttOpenMs, "the last real measurement still stands") } + // ---- findings from outside the websocket client ------------------------ + + @Test + fun `a probe failure is published even though nothing was dialled`() { + // The cheap checks that decide NOT to open a websocket are exactly the + // ones that learn a relay is gone. Without a way in, a listener-only + // observer reports on the small minority it happened to connect to — + // 104 records out of a 16,507-relay list — which is not a census. + val o = RelayObserver() + o.record(url, reachable = false, error = "nodename nor servname provided") + + val obs = o.only() + assertFalse(obs.reachable) + assertEquals("nodename nor servname provided", obs.error) + assertNull(obs.rttOpenMs, "a failed probe times nothing") + } + + @Test + fun `a probe that connected reports its measured time or none at all`() { + val timed = RelayObserver() + timed.record(url, reachable = true, rttOpenMs = 42) + assertEquals(42L, timed.only().rttOpenMs) + + val untimed = RelayObserver() + untimed.record(url, reachable = true) + val obs = untimed.only() + assertTrue(obs.reachable) + assertNull(obs.rttOpenMs, "reachable without a timing must not invent one") + } + + @Test + fun `a failed probe does not demote a relay that already answered`() { + // Same rule the connection path follows: one bad probe is not death, and + // only the writer decides what record a mixed history becomes. + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.record(url, reachable = false, error = "connect timeout") + + assertTrue(o.only().reachable, "it answered; a later probe failure does not erase that") + } + + @Test + fun `an out-of-band finding is reported once like any other`() { + val o = RelayObserver() + o.record(url, reachable = false, error = "refused") + assertEquals(1, o.collectUnreported().size) + assertEquals(0, o.collectUnreported().size, "nothing new to say") + } + @Test fun `each relay is observed on its own`() { val o = RelayObserver()