From 22601be42d6868f6a5ed7b39e951a74adccf83bf Mon Sep 17 00:00:00 2001 From: Dagur Valberg Johannsson Date: Wed, 8 Apr 2026 09:46:00 +0200 Subject: [PATCH] Detect stale tcp connections to relay Enable nostr-tools' internal stale tcp connection detection --- docs/transport.md | 17 +++++++++++++++++ packages/core/src/relay-client.ts | 19 ++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/transport.md b/docs/transport.md index be40075..db7fa73 100644 --- a/docs/transport.md +++ b/docs/transport.md @@ -85,6 +85,23 @@ to `now` in `disconnect()`. Any incoming message with `time < lastProcessedTimes dropped. This prevents the relay from re-delivering messages that were already handled before a disconnect. +### Keepalive + +`RelayClient` creates its `SimplePool` with `enablePing: true`. This enables nostr-tools' +built-in heartbeat: every 29 seconds the pool pings each connected relay and expects a +response within 20 seconds. In Node.js this uses native WebSocket ping/pong frames; in +browsers (where the WebSocket API doesn't expose ping) it falls back to sending a dummy +subscription request and waiting for EOSE. + +If a relay fails to respond, nostr-tools closes the WebSocket, which fires the subscription +`onclose` callback, which emits `"disconnect"` on `RelayClient`, which triggers the +connection manager's existing reconnect loop. This detects "zombie" TCP connections where the +socket appears open but the relay is unreachable. + +Additionally, if a `publishMessage()` call fails (all relays reject the event), +`RelayClient` emits `"disconnect"` alongside the thrown error. This ensures the reconnect +loop starts immediately rather than waiting for the next ping cycle. + ### Sequence numbers `nextSequence()` starts at a random offset in the safe integer range and increments by 2. This diff --git a/packages/core/src/relay-client.ts b/packages/core/src/relay-client.ts index b2e604a..3f6fb29 100644 --- a/packages/core/src/relay-client.ts +++ b/packages/core/src/relay-client.ts @@ -46,6 +46,8 @@ export class RelayClient extends EventEmitter { private messageQueue: MessageQueue; private readyTimeoutId: ReturnType | null = null; + private disconnecting: boolean = false; + private sequence: number = Math.floor( Math.random() * (Number.MAX_SAFE_INTEGER - 500_000), ); @@ -72,7 +74,7 @@ export class RelayClient extends EventEmitter { logNetworkActivity: true, ...config, }; - this.pool = pool ?? new SimplePool(); + this.pool = pool ?? new SimplePool({ enablePing: true }); this.sharedPool = pool !== undefined; this.messageQueue = new MessageQueue({ @@ -120,11 +122,19 @@ export class RelayClient extends EventEmitter { return !this.config.pairedPublicKey.every((byte) => byte === 0); } + private emitDisconnect(error: Error): void { + if (this.disconnecting) return; + this.disconnecting = true; + this.emit("disconnect", error); + } + async connect(): Promise { if (this.config.logNetworkActivity) { debug(Scope.Relay, `Connecting to relay...`); } + this.disconnecting = false; + if (this.lastProcessedTimestamp === 0) { this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2; } @@ -150,7 +160,7 @@ export class RelayClient extends EventEmitter { if (this.config.logNetworkActivity) { debug(Scope.Relay, `Subscription closed: ${reasons.join(", ")}`); } - this.emit("disconnect", new Error("Subscription closed")); + this.emitDisconnect(new Error("Subscription closed")); }, }, ); @@ -251,6 +261,9 @@ export class RelayClient extends EventEmitter { error, ); } + this.emitDisconnect( + error instanceof Error ? error : new Error(`Publish failed: ${error}`), + ); throw error; } } @@ -330,7 +343,7 @@ export class RelayClient extends EventEmitter { } isConnected(): boolean { - return true; + return this.subscription !== null; } private netlog(