Drift
TypeScript's SidecarWsClient.subscribe() checks for an existing subscription, then awaits a connection-ensure step, and only after that await registers the new subscription in activeSubs. Because await always yields to the JS event loop (even when the awaited value resolves synchronously), two concurrent subscribe() calls for the same symbol can both pass the dedup check before either registers, causing two independent "subscribe" frames to be sent to the server for the same symbol. Python's equivalent holds a lock across the entire check-then-register-then-send critical section, making this race structurally impossible.
TypeScript SDK
sdks/typescript/pmxt/ws-client.ts:341-372 (subscribe):
async subscribe(exchange, method, args, credentials, timeoutMs = 30000) {
...
const existingId = this.activeSubs.get(subKey);
if (existingId && this.subscriptions.has(existingId)) {
return this.waitForData(existingId, timeoutMs);
}
await this.ensureConnected(); // yields to the event loop here — no lock held
const requestId = `req-${...}`;
...
this.subscriptions.set(requestId, sub);
this.activeSubs.set(subKey, requestId); // dedup key only set AFTER the await
...
this.ws.send(JSON.stringify(message));
Python SDK
sdks/python/pmxt/ws_client.py:311-336 (subscribe):
with self._lock:
existing_id = self._active_subs.get(sub_key)
if existing_id and existing_id in self._subscriptions:
sub = self._subscriptions[existing_id]
else:
self._ensure_connected()
request_id = f"req-{uuid.uuid4().hex[:12]}"
...
self._subscriptions[request_id] = sub
self._active_subs[sub_key] = request_id
self._ws.send(json.dumps(message))
The entire block — including the socket send — is inside with self._lock:, fully serializing concurrent callers.
Expected
TypeScript's subscribe() should register the activeSubs entry (or otherwise stake a claim on the subscription key) before the await this.ensureConnected() call, or serialize concurrent subscribe() calls for the same key some other way, so two callers racing on the same symbol reuse one subscription instead of creating two.
Impact
Any code path that issues two subscribe() calls for the same symbol without awaiting the first (e.g. Promise.all([client.subscribe(...), client.subscribe(...)]), or two watchers started back-to-back) can silently double-subscribe server-side for that symbol, and one of the two requestIds becomes orphaned in subscriptions (unreachable via activeSubs), leaking its data queue until the client is garbage collected. This is distinct from the already-tracked #2140, which covers a race between a second waiter and an already-registered subscription's resolve/reject (i.e., after activeSubs is already set) — this finding is about the registration race for two brand-new subscriptions, which is not described by #2140 or any other existing title.
Found by automated SDK cross-language drift audit
Drift
TypeScript's
SidecarWsClient.subscribe()checks for an existing subscription, thenawaits a connection-ensure step, and only after that await registers the new subscription inactiveSubs. Becauseawaitalways yields to the JS event loop (even when the awaited value resolves synchronously), two concurrentsubscribe()calls for the same symbol can both pass the dedup check before either registers, causing two independent"subscribe"frames to be sent to the server for the same symbol. Python's equivalent holds a lock across the entire check-then-register-then-send critical section, making this race structurally impossible.TypeScript SDK
sdks/typescript/pmxt/ws-client.ts:341-372(subscribe):Python SDK
sdks/python/pmxt/ws_client.py:311-336(subscribe):The entire block — including the socket send — is inside
with self._lock:, fully serializing concurrent callers.Expected
TypeScript's
subscribe()should register theactiveSubsentry (or otherwise stake a claim on the subscription key) before theawait this.ensureConnected()call, or serialize concurrentsubscribe()calls for the same key some other way, so two callers racing on the same symbol reuse one subscription instead of creating two.Impact
Any code path that issues two
subscribe()calls for the same symbol without awaiting the first (e.g.Promise.all([client.subscribe(...), client.subscribe(...)]), or two watchers started back-to-back) can silently double-subscribe server-side for that symbol, and one of the tworequestIds becomes orphaned insubscriptions(unreachable viaactiveSubs), leaking its data queue until the client is garbage collected. This is distinct from the already-tracked #2140, which covers a race between a second waiter and an already-registered subscription'sresolve/reject(i.e., afteractiveSubsis already set) — this finding is about the registration race for two brand-new subscriptions, which is not described by #2140 or any other existing title.Found by automated SDK cross-language drift audit