Guard solver relay websocket close recursion
All checks were successful
deploy / deploy (push) Successful in 33s

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.
This commit is contained in:
philipp 2026-04-16 14:33:01 +02:00
parent ea0a7cbb4c
commit a7a73336a5
2 changed files with 39 additions and 2 deletions

View file

@ -22,6 +22,7 @@ export async function startSolverRelayWs({
let lastConnectedAt = null; let lastConnectedAt = null;
let lastDisconnectedAt = null; let lastDisconnectedAt = null;
let lastReconnectAt = null; let lastReconnectAt = null;
const closingSockets = new WeakSet();
connect(); connect();
@ -202,9 +203,11 @@ export async function startSolverRelayWs({
} }
function closeSocket() { 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 { try {
socket.close(); activeSocket.close();
} catch (error) { } catch (error) {
logger?.warn('socket_close_failed', { logger?.warn('socket_close_failed', {
venue: 'near-intents', venue: 'near-intents',

View file

@ -23,6 +23,8 @@ function installMockWebSocket() {
this.readyState = MockWebSocket.CONNECTING; this.readyState = MockWebSocket.CONNECTING;
this.sent = []; this.sent = [];
this.listeners = new Map(); this.listeners = new Map();
this.closeCalls = 0;
this.emitErrorDuringClose = false;
instances.push(this); instances.push(this);
} }
@ -37,7 +39,11 @@ function installMockWebSocket() {
} }
close() { close() {
this.closeCalls += 1;
if (this.readyState === MockWebSocket.CLOSED) return; if (this.readyState === MockWebSocket.CLOSED) return;
if (this.emitErrorDuringClose) {
this.emit('error', new Error('socket failed while closing'));
}
this.readyState = MockWebSocket.CLOSED; this.readyState = MockWebSocket.CLOSED;
this.emit('close', {}); 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 () => { test('solver relay sends queued request after connection and resolves rpc result', async () => {
const mock = installMockWebSocket(); const mock = installMockWebSocket();
const client = await startSolverRelayWs({ const client = await startSolverRelayWs({