Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions desktop/src/features/channels/unreadReadMarker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "./useUnreadChannels.ts";
import {
isChannelUnreadTriggerKind,
trackSeenEvent,
withChannelTagFallback,
} from "./useLiveChannelUpdates.ts";
import {
Expand Down Expand Up @@ -90,6 +91,16 @@ test("live event with h tag is preserved", () => {
assert.equal(withChannelTagFallback(message, "other-channel"), message);
});

test("notification event guard suppresses reconnect replay and stays bounded", () => {
const seen = new Set();

assert.equal(trackSeenEvent(seen, "event-a", 2), true);
assert.equal(trackSeenEvent(seen, "event-a", 2), false);
assert.equal(trackSeenEvent(seen, "event-b", 2), true);
assert.equal(trackSeenEvent(seen, "event-c", 2), true);
assert.deepEqual([...seen], ["event-b", "event-c"]);
});

test("dmHuddleStart_isDmOnlyUnreadTrigger", () => {
assert.equal(
isChannelUnreadTriggerKind(KIND_HUDDLE_STARTED, true),
Expand Down
98 changes: 56 additions & 42 deletions desktop/src/features/channels/useLiveChannelUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,19 @@ function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) {
);
}

function trackSeenEvent(seenEventIds: Set<string>, eventId: string): boolean {
const SEEN_NOTIFICATION_EVENT_LIMIT = 5_000;

export function trackSeenEvent(
seenEventIds: Set<string>,
eventId: string,
limit = 200,
): boolean {
if (seenEventIds.has(eventId)) {
return false;
}

seenEventIds.add(eventId);
if (seenEventIds.size > 200) {
if (seenEventIds.size > limit) {
const oldestEventId = seenEventIds.values().next().value;
if (oldestEventId) {
seenEventIds.delete(oldestEventId);
Expand All @@ -135,6 +141,11 @@ export function useLiveChannelUpdates(
const normalizedCurrentPubkey =
options.currentPubkey?.trim().toLowerCase() ?? "";
const seenMentionEventIdsRef = React.useRef(new Set<string>());
// Reconnect replay overlaps each live filter by five seconds so no message is
// lost at the boundary. Keep one shared guard for every notification side
// effect: the same event can be replayed repeatedly while a relay flaps, and
// mention events also arrive through both the channel and mention filters.
const seenNotificationEventIdsRef = React.useRef(new Set<string>());
const channelsInvalidateRef = React.useRef<TrailingDebounce | null>(null);
if (channelsInvalidateRef.current === null) {
channelsInvalidateRef.current = createTrailingDebounce(() => {
Expand Down Expand Up @@ -164,7 +175,6 @@ export function useLiveChannelUpdates(
),
[channels],
);
const seenDmEventIdsRef = React.useRef(new Set<string>());
const dmSubscriptionStartedAtRef = React.useRef(0);

// Reset subscription timestamp when identity changes.
Expand All @@ -181,57 +191,49 @@ export function useLiveChannelUpdates(
[channels],
);

const handleDmEvent = React.useEffectEvent((event: RelayEvent) => {
// Only human-visible message kinds should fire DM notifications.
if (!isDmNotifiableKind(event.kind)) {
return;
}

// Suppress backlog events that predate our subscription — these are
// historical replays, not live messages.
if (event.created_at < dmSubscriptionStartedAtRef.current) {
return;
}
const handleDmEvent = React.useEffectEvent(
(event: RelayEvent, isFirstNotificationDelivery: boolean) => {
// Only human-visible message kinds should fire DM notifications.
if (!isDmNotifiableKind(event.kind) || !isFirstNotificationDelivery) {
return;
}

const channelId = getChannelIdFromTags(event.tags);
if (!channelId) {
return;
}
// Suppress backlog events that predate our subscription — these are
// historical replays, not live messages.
if (event.created_at < dmSubscriptionStartedAtRef.current) {
return;
}

if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) {
return;
}
const channelId = getChannelIdFromTags(event.tags);
if (!channelId) {
return;
}

const dmChannel = dmChannelMap.get(channelId);
if (!dmChannel) {
return;
}
if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) {
return;
}

if (!trackSeenEvent(seenDmEventIdsRef.current, event.id)) {
return;
}
const dmChannel = dmChannelMap.get(channelId);
if (!dmChannel) {
return;
}

// Don't fire a notification for the channel the user is already viewing,
// unless the notify-while-viewing setting opts in.
if (channelId === activeChannelId && !options.notifyForActiveChannel) {
return;
}
// Don't fire a notification for the channel the user is already viewing,
// unless the notify-while-viewing setting opts in.
if (channelId === activeChannelId && !options.notifyForActiveChannel) {
return;
}

options.onDmMessage?.(event, dmChannel);
});
options.onDmMessage?.(event, dmChannel);
},
);

const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => {
const channelId = getChannelIdFromTags(event.tags);
if (!channelId) {
return;
}

// Track DM events even for the active channel so the dedup set stays
// current. The handler itself skips firing the notification callback
// when the user is already viewing the DM (unless opted in via
// notifyForActiveChannel).
handleDmEvent(event);

if (!liveChannelIds.has(channelId)) {
if (channelId !== activeChannelId) {
invalidateChannelsDebounced();
Expand Down Expand Up @@ -263,9 +265,21 @@ export function useLiveChannelUpdates(
isUnreadTriggerKind &&
(normalizedCurrentPubkey.length === 0 ||
event.pubkey.toLowerCase() !== normalizedCurrentPubkey);
const isFirstNotificationDelivery =
!isExternalTriggerEvent ||
trackSeenEvent(
seenNotificationEventIdsRef.current,
event.id,
SEEN_NOTIFICATION_EVENT_LIMIT,
);
const isThreadedReply = isThreadReply(event.tags);

if (isExternalTriggerEvent) {
// DM alerts and every other notification side effect share this delivery
// decision, preventing a replayed event from escaping through a second
// callback path.
handleDmEvent(event, isFirstNotificationDelivery);

if (isExternalTriggerEvent && isFirstNotificationDelivery) {
const shouldNotify = shouldNotifyForEvent(
event,
normalizedCurrentPubkey,
Expand Down
94 changes: 47 additions & 47 deletions desktop/src/shared/api/relayClientSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,34 +49,22 @@ import {
isWebSocketClose,
shouldRefuseConnect,
shouldScheduleReconnect,
shouldWaitForScheduledReconnect,
} from "@/shared/api/relayReconnectPolicy";
import { RelayReconnectWaiters } from "@/shared/api/relayReconnectWaiters";
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;

/**
* Op-level timeout constants. Raised from 8 s to 25 s to survive degraded
* networks where TLS handshakes and DNS resolution can take 3–10 s.
*/
export const AUTH_TIMEOUT_MS = 25_000;
export const HISTORY_TIMEOUT_MS = 25_000;
export const PUBLISH_TIMEOUT_MS = 25_000;

/**
* The connection must remain stable for this long after a successful AUTH
* before the reconnect backoff delay resets to its base value. Stability-
* gated reset prevents repeated fast reconnects (flapping) from erasing the
* backoff that throttles them.
*/
export const BACKOFF_RESET_STABLE_MS = 60_000;

/**
* Passive liveness check. The relay sends heartbeat pings every 30s; if no
* inbound frame arrives for two heartbeat windows, treat the socket as stalled.
*/
const STALL_CHECK_INTERVAL_MS = 10_000;
const STALL_IDLE_TIMEOUT_MS = 60_000;

Expand All @@ -85,6 +73,7 @@ export class RelayClient {
private relayUrl: string | null = null;
private connectPromise: Promise<void> | null = null;
private reconnectTimeout: number | null = null;
private reconnectWaiters = new RelayReconnectWaiters();
private reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
private keepAliveRequested = false;
private authRequest: {
Expand All @@ -105,16 +94,6 @@ export class RelayClient {
private stabilityTimer: number | null = null;
private visibleChannelId: string | null = null;

/**
* Sticky terminal flag. Set when `resetConnection` is called with
* `reconnect: false` (today: auth rejection). Acts as a hard guard against
* the reconnect-timer / retry-wrapper paths racing back to "reconnecting"
* after we've already declared the session dead.
*
* Cleared only on explicit user re-engagement: `disconnect()` (community
* switch — the singleton is being reused for a different community) and
* `preconnect()` (caller is asking us to come back up).
*/
private terminal = false;

private connectionStateEmitter = new RelayConnectionStateEmitter("idle");
Expand All @@ -127,21 +106,10 @@ export class RelayClient {
},
});

/**
* Track which channel the user is currently viewing so its subscriptions
* are sent first during reconnect replay — reducing visible latency on
* degraded networks where the relay REQ storm would otherwise delay all
* channels equally.
*/
setVisibleChannelId(id: string | null) {
this.visibleChannelId = id;
}

/**
* Cleanly tear down the connection without scheduling a reconnect.
* Used during community switches to reset the singleton before the
* new community applies.
*/
disconnect() {
const error = new Error("Relay disconnected for community switch.");

Expand Down Expand Up @@ -169,6 +137,7 @@ export class RelayClient {
}

this.connectPromise = null;
this.reconnectWaiters.settle(error);

if (this.authRequest) {
window.clearTimeout(this.authRequest.timeout);
Expand Down Expand Up @@ -247,10 +216,6 @@ export class RelayClient {
return this.fetchHistory(filter);
}

/**
* Return the first event matching `filter` as soon as it arrives, without
* waiting for EOSE. Resolves to `null` when EOSE arrives before any event.
*/
async fetchFirstEvent(
filter: RelaySubscriptionFilter,
): Promise<RelayEvent | null> {
Expand Down Expand Up @@ -462,10 +427,24 @@ export class RelayClient {

async preconnect() {
// Explicit re-engagement. If the session went terminal (auth rejection)
// the caller is asking us to try again, so clear the latch.
// the caller is asking us to try again, so clear the latch. A manual
// reconnect also bypasses the current delay once; ordinary operations do
// not, so background traffic cannot continuously defeat backoff.
this.terminal = false;
this.keepAliveRequested = true;
await this.ensureConnected();
if (this.reconnectTimeout !== null) {
window.clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
try {
await this.ensureConnected();
this.reconnectWaiters.settle();
} catch (error) {
this.reconnectWaiters.settle(
this.normalizeRelayError(error, "Relay reconnect failed."),
);
throw error;
}
}

subscribeToReconnects(listener: () => void) {
Expand Down Expand Up @@ -508,9 +487,15 @@ export class RelayClient {
return;
}

if (this.reconnectTimeout) {
window.clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
if (
shouldWaitForScheduledReconnect({
hasPendingReconnect: this.reconnectTimeout !== null,
})
) {
// The reconnect coordinator owns outage pacing. Query, publish, and
// subscription callers must wait for its scheduled attempt instead of
// clearing the timer and creating an immediate reconnect storm.
return this.waitForScheduledReconnect();
}

const connectPromise = this.connect();
Expand Down Expand Up @@ -964,6 +949,13 @@ export class RelayClient {
}
}

private waitForScheduledReconnect(): Promise<void> {
if (this.reconnectTimeout === null) {
return this.ensureConnected();
}
return this.reconnectWaiters.wait();
}

private scheduleReconnect() {
if (
!shouldScheduleReconnect({
Expand All @@ -989,9 +981,14 @@ export class RelayClient {

this.reconnectTimeout = window.setTimeout(() => {
this.reconnectTimeout = null;
void this.ensureConnected().catch(() => {
this.scheduleReconnect();
});
void this.ensureConnected()
.then(() => this.reconnectWaiters.settle())
.catch((error) => {
this.reconnectWaiters.settle(
this.normalizeRelayError(error, "Relay reconnect failed."),
);
this.scheduleReconnect();
});
}, delay);
}

Expand Down Expand Up @@ -1049,6 +1046,9 @@ export class RelayClient {
window.clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (options?.reconnect === false) {
this.reconnectWaiters.settle(error);
}

if (this.wsId !== null) {
void closeWebSocket(this.wsId, "connection reset");
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/shared/api/relayReconnectPolicy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isWebSocketClose,
shouldRefuseConnect,
shouldScheduleReconnect,
shouldWaitForScheduledReconnect,
} from "./relayReconnectPolicy.ts";

// The "happy" baseline that *should* schedule a reconnect: not terminal,
Expand Down Expand Up @@ -79,6 +80,17 @@ test("keep-alive alone is enough to schedule", () => {
);
});

test("ordinary operations wait for a scheduled reconnect instead of bypassing backoff", () => {
assert.equal(
shouldWaitForScheduledReconnect({ hasPendingReconnect: true }),
true,
);
assert.equal(
shouldWaitForScheduledReconnect({ hasPendingReconnect: false }),
false,
);
});

test("shouldRefuseConnect mirrors terminal", () => {
assert.equal(shouldRefuseConnect({ terminal: false }), false);
assert.equal(shouldRefuseConnect({ terminal: true }), true);
Expand Down
Loading
Loading