diff --git a/src/venues/near-intents/solver-relay-ws.mjs b/src/venues/near-intents/solver-relay-ws.mjs index 2c726d6..fe32444 100644 --- a/src/venues/near-intents/solver-relay-ws.mjs +++ b/src/venues/near-intents/solver-relay-ws.mjs @@ -22,6 +22,7 @@ export async function startSolverRelayWs({ let lastConnectedAt = null; let lastDisconnectedAt = null; let lastReconnectAt = null; + const closingSockets = new WeakSet(); connect(); @@ -202,9 +203,11 @@ export async function startSolverRelayWs({ } function closeSocket() { - if (!socket || socket.readyState > WebSocket.OPEN) return; + const activeSocket = socket; + if (!activeSocket || activeSocket.readyState > WebSocket.OPEN || closingSockets.has(activeSocket)) return; + closingSockets.add(activeSocket); try { - socket.close(); + activeSocket.close(); } catch (error) { logger?.warn('socket_close_failed', { venue: 'near-intents', diff --git a/test/solver-relay-ws.test.mjs b/test/solver-relay-ws.test.mjs index 76063c5..2623802 100644 --- a/test/solver-relay-ws.test.mjs +++ b/test/solver-relay-ws.test.mjs @@ -23,6 +23,8 @@ function installMockWebSocket() { this.readyState = MockWebSocket.CONNECTING; this.sent = []; this.listeners = new Map(); + this.closeCalls = 0; + this.emitErrorDuringClose = false; instances.push(this); } @@ -37,7 +39,11 @@ function installMockWebSocket() { } close() { + this.closeCalls += 1; if (this.readyState === MockWebSocket.CLOSED) return; + if (this.emitErrorDuringClose) { + this.emit('error', new Error('socket failed while closing')); + } this.readyState = MockWebSocket.CLOSED; this.emit('close', {}); } @@ -112,6 +118,34 @@ test('solver relay reconnects after websocket error even when no close event is } }); +test('solver relay websocket close is reentrant-safe when close emits an error', async () => { + const mock = installMockWebSocket(); + const client = await startSolverRelayWs({ + apiKey: 'api-key', + wsUrl: 'wss://relay.example/ws', + reconnectDelayMs: 1, + }); + + try { + assert.equal(mock.instances.length, 1); + mock.instances[0].open(); + mock.instances[0].emitErrorDuringClose = true; + + assert.doesNotThrow(() => { + mock.instances[0].error(new Error('network failed')); + }); + await delay(10); + + assert.equal(mock.instances[0].closeCalls, 1); + assert.equal(client.getState().connected, false); + assert.ok(client.getState().reconnect_count >= 2); + assert.ok(mock.instances.length >= 2); + } finally { + client.close(); + mock.restore(); + } +}); + test('solver relay sends queued request after connection and resolves rpc result', async () => { const mock = installMockWebSocket(); const client = await startSolverRelayWs({