Skip to content

SDK drift: Python's SidecarWsClient.close() can block behind an in-progress connect-retry loop; TypeScript's close() never blocks #2195

Description

@realfishsam

Drift

Python's SidecarWsClient.close() shares self._lock with subscribe()/subscribe_batch(), both of which hold that lock for the entire duration of _ensure_connected() — including its full multi-attempt connect-retry loop (up to CONNECT_ATTEMPTS attempts, each with a 10s socket-connect timeout plus inter-attempt backoff, so potentially 30+ seconds). If a caller invokes close() from another thread while a subscribe()/subscribe_batch() call is stuck retrying a failing connection, close() blocks on with self._lock: until that retry sequence finishes releasing the lock — it cannot promptly interrupt or tear down the client.

TypeScript's close() has no locking at all and always runs to completion immediately; its ensureConnected() retry loop has no equivalent shared-mutex dependency with close().

TypeScript SDK

sdks/typescript/pmxt/ws-client.ts:454-467close() is unconditionally synchronous: sets this.closed = true, clears the ping interval, closes the socket, and clears dataQueues/dataStore, with no lock/mutex involved anywhere in the class. Its connect-retry loop lives in ensureConnected() at sdks/typescript/pmxt/ws-client.ts:109-144, which is also lock-free (JS is single-threaded; concurrent subscribe() calls await the same connectPromise, line 114).

Python SDK

sdks/python/pmxt/ws_client.py:393-405close():

def close(self) -> None:
    """Close the WebSocket connection."""
    self._closed = True
    if self._ws:
        try:
            self._ws.close()
        except Exception as e:
            import logging
            logging.warning("WebSocket close error: %s", e)
        self._ws = None
    with self._lock:
        self._data_queues.clear()
        self._data_store.clear()

self._lock is the same lock acquired by subscribe() (sdks/python/pmxt/ws_client.py:311, with self._lock:) and subscribe_batch() (sdks/python/pmxt/ws_client.py:357, with self._lock:) around their call to _ensure_connected() (sdks/python/pmxt/ws_client.py:102-181), whose retry loop runs at lines 151-167 (up to CONNECT_ATTEMPTS iterations, each _connect_websocket(ws, url, timeout=10) at line 154, with time.sleep backoff between attempts). On a fresh/never-connected client self._ws is still None when close() runs, so the if self._ws: branch is skipped entirely and close()'s only remaining work — the trailing with self._lock: block — is exactly the statement that contends with the in-progress retry loop.

Expected

close() should behave the same way in both SDKs: an immediate, best-effort teardown that a caller can rely on to interrupt a client regardless of what other operations are in flight, matching TypeScript's lock-free implementation. Python's shared single threading.Lock couples an unrelated teardown call to the worst-case duration of a concurrent connection attempt.

Impact

A Python caller using close() as a fast/best-effort shutdown or timeout-cancellation mechanism (e.g. during process shutdown, or to abandon a client stuck talking to an unreachable host) can have that call hang for up to the full connect-retry duration instead of returning immediately, which is surprising and not documented. It also means two unrelated subscribe() calls for different exchanges/symbols unnecessarily serialize behind each other's connection attempt via the same lock that close() depends on.


Found by automated SDK cross-language drift audit

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions