diff --git a/scripts/deploy/bootstrap.sh b/scripts/deploy/bootstrap.sh index 0d218ce..bf3eb5e 100755 --- a/scripts/deploy/bootstrap.sh +++ b/scripts/deploy/bootstrap.sh @@ -196,8 +196,22 @@ kubectl -n "$PROJECT_NAMESPACE" create secret docker-registry "$PROJECT_REGISTRY --docker-password="$REGISTRY_PASSWORD" \ --dry-run=client -o yaml | kubectl apply -f - +current_release_image() { + kubectl -n "$PROJECT_NAMESPACE" get deploy operator-dashboard \ + -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || true +} + +APP_MANIFEST_IMAGE="${PROJECT_RELEASE_IMAGE:-$(current_release_image)}" +BOOTSTRAP_IMAGE="ghcr.io/example/unrip:bootstrap" + echo "applying app manifests" -kubectl apply -k "$ROOT_DIR/deploy/k8s/base" +if [[ -n "$APP_MANIFEST_IMAGE" && "$APP_MANIFEST_IMAGE" != "$BOOTSTRAP_IMAGE" ]]; then + kubectl kustomize "$ROOT_DIR/deploy/k8s/base" \ + | python3 "$ROOT_DIR/scripts/deploy/render_release_manifest.py" --image "$APP_MANIFEST_IMAGE" \ + | kubectl apply -f - +else + kubectl apply -k "$ROOT_DIR/deploy/k8s/base" +fi echo "upserting Forgejo repo settings" forgejo_args=() diff --git a/src/venues/near-intents/ws.mjs b/src/venues/near-intents/ws.mjs index 4e2bc3f..a358ef9 100644 --- a/src/venues/near-intents/ws.mjs +++ b/src/venues/near-intents/ws.mjs @@ -43,6 +43,7 @@ export async function startNearIntentsWs({ let lastConnectedAt = null; let lastDisconnectedAt = null; let lastReconnectAt = null; + const closingSockets = new WeakSet(); function connect() { if (closed) return; @@ -171,7 +172,8 @@ export async function startNearIntentsWs({ } function closeSocket(ws) { - if (!ws || ws.readyState > WebSocket.OPEN) return; + if (!ws || ws.readyState > WebSocket.OPEN || closingSockets.has(ws)) return; + closingSockets.add(ws); try { ws.close(); } catch (error) { diff --git a/test/bootstrap_script_static_test.py b/test/bootstrap_script_static_test.py new file mode 100644 index 0000000..68e5bc0 --- /dev/null +++ b/test/bootstrap_script_static_test.py @@ -0,0 +1,18 @@ +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class BootstrapScriptStaticTest(unittest.TestCase): + def test_bootstrap_renders_existing_release_image_instead_of_reapplying_placeholders(self): + source = (ROOT / 'scripts/deploy/bootstrap.sh').read_text() + self.assertIn('PROJECT_RELEASE_IMAGE', source) + self.assertIn('current_release_image', source) + self.assertIn('render_release_manifest.py', source) + self.assertIn('ghcr.io/example/unrip:bootstrap', source) + self.assertIn('kubectl kustomize "$ROOT_DIR/deploy/k8s/base"', source) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/near-intents-ws.test.mjs b/test/near-intents-ws.test.mjs index 355e544..d943b62 100644 --- a/test/near-intents-ws.test.mjs +++ b/test/near-intents-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', {}); } @@ -96,3 +102,36 @@ test('near intents ingest reconnects after websocket error before open without r mock.restore(); } }); + +test('near intents websocket close is reentrant-safe when close emits an error', async () => { + const mock = installMockWebSocket(); + const producer = { sendJson: async () => {} }; + const client = await startNearIntentsWs({ + apiKey: 'api-key', + wsUrl: 'wss://relay.example/ws', + pairFilter: ['btc', 'eure'], + producer, + rawTopic: 'raw.near_intents.quote', + normalizedTopic: 'norm.swap_demand', + 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(); + } +});