From a7a73336a50773d5f6f53933c90f15ce83e6cac8 Mon Sep 17 00:00:00 2001 From: philipp Date: Thu, 16 Apr 2026 14:33:01 +0200 Subject: [PATCH] Guard solver relay websocket close recursion Proof: npm test; npm run operator-dashboard:build; node --test test/solver-relay-ws.test.mjs; PYTHONPATH=. python3 test/bootstrap_script_static_test.py; PYTHONPATH=. python3 test/render_release_manifest_test.py; PYTHONPATH=. python3 test/repo_deployments_test.py; PYTHONPATH=. python3 test/ntfy_manifest_test.py; kubectl kustomize deploy/k8s/base. Assumptions: the solver relay websocket client can receive reentrant error events while closing, matching the ingest runtime failure pattern observed in production. Still fake: Notification emissions are limited to credited deposits, completed withdrawals, and completed trades with durable inventory movement; generic alert notification policy remains disabled. --- src/venues/near-intents/solver-relay-ws.mjs | 7 +++-- test/solver-relay-ws.test.mjs | 34 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) 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({