From 7ca4fbab337357f09afbcc0c8acdb9315f7dccb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 23:48:56 +0000 Subject: [PATCH] Cover the dense-second step-past the new guard sits in front of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step-past path had no test, and it is the one thing the ignored-cursor guard could plausibly break: both cases reach the same `delivered == 0` branch. They are told apart by WHERE the events landed — a dense boundary second returns them AT the boundary, so `aboveBoundary` stays 0 while `received` is 1 and the guard holds its fire; only a relay answering ABOVE the boundary is not paging at all. Scripted end to end: a second the relay's page cap can only ever return the head of, the step strictly past it, and the empty EOSEd page below. Also proves the documented cost is still paid rather than silently changed — the unreachable tail of that second is lost, and `downloaded` says so. `event()` grows a nonce so two events can share one `created_at`; the id was derived from the timestamp alone, which collapsed them into one event and made a dense second impossible to script. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HPSzniNdvJxkhRsCe1QcyT --- .../NostrClientFetchAllPagesDrainTest.kt | 71 ++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt index bc949a4826..69b088dc40 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt @@ -75,16 +75,23 @@ class NostrClientFetchAllPagesDrainTest { private val relay = RelayUrlNormalizer.normalize("wss://drain.example.com") - private fun event(createdAt: Long) = - Event( - id = createdAt.toString(16).padStart(64, '0'), - pubKey = "f".repeat(64), - createdAt = createdAt, - kind = 1, - tags = emptyArray(), - content = "e$createdAt", - sig = "0".repeat(128), - ) + /** + * [nonce] distinguishes two events sharing one `created_at`, which the id would + * otherwise collapse into the same event — and a boundary second holding more + * than one is the whole subject of the dense-second test below. + */ + private fun event( + createdAt: Long, + nonce: String = "", + ) = Event( + id = (createdAt.toString(16) + nonce).padStart(64, '0'), + pubKey = "f".repeat(64), + createdAt = createdAt, + kind = 1, + tags = emptyArray(), + content = "e$createdAt$nonce", + sig = "0".repeat(128), + ) @Test fun anEmptyPageConfirmedByEoseDrains() = @@ -227,6 +234,50 @@ class NostrClientFetchAllPagesDrainTest { // ---- termination: the walk must END, whatever the relay does ------------- + @Test + fun aBoundarySecondDenserThanAPageIsStillSteppedPast() = + runBlocking { + // The step-past path itself, which had no test and which the + // ignored-cursor guard now sits in front of. The two look identical + // from `delivered == 0` and must NOT be treated alike: a dense second + // returns events AT the boundary, so `aboveBoundary` stays 0 while + // `received` is 1, the guard holds its fire, and the walk steps past + // exactly as before. Only a relay answering ABOVE the boundary — which + // is not paging at all — trips it. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000, "a"), false, relay, null) + client.listener!!.onEose(relay, null) + + // Page two re-asks second 1000 inclusively. The relay's page cap + // hands back the same head of that second — event "b" living + // there too can never be reached. Nothing new: stuck. + client.awaitPage(2) + client.listener!!.onEvent(event(1000, "a"), false, relay, null) + client.listener!!.onEose(relay, null) + + // So the walk steps strictly past to 999 and finds the corpus + // ends there. + client.awaitPage(3) + client.listener!!.onEose(relay, null) + } + + val result = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + ) { } + feeder.join() + + assertEquals(2, result.downloaded, "the duplicate is dropped, the dense second's tail is the documented loss") + assertEquals(3, client.subscribeCount, "it stepped past the stuck second instead of stopping on it") + assertEquals(PagedFetchResult.End.DRAINED, result.end, "and reached a genuinely empty, EOSEd page below it") + } + @Test fun aRelayThatIgnoresTheCursorEndsTheWalkInsteadOfSteppingForever() = runBlocking {