Detect stale tcp connections to relay

Enable nostr-tools' internal stale tcp connection detection
This commit is contained in:
Dagur Valberg Johannsson 2026-04-08 09:46:00 +02:00
parent 4bcbef2aae
commit 22601be42d
No known key found for this signature in database
GPG key ID: FD701804AEE88107
2 changed files with 33 additions and 3 deletions

View file

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

View file

@ -46,6 +46,8 @@ export class RelayClient extends EventEmitter {
private messageQueue: MessageQueue;
private readyTimeoutId: ReturnType<typeof setTimeout> | 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<void> {
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(