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";