From f64b2e6f1cf489d172b426185949a36097736656 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 05:42:46 +0000 Subject: [PATCH] fix(quartz): record REQ state before send in PoolRequests.syncState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On connect, syncFilters re-sends every desired REQ through PoolRequests.syncState. It previously sent the frame and only recorded the subscription as SENT afterward, in the post-send onSent callback. A relay that answers faster than that callback runs — the in-process transport used by the desktop launch-optimization tests, or any relay on a fast path — can deliver the EOSE while the per-sub state still reads "nothing in flight" (onConnecting cleared it, onSent hasn't recorded it). The EOSE handler then sees empty filters, concludes it never sent a REQ, and fires a duplicate, replaying the whole page a second time. Pre-mark the sub as SENT under its lock before the frame leaves, mirroring the decideCommandLocked pre-mark already used by sendToRelayIfChanged, so a response can never race ahead of the record. The send stays unconditional: this is a fresh-connection sync (onConnecting always cleared the per-relay state first), so there is no in-flight REQ on the new socket to dedupe against. Fixes the flaky SubscribeBeforeConnectTest, which asserted a pre-connect subscription delivers exactly its events once and intermittently saw them doubled. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W4kmtZoNUSXxwG23wD2JwP --- .../relay/client/pool/PoolRequests.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 289d62300e..7dfa5a5333 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -295,9 +295,26 @@ class PoolRequests { relay: NormalizedRelayUrl, sync: (Command) -> Unit, ) { - desiredSubs.forEach { subId, filters -> - val filters = filters[relay] + // Pre-mark the subscription as SENT (recording the filters) *before* + // the frame leaves, so a response can never race ahead of the record. + // A fast relay — the in-process transport, or any relay that answers + // before the sender's post-send onSent callback runs — can deliver the + // EOSE for this REQ while the state still reads "nothing in flight" + // (onConnecting cleared it and onSent hasn't recorded it yet). The EOSE + // handler would then see empty filters, conclude it never sent a REQ, + // and fire a duplicate — replaying the whole page a second time. + // Recording under the lock up front closes that window. + // + // This is a fresh-connection sync (onConnected → onConnecting always + // cleared the per-relay state first), so every desired filter is + // (re)sent unconditionally: unlike the change-driven path there is no + // in-flight REQ on this brand-new socket to dedupe against. + desiredSubs.forEach { subId, perRelayFilters -> + val filters = perRelayFilters[relay] if (!filters.isNullOrEmpty()) { + subState(subId).let { state -> + state.withLock { state.onOpenReq(relay, filters) } + } sync(ReqCmd(subId, filters)) } }