Ping pong memory leak fix for #511 (#512)

* New test for pingpong memory leak (failing).

* Shim once in relay ping mem test.

* Fix pong memory leak with .once.

Fixes #511.

* Fix missing global WebSocket on Node.

* Lint fix.

* Remove overkill WebSocket impl check.
This commit is contained in:
Chris McCormick
2025-10-27 10:14:33 +08:00
committed by fiatjaf
parent e8927d78e6
commit 1bec9fa365
4 changed files with 81 additions and 12 deletions

View File

@@ -193,12 +193,12 @@ export class AbstractRelay {
return this.connectionPromise return this.connectionPromise
} }
private async waitForPingPong() { private waitForPingPong() {
return new Promise((res, err) => { return new Promise(resolve => {
// listen for pong // listen for pong
this.ws && this.ws.on ? this.ws.on('pong', () => res(true)) : err("ws can't listen for pong") ;(this.ws as any).once('pong', () => resolve(true))
// send a ping // send a ping
this.ws && this.ws.ping && this.ws.ping() this.ws!.ping!()
}) })
} }
@@ -224,7 +224,7 @@ export class AbstractRelay {
// wait for either a ping-pong reply or a timeout // wait for either a ping-pong reply or a timeout
const result = await Promise.any([ const result = await Promise.any([
// browsers don't have ping so use a dummy req // browsers don't have ping so use a dummy req
this.ws && this.ws.ping && this.ws.on ? this.waitForPingPong() : this.waitForDummyReq(), this.ws && this.ws.ping && (this.ws as any).once ? this.waitForPingPong() : this.waitForDummyReq(),
new Promise(res => setTimeout(() => res(false), this.pingTimeout)), new Promise(res => setTimeout(() => res(false), this.pingTimeout)),
]) ])
if (result) { if (result) {
@@ -232,7 +232,7 @@ export class AbstractRelay {
this.pingTimeoutHandle = setTimeout(() => this.pingpong(), this.pingFrequency) this.pingTimeoutHandle = setTimeout(() => this.pingpong(), this.pingFrequency)
} else { } else {
// pingpong closing socket // pingpong closing socket
if (this.ws?.readyState === WebSocket.OPEN) { if (this.ws?.readyState === this._WebSocket.OPEN) {
this.ws?.close() this.ws?.close()
} }
} }
@@ -434,7 +434,7 @@ export class AbstractRelay {
this.closeAllSubscriptions('relay connection closed by us') this.closeAllSubscriptions('relay connection closed by us')
this._connected = false this._connected = false
this.onclose?.() this.onclose?.()
if (this.ws?.readyState === WebSocket.OPEN) { if (this.ws?.readyState === this._WebSocket.OPEN) {
this.ws?.close() this.ws?.close()
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nostr/tools", "name": "@nostr/tools",
"version": "2.17.0", "version": "2.17.1",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./core": "./core.ts", "./core": "./core.ts",

View File

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "nostr-tools", "name": "nostr-tools",
"version": "2.17.0", "version": "2.17.1",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -129,9 +129,17 @@ test('ping-pong timeout (with native ping)', async () => {
this.dispatchEvent(new Event('pong')) this.dispatchEvent(new Event('pong'))
} }
} }
;(MockWebSocketClient.prototype as any).on = function (this: any, event: string, listener: () => void) { ;(MockWebSocketClient.prototype as any).once = function (
this: any,
event: string,
listener: (...args: any[]) => void,
) {
if (event === 'pong') { if (event === 'pong') {
this.addEventListener('pong', listener) const onceListener = (...args: any[]) => {
this.removeEventListener(event, onceListener)
listener.apply(this, args)
}
this.addEventListener('pong', onceListener)
} }
} }
@@ -166,7 +174,7 @@ test('ping-pong timeout (with native ping)', async () => {
expect(closed).toBeTrue() expect(closed).toBeTrue()
} finally { } finally {
delete (MockWebSocketClient.prototype as any).ping delete (MockWebSocketClient.prototype as any).ping
delete (MockWebSocketClient.prototype as any).on delete (MockWebSocketClient.prototype as any).once
} }
}) })
@@ -217,6 +225,67 @@ test('ping-pong timeout (no-ping browser environment)', async () => {
} }
}) })
test('ping-pong listeners are cleaned up', async () => {
const mockRelay = new MockRelay()
let listenerCount = 0
// mock a native ping/pong mechanism
;(MockWebSocketClient.prototype as any).ping = function (this: any) {
if (!mockRelay.unresponsive) {
this.dispatchEvent(new Event('pong'))
}
}
const originalAddEventListener = MockWebSocketClient.prototype.addEventListener
MockWebSocketClient.prototype.addEventListener = function (event, listener, options) {
if (event === 'pong') {
listenerCount++
}
// @ts-ignore
return originalAddEventListener.call(this, event, listener, options)
}
const originalRemoveEventListener = MockWebSocketClient.prototype.removeEventListener
MockWebSocketClient.prototype.removeEventListener = function (event, listener) {
if (event === 'pong') {
listenerCount--
}
// @ts-ignore
return originalRemoveEventListener.call(this, event, listener)
}
// the check in pingpong() is for .once() so we must mock it
;(MockWebSocketClient.prototype as any).once = function (
this: any,
event: string,
listener: (...args: any[]) => void,
) {
const onceListener = (...args: any[]) => {
this.removeEventListener(event, onceListener)
listener.apply(this, args)
}
this.addEventListener(event, onceListener)
}
try {
const relay = new Relay(mockRelay.url, { enablePing: true })
relay.pingTimeout = 50
relay.pingFrequency = 50
await relay.connect()
await new Promise(resolve => setTimeout(resolve, 175))
expect(listenerCount).toBeLessThan(2)
relay.close()
} finally {
delete (MockWebSocketClient.prototype as any).ping
delete (MockWebSocketClient.prototype as any).once
MockWebSocketClient.prototype.addEventListener = originalAddEventListener
MockWebSocketClient.prototype.removeEventListener = originalRemoveEventListener
}
})
test('reconnect on disconnect', async () => { test('reconnect on disconnect', async () => {
const mockRelay = new MockRelay() const mockRelay = new MockRelay()
const relay = new Relay(mockRelay.url, { enablePing: true, enableReconnect: true }) const relay = new Relay(mockRelay.url, { enablePing: true, enableReconnect: true })