fix: handle all relays disconnecting before EOSE (stuck in LOADING bug)

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)
This commit is contained in:
Claude
2025-12-22 17:38:31 +00:00
parent 70651ae29f
commit ce3a4a7322
2 changed files with 170 additions and 0 deletions

View File

@@ -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<string, ReqRelayState>([
[
"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<string, ReqRelayState>([
[
"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<string, ReqRelayState>([
[
"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<string, ReqRelayState>([
[
"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<string, ReqRelayState>([
[
"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);
});
});
});

View File

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