Let a monitor publish what it learned without dialling

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-01 09:42:00 -04:00
co-authored by Claude Opus 5
parent da009f36bd
commit c3c20c6615
2 changed files with 88 additions and 0 deletions
@@ -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.
*
@@ -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()