fix(desktop): recover relay-closed subscriptions (#2060)

Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-17 20:51:17 -04:00
committed by GitHub
co-authored by npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent 7ba13bfa5d
commit 79598bdb3f
8 changed files with 261 additions and 28 deletions
+30 -28
View File
@@ -1,5 +1,4 @@
import { Channel, invoke } from "@tauri-apps/api/core";
import {
createAuthEvent,
getRelayWsUrl,
@@ -15,7 +14,6 @@ import {
} from "@/shared/constants/kinds";
import {
getTextPayload,
sortEvents,
type ConnectionState,
type PendingEvent,
type RelaySubscription,
@@ -30,6 +28,12 @@ import {
buildGlobalStreamFilter,
} from "@/shared/api/relayChannelFilters";
import { collectWithConcurrency } from "@/shared/api/concurrency";
import {
clearClosedRetry,
handleRelayClosed,
handleSubscriptionEose,
prepareSubscriptionEvent,
} from "@/shared/api/relayClosedRecovery";
import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay";
import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter";
import {
@@ -39,7 +43,6 @@ import {
import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog";
import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
const RECONNECT_BASE_DELAY_MS = 1_000,
RECONNECT_MAX_DELAY_MS = 30_000,
EVENT_BATCH_MS = 16,
@@ -135,6 +138,8 @@ export class RelayClient {
if (sub.mode === "history") {
window.clearTimeout(sub.timeout);
sub.reject(error);
} else {
clearClosedRetry(sub);
}
this.subscriptions.delete(subId);
}
@@ -596,6 +601,7 @@ export class RelayClient {
}
this.subscriptions.delete(subId);
clearClosedRetry(active);
await this.closeSubscription(subId);
};
}
@@ -769,6 +775,20 @@ export class RelayClient {
if (type === "EOSE" && typeof rest[0] === "string") {
this.handleEose(rest[0]);
return;
}
if (type === "CLOSED" && typeof rest[0] === "string") {
handleRelayClosed({
subscriptions: this.subscriptions,
subId: rest[0],
message: typeof rest[1] === "string" ? rest[1] : "",
sendReq: (subId, filter) =>
this.sendRawWithReconnectRetry(
["REQ", subId, filter],
"Failed to restore relay subscription after CLOSED.",
),
});
}
}
@@ -796,16 +816,7 @@ export class RelayClient {
return;
}
if (subscription.mode === "history") {
subscription.events.push(event);
return;
}
subscription.lastSeenCreatedAt = Math.max(
subscription.lastSeenCreatedAt ?? 0,
event.created_at,
);
if (!prepareSubscriptionEvent(subscription, event)) return;
this.eventBuffer.push({ subId, event });
this.flushTimeout ??= window.setTimeout(
() => this.flushEventBuffer(),
@@ -828,21 +839,11 @@ export class RelayClient {
}
private handleEose(subId: string) {
const subscription = this.subscriptions.get(subId);
if (!subscription) {
return;
}
if (subscription.mode === "live") {
subscription.resolveReady?.();
subscription.resolveReady = undefined;
return;
}
window.clearTimeout(subscription.timeout);
this.subscriptions.delete(subId);
void this.closeSubscription(subId);
subscription.resolve(sortEvents(subscription.events));
handleSubscriptionEose({
subscriptions: this.subscriptions,
subId,
closeSubscription: (id) => this.closeSubscription(id),
});
}
private handleOk(eventId: string, success: boolean, message: string) {
@@ -1004,6 +1005,7 @@ export class RelayClient {
subscription.resolveReady?.();
subscription.resolveReady = undefined;
clearClosedRetry(subscription);
}
for (const [eventId, pendingEvent] of this.pendingEvents) {
@@ -52,6 +52,8 @@ type LiveSubscription = {
onEvent: (event: RelayEvent) => void;
resolveReady?: () => void;
lastSeenCreatedAt?: number;
closedRetryAttempt?: number;
closedRetryTimeout?: number;
};
export type PendingEvent = {
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isRetryableRelayClosed } from "./relayClosedPolicy.ts";
test("retries transient CLOSED responses", () => {
for (const message of [
"rate-limited: slow down",
"error: database error",
"server shutting down",
"",
]) {
assert.equal(isRetryableRelayClosed(message), true, message);
}
});
test("does not retry permanent CLOSED responses", () => {
for (const message of [
"restricted: not a channel member",
"restricted: channel access revoked",
"auth-required: not authenticated",
"blocked: banned",
"invalid: malformed filter",
"pow: difficulty too low",
"duplicate: subscription exists",
"unsupported: filter",
"error: mixed search and non-search filters not supported",
"error: too many subscriptions",
]) {
assert.equal(isRetryableRelayClosed(message), false, message);
}
});
@@ -0,0 +1,19 @@
/**
* CLOSED ends a NIP-01 subscription. Retry failures that may recover without
* changing the request; authorization and malformed-filter failures require a
* caller/state change and would otherwise loop forever.
*/
export function isRetryableRelayClosed(message: string) {
const normalized = message.trim().toLowerCase();
return !(
normalized.startsWith("restricted:") ||
normalized.startsWith("auth-required:") ||
normalized.startsWith("blocked:") ||
normalized.startsWith("invalid:") ||
normalized.startsWith("pow:") ||
normalized.startsWith("duplicate:") ||
normalized.startsWith("unsupported:") ||
normalized.startsWith("error: mixed search") ||
normalized.startsWith("error: too many subscriptions")
);
}
@@ -0,0 +1,133 @@
import { isRetryableRelayClosed } from "@/shared/api/relayClosedPolicy";
import {
sortEvents,
type RelaySubscription,
type RelaySubscriptionFilter,
} from "@/shared/api/relayClientShared";
import type { RelayEvent } from "@/shared/api/types";
const RETRY_BASE_DELAY_MS = 1_000;
const RETRY_MAX_DELAY_MS = 30_000;
type LiveSubscription = Extract<RelaySubscription, { mode: "live" }>;
export function clearClosedRetry(subscription: LiveSubscription) {
if (subscription.closedRetryTimeout === undefined) return;
window.clearTimeout(subscription.closedRetryTimeout);
subscription.closedRetryTimeout = undefined;
}
export function handleRelayClosed({
subscriptions,
subId,
message,
sendReq,
}: {
subscriptions: Map<string, RelaySubscription>;
subId: string;
message: string;
sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise<void>;
}) {
const subscription = subscriptions.get(subId);
if (!subscription) return;
if (subscription.mode === "history") {
window.clearTimeout(subscription.timeout);
subscriptions.delete(subId);
subscription.reject(
new Error(message || "Relay closed the history subscription."),
);
return;
}
recoverLiveSubscriptionFromClosed({
subscriptions,
subId,
subscription,
message,
sendReq,
});
}
function recoverLiveSubscriptionFromClosed({
subscriptions,
subId,
subscription,
message,
sendReq,
}: {
subscriptions: Map<string, RelaySubscription>;
subId: string;
subscription: LiveSubscription;
message: string;
sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise<void>;
}) {
subscription.resolveReady?.();
subscription.resolveReady = undefined;
if (!isRetryableRelayClosed(message)) {
subscriptions.delete(subId);
return;
}
if (subscription.closedRetryTimeout !== undefined) return;
const attempt = subscription.closedRetryAttempt ?? 0;
const delayMs = Math.min(
RETRY_BASE_DELAY_MS * 2 ** attempt,
RETRY_MAX_DELAY_MS,
);
subscription.closedRetryAttempt = attempt + 1;
subscription.closedRetryTimeout = window.setTimeout(() => {
subscription.closedRetryTimeout = undefined;
if (subscriptions.get(subId) !== subscription) return;
void sendReq(subId, subscription.filter).catch((error) => {
if (subscriptions.get(subId) !== subscription) return;
console.error("Failed to restore closed relay subscription", error);
recoverLiveSubscriptionFromClosed({
subscriptions,
subId,
subscription,
message,
sendReq,
});
});
}, delayMs);
}
export function prepareSubscriptionEvent(
subscription: RelaySubscription,
event: RelayEvent,
) {
if (subscription.mode === "history") {
subscription.events.push(event);
return false;
}
subscription.closedRetryAttempt = 0;
clearClosedRetry(subscription);
subscription.lastSeenCreatedAt = Math.max(
subscription.lastSeenCreatedAt ?? 0,
event.created_at,
);
return true;
}
export function handleSubscriptionEose({
subscriptions,
subId,
closeSubscription,
}: {
subscriptions: Map<string, RelaySubscription>;
subId: string;
closeSubscription: (subId: string) => Promise<void>;
}) {
const subscription = subscriptions.get(subId);
if (!subscription) return;
if (subscription.mode === "live") {
subscription.resolveReady?.();
subscription.resolveReady = undefined;
subscription.closedRetryAttempt = 0;
clearClosedRetry(subscription);
return;
}
window.clearTimeout(subscription.timeout);
subscriptions.delete(subId);
void closeSubscription(subId);
subscription.resolve(sortEvents(subscription.events));
}
+14
View File
@@ -164,6 +164,8 @@ type E2eConfig = {
applyCommunityDelayMs?: number;
openDmDelayMs?: number;
sendMessageDelayMs?: number;
/** Close the first channel-window live REQ; its retry is accepted. */
closeChannelLiveSubscriptionOnce?: boolean;
/** Reject successive kind-9 sends with these messages, then resume. */
sendMessageErrors?: string[];
/** Reject successive managed-agent starts, then resume. */
@@ -2563,6 +2565,7 @@ const mockReminderEvents: RelayEvent[] = [];
let mockRelayMembers: RawRelayMember[] = [];
const mockSockets = new Map<number, MockSocket>();
let mockWebsocketSendMutexWedged = false;
let mockClosedChannelLiveSubscription = false;
const realSockets = new Map<number, WebSocket>();
let mockManagedAgents: MockManagedAgent[] = [];
@@ -8234,6 +8237,16 @@ function sendToMockSocket(args: {
channelIds.size === 1
? (channelIds.values().next().value as string)
: undefined;
if (
getConfig()?.mock?.closeChannelLiveSubscriptionOnce &&
!mockClosedChannelLiveSubscription &&
onlyChannelId &&
kinds.has(KIND_CHANNEL_THREAD_SUMMARY)
) {
mockClosedChannelLiveSubscription = true;
sendWsText(socket.handler, ["CLOSED", subId, "rate-limited"]);
return;
}
socket.subscriptions.set(subId, {
channelId: onlyChannelId ?? GLOBAL_MOCK_SUBSCRIPTION,
kinds: kinds.size > 0 ? [...kinds] : null,
@@ -8451,6 +8464,7 @@ export function maybeInstallE2eTauriMocks() {
return;
}
mockClosedChannelLiveSubscription = false;
mockGlobalAgentConfig = config.mock?.globalAgentConfig
? { ...config.mock.globalAgentConfig }
: null;
+29
View File
@@ -1158,6 +1158,35 @@ test("thread refetch preserves a live reply and reaction received in flight", as
await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible();
});
test("thread reply appears after relay closes and restores its live subscription", async ({
page,
}) => {
await installMockBridge(page, { closeChannelLiveSubscriptionOnce: true });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const seed = `Thread CLOSED seed ${Date.now()}`;
await page.getByTestId("message-input").fill(seed);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(seed);
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
const reply = `Thread reply after CLOSED ${Date.now()}`;
await threadPanel.getByTestId("message-input").fill(reply);
await page.waitForTimeout(1_100);
await threadPanel.getByTestId("send-message").click();
await expect(threadPanel).toContainText(reply);
});
test("thread composer keeps focus after sending a thread reply", async ({
page,
}) => {
+2
View File
@@ -184,6 +184,8 @@ type MockBridgeOptions = {
applyCommunityDelayMs?: number;
openDmDelayMs?: number;
sendMessageDelayMs?: number;
/** Close the first channel-window live REQ; its retry is accepted. */
closeChannelLiveSubscriptionOnce?: boolean;
/** Reject successive kind-9 sends with these messages, then resume. */
sendMessageErrors?: string[];
/** Reject successive managed-agent starts, then resume. */