From ce3a4a7322f35e2e14c05860d49053dac7e4849c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Dec 2025 17:38:31 +0000 Subject: [PATCH] fix: handle all relays disconnecting before EOSE (stuck in LOADING bug) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical Edge Case Fix: Previously, when all relays disconnected before sending EOSE, the state remained stuck in LOADING because overallEoseReceived stayed false. Solution: Check if all relays are in terminal states - Terminal states: eose, error, or disconnected - If all terminal AND no overall EOSE yet, derive state from events: * No events → FAILED * Has events, all disconnected, streaming → OFFLINE * Has events, all disconnected, non-streaming → CLOSED * Some active, some terminal → PARTIAL New Test Coverage (5 tests): 1. All relays disconnect before EOSE, no events → FAILED 2. All relays disconnect before EOSE, with events (streaming) → OFFLINE 3. All relays disconnect before EOSE, with events (non-streaming) → CLOSED 4. Some EOSE, others disconnect before EOSE → PARTIAL 5. Mix of EOSE and errors, all terminal → PARTIAL This fixes the user-reported issue where disconnected relays show LOADING instead of transitioning to appropriate terminal state. Tests: 639/639 passing (added 5 new edge case tests) --- src/lib/req-state-machine.test.ts | 142 ++++++++++++++++++++++++++++++ src/lib/req-state-machine.ts | 28 ++++++ 2 files changed, 170 insertions(+) diff --git a/src/lib/req-state-machine.test.ts b/src/lib/req-state-machine.test.ts index a4430a4..f4f2a13 100644 --- a/src/lib/req-state-machine.test.ts +++ b/src/lib/req-state-machine.test.ts @@ -387,6 +387,148 @@ describe("deriveOverallState", () => { expect(state.disconnectedCount).toBe(15); expect(state.errorCount).toBe(5); }); + + it("NEW: All relays disconnect before EOSE, no events (streaming)", () => { + // THE CRITICAL BUG: Stuck in LOADING when all relays disconnect + const relays = new Map([ + [ + "wss://relay1.com", + { + url: "wss://relay1.com", + connectionState: "disconnected", + subscriptionState: "waiting", // Never got to receiving/eose + eventCount: 0, + }, + ], + [ + "wss://relay2.com", + { + url: "wss://relay2.com", + connectionState: "disconnected", + subscriptionState: "waiting", + eventCount: 0, + }, + ], + ]); + const state = deriveOverallState(relays, false, true, queryStartedAt); + // Should be FAILED, not LOADING + expect(state.status).toBe("failed"); + expect(state.connectedCount).toBe(0); + expect(state.hasReceivedEvents).toBe(false); + }); + + it("NEW: All relays disconnect before EOSE, with events (streaming)", () => { + // Relays sent some events then disconnected before EOSE + const relays = new Map([ + [ + "wss://relay1.com", + { + url: "wss://relay1.com", + connectionState: "disconnected", + subscriptionState: "receiving", // Was receiving + eventCount: 5, + }, + ], + [ + "wss://relay2.com", + { + url: "wss://relay2.com", + connectionState: "disconnected", + subscriptionState: "receiving", + eventCount: 3, + }, + ], + ]); + const state = deriveOverallState(relays, false, true, queryStartedAt); + // Should be OFFLINE (had events but all disconnected) + expect(state.status).toBe("offline"); + expect(state.connectedCount).toBe(0); + expect(state.hasReceivedEvents).toBe(true); + }); + + it("NEW: All relays disconnect before EOSE, with events (non-streaming)", () => { + // Same as above but non-streaming + const relays = new Map([ + [ + "wss://relay1.com", + { + url: "wss://relay1.com", + connectionState: "disconnected", + subscriptionState: "receiving", + eventCount: 5, + }, + ], + ]); + const state = deriveOverallState(relays, false, false, queryStartedAt); + // Should be CLOSED (non-streaming completes) + expect(state.status).toBe("closed"); + expect(state.hasReceivedEvents).toBe(true); + }); + + it("NEW: Some relays EOSE, others disconnect before EOSE", () => { + // Partial success before overall EOSE + const relays = new Map([ + [ + "wss://relay1.com", + { + url: "wss://relay1.com", + connectionState: "connected", + subscriptionState: "eose", + eventCount: 10, + }, + ], + [ + "wss://relay2.com", + { + url: "wss://relay2.com", + connectionState: "disconnected", + subscriptionState: "receiving", + eventCount: 3, + }, + ], + [ + "wss://relay3.com", + { + url: "wss://relay3.com", + connectionState: "error", + subscriptionState: "error", + eventCount: 0, + }, + ], + ]); + const state = deriveOverallState(relays, false, true, queryStartedAt); + // Should be PARTIAL (some succeeded, some failed, but not all terminal) + expect(state.status).toBe("partial"); + expect(state.connectedCount).toBe(1); + expect(state.eoseCount).toBe(1); + }); + + it("NEW: Mix of EOSE and errors, all terminal", () => { + const relays = new Map([ + [ + "wss://relay1.com", + { + url: "wss://relay1.com", + connectionState: "connected", + subscriptionState: "eose", + eventCount: 10, + }, + ], + [ + "wss://relay2.com", + { + url: "wss://relay2.com", + connectionState: "error", + subscriptionState: "error", + eventCount: 0, + }, + ], + ]); + const state = deriveOverallState(relays, false, true, queryStartedAt); + // All terminal (eose + error), should be PARTIAL + expect(state.status).toBe("partial"); + expect(state.connectedCount).toBe(1); + }); }); }); diff --git a/src/lib/req-state-machine.ts b/src/lib/req-state-machine.ts index 851a3f9..9a7d94d 100644 --- a/src/lib/req-state-machine.ts +++ b/src/lib/req-state-machine.ts @@ -55,6 +55,14 @@ export function deriveOverallState( const allEoseAt = overallEoseReceived ? Date.now() : undefined; + // Check if all relays are in terminal states (won't make further progress) + const allRelaysTerminal = states.every( + (s) => + s.subscriptionState === "eose" || + s.connectionState === "error" || + s.connectionState === "disconnected", + ); + // Derive status based on relay states and flags const status: ReqOverallStatus = (() => { // No relays selected yet (NIP-65 discovery in progress) @@ -67,6 +75,26 @@ export function deriveOverallState( return "failed"; } + // All relays are in terminal states (done trying) + // This handles the case where relays disconnect before EOSE + if (allRelaysTerminal && !overallEoseReceived) { + if (!hasReceivedEvents) { + // All relays gave up before sending events + return "failed"; + } + if (!hasActiveRelays) { + // Received events but all relays disconnected before EOSE + if (isStreaming) { + return "offline"; // Was trying to stream, now offline + } else { + return "closed"; // Non-streaming query, relays closed + } + } + // Some relays still active but all others terminated + // This is a partial success scenario + return "partial"; + } + // No relays connected and no events received yet if (!hasActiveRelays && !hasReceivedEvents) { return "connecting";