fix: use ?? for autoReconnect default; add onClose and onError callbacks - #45
fix: use ?? for autoReconnect default; add onClose and onError callbacks#45osr21 wants to merge 8 commits into
Conversation
Two bugs fixed:
1. autoReconnect: false was silently ignored
The constructor used || (logical OR) which treats false as falsy:
this.autoReconnect = args!.autoReconnect || true // always true!
Fixed by using ?? (nullish coalescing) which only falls back on null/undefined:
this.autoReconnect = args!.autoReconnect ?? true
2. No onClose or onError callbacks exposed to callers
The interface only offered onConnect, onMessage, onStatusChange.
Users had no way to react to disconnect events or errors beyond
autoReconnect. Added:
- onClose?: (client, event) => void — called before reconnect logic
- onError?: (client, error) => void — called before reconnect logic
This is needed to implement application-level watchdogs, logging,
and back-off strategies. Fixes Polymarket#43.
| if (this.onUserError) { | ||
| this.onUserError(this, err); | ||
| } |
There was a problem hiding this comment.
If the user's onUserError callback throws an exception, execution stops and the auto-reconnect logic on lines 155-157 never runs. This breaks automatic reconnection.
Fix: Wrap the callback invocation in a try-catch block:
if (this.onUserError) {
try {
this.onUserError(this, err);
} catch (callbackError) {
console.error('Error in onError callback:', callbackError);
}
}| if (this.onUserError) { | |
| this.onUserError(this, err); | |
| } | |
| if (this.onUserError) { | |
| try { | |
| this.onUserError(this, err); | |
| } catch (callbackError) { | |
| console.error('Error in onError callback:', callbackError); | |
| } | |
| } | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
Two issues raised in PR review: 1. (Graphite) If the user-provided onError callback throws, execution stops before the autoReconnect branch runs, silently breaking automatic reconnection. Fixed by wrapping onUserError (and onUserClose symmetrically) in try-catch so callback exceptions are logged but do not interrupt the reconnect path. 2. (Cursor Bugbot) When onError fires and autoReconnect calls connect(), this.ws is replaced with a new WebSocket — but the OLD socket still has onclose = this.onClose wired. When the dying socket closes naturally (code 1006 etc.), onClose fires spuriously: onUserClose is called as if the client disconnected, and connect() is invoked a second time, creating a double-reconnect race. Fixed by setting this.ws.onclose = null on the current socket immediately before calling connect() in the error path.
|
Thanks for the bot reviews — both findings are valid and have been addressed in the latest commit. Graphite: throwing
|
| this.onUserClose = args!.onClose; | ||
| this.onUserError = args!.onError; |
There was a problem hiding this comment.
Runtime crash when args is undefined
The constructor uses non-null assertion (args!) but args is typed as optional (args?: RealTimeDataClientArgs). When new RealTimeDataClient() is called without arguments, args will be undefined, causing args!.onClose and args!.onError to throw:
Cannot read property 'onClose' of undefined
This crashes the application during instantiation.
Fix: Add null check:
this.onUserClose = args?.onClose;
this.onUserError = args?.onError;Note: Lines 77-82 have the same pre-existing bug with other properties, but since those weren't modified in this PR, the new callback assignments are the immediate issue introduced here.
| this.onUserClose = args!.onClose; | |
| this.onUserError = args!.onError; | |
| this.onUserClose = args?.onClose; | |
| this.onUserError = args?.onError; | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
Fixed in the latest commit — replaced all eight args!. accesses in the constructor with args?.:
this.host = args?.host || DEFAULT_HOST;
this.pingInterval = args?.pingInterval || DEFAULT_PING_INTERVAL;
this.autoReconnect = args?.autoReconnect ?? true;
this.onCustomMessage = args?.onMessage;
this.onConnect = args?.onConnect;
this.onStatusChange = args?.onStatusChange;
this.onUserClose = args?.onClose;
this.onUserError = args?.onError;The existing fallback values (|| DEFAULT_HOST, || DEFAULT_PING_INTERVAL, ?? true) already handle the undefined case correctly, so new RealTimeDataClient() with no arguments now works as expected. The pre-existing lines (host, pingInterval, autoReconnect, onMessage, onConnect, onStatusChange) were fixed at the same time since they had the same crash risk.
The previous fix (ws.onclose = null) stopped the spurious double-reconnect but introduced a new issue flagged by Cursor Bugbot: nulling onclose on the dying socket suppressed its natural close event entirely, so onStatusChange never received DISCONNECTED and onUserClose was never called on error-driven disconnects. Fix: introduce an isReconnecting boolean guard instead. - onError sets isReconnecting = true, then calls connect() - The dying socket still fires its natural onClose event, which delivers the DISCONNECTED status notification and invokes onUserClose normally - onClose checks !isReconnecting before calling connect(), so the second reconnect is skipped — no double-reconnect loop - connect() resets isReconnecting = false at the top so future error/close cycles work correctly This preserves all disconnect notifications on error-driven disconnects while preventing the double-connect race.
|
Good catch. The Replaced in the latest commit with an private isReconnecting = false;
private onError = async (err: ErrorEvent) => {
// ... callback with try/catch ...
if (this.autoReconnect && !this.isReconnecting) {
this.isReconnecting = true;
this.connect(); // this.ws replaced here
}
};
private onClose = async (message: CloseEvent) => {
this.notifyStatusChange(ConnectionStatus.DISCONNECTED); // always fires
// ... onUserClose with try/catch ...
if (this.autoReconnect && !this.isReconnecting) {
this.isReconnecting = true;
this.connect(); // only if onError did not already reconnect
}
};
public connect() {
this.isReconnecting = false; // reset for next cycle
// ...
}The sequence on an error-driven disconnect is now:
|
Using the non-null assertion operator (args!) on an optional parameter causes a runtime crash when new RealTimeDataClient() is called without arguments — every property access throws: "Cannot read properties of undefined" Fixed by replacing all eight args!. accesses with args?. (optional chaining). The fallback values (|| DEFAULT_HOST, || DEFAULT_PING_INTERVAL, ?? true) handle the undefined case correctly, so behaviour is unchanged when args is provided. (Graphite review)
Summary of all fixes applied to this PRThis PR grew across several review iterations. Here is a full log of every issue raised and how it was addressed, in order. Fix 1 —
|
| Behaviour | Before this PR | After this PR |
|---|---|---|
autoReconnect: false |
❌ ignored (|| true) |
✅ respected (?? true) |
onClose callback |
❌ not available | ✅ fires on every disconnect |
onError callback |
❌ not available | ✅ fires on every WS error |
| Throwing callback | ❌ aborts reconnect silently | ✅ logged, reconnect continues |
| Error-driven disconnect | ❌ double-reconnect race | ✅ single reconnect, DISCONNECTED fires |
| No-args constructor | ❌ runtime crash | ✅ works, defaults applied |
The previous guard had a race condition: connect() reset isReconnecting
to false at the very top of the function, before the dying socket
fired its onClose. The sequence was:
onError → isReconnecting = true → connect()
└─ connect() resets isReconnecting = false immediately
old onClose fires → sees false → calls connect() again
→ duplicate WebSocket + duplicate ping cycles
Fix: remove the reset from connect() and move it to onOpen. The flag
now stays true until the new socket successfully opens, so any onClose
firing on the dying socket finds isReconnecting === true and skips the
redundant reconnect. Once the new connection is established, onOpen
clears the flag so future disconnect/error events can reconnect again.
(Cursor Bugbot review — High Severity)
The boolean isReconnecting flag cannot solve both problems simultaneously:
Problem A — old socket fires onClose after onError already triggered
connect(), causing a duplicate WebSocket + double ping cycle.
Problem B — if the NEW socket fails before onOpen (never opens),
isReconnecting stays true permanently and autoReconnect is dead.
Solving A required the flag to stay true through connect().
Solving B required it to be cleared on the next close/error.
These are contradictory with a single boolean.
Fix: a monotonically-increasing connectionId counter. connect() stamps
each socket with the current id via a captured lambda closure. Handlers
compare their captured id against this.connectionId at call time:
- id !== this.connectionId → stale dying socket → return early
- id === this.connectionId → current socket → proceed, then connect()
increments connectionId so the subsequent onClose (if any) is stale
This correctly handles all cases:
✓ Error + close on same socket → onError reconnects, onClose is stale
✓ Failed reconnect (new socket errors) → new id matches, retries
✓ Clean close → id matches, reconnects
✓ disconnect() → autoReconnect=false, clean close, no retry
(Cursor Bugbot review — High Severity)
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 3c9994e. Configure here.
The previous commit suppressed all onClose notifications for stale sockets (sockets where onError already triggered connect()). This silently dropped: - notifyStatusChange(DISCONNECTED) - the user onClose callback - close-code and reason logging Those details are only available in onClose, not in onError, so callers could no longer inspect the CloseEvent (code, reason) on error-driven disconnects even though onError had already run. (Cursor Bugbot review: "Stale close skips callbacks", Medium Severity) Fix: remove the early return from onClose. Deliver DISCONNECTED and the onUserClose callback unconditionally. Only gate the reconnect call on id === this.connectionId to prevent the stale socket opening a duplicate connection alongside the one onError already started.
| public disconnect() { | ||
| this.autoReconnect = false; | ||
| this.ws.close(); |
There was a problem hiding this comment.
Calling disconnect() before connect() will throw an error because this.ws is uninitialized. The class uses a definite assignment assertion (ws!: WebSocket) at line 92, which tells TypeScript the property will be assigned, but disconnect() can be called before any connection is established.
public disconnect() {
this.autoReconnect = false;
if (this.ws) {
this.ws.close();
}
}Alternatively, check if the WebSocket is in an appropriate state before closing:
public disconnect() {
this.autoReconnect = false;
if (this.ws && this.ws.readyState !== WebSocket.CLOSED && this.ws.readyState !== WebSocket.CLOSING) {
this.ws.close();
}
}| public disconnect() { | |
| this.autoReconnect = false; | |
| this.ws.close(); | |
| public disconnect() { | |
| this.autoReconnect = false; | |
| if (this.ws && this.ws.readyState !== WebSocket.CLOSED && this.ws.readyState !== WebSocket.CLOSING) { | |
| this.ws.close(); | |
| } | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
Fixed — applied the readyState guard suggestion. this.ws is declared with ws!: WebSocket (definite assignment assertion), so calling disconnect() before connect() dereferences an undefined value and throws immediately. The readyState check also prevents calling close() on a socket that is already CLOSING or CLOSED:
public disconnect() {
this.autoReconnect = false;
if (this.ws && this.ws.readyState !== WebSocket.CLOSED && this.ws.readyState !== WebSocket.CLOSING) {
this.ws.close();
}
}This is consistent with the guards already in place on subscribe() and unsubscribe(), which both check this.ws.readyState !== WebSocket.OPEN before sending.
this.ws is declared with a definite-assignment assertion (ws!: WebSocket), so calling disconnect() before connect() throws at runtime. Additionally, calling ws.close() on a socket already in CLOSING or CLOSED state is a no-op at best and may trigger unexpected onClose events at worst. Fix: check both that ws exists and that its readyState is neither CLOSED nor CLOSING before calling close(), matching the pattern used by the existing subscribe/unsubscribe guards. (Graphite review)

Summary
Fixes two bugs in
src/client.tsreported in #43.Bug 1:
autoReconnect: falseis silently ignoredRoot cause: The constructor used
||(logical OR), which treatsfalseas falsy and always falls back totrue:Fix: Replace with
??(nullish coalescing), which only falls back onnullorundefined:Bug 2: No
onCloseoronErrorcallbacks exposed to callersRealTimeDataClientArgsonly offeredonConnect,onMessage, andonStatusChange. Users had no way to react to disconnect events or errors — they could not implement application-level watchdogs, structured logging, or custom back-off strategies.Fix: Added two new optional callbacks to both the interface and the class:
Both callbacks fire before the existing reconnect logic, so callers can:
autoReconnect: falseand callingconnect()themselves on a timer)Example usage after this fix
Testing
autoReconnect: falsenow prevents automatic reconnection on close/erroronClosefires with the correctCloseEvent(code + reason) before the reconnect gateonErrorfires with theErrorEventbefore the reconnect gateautoReconnectis omitted (defaults totrue) or whenonClose/onErrorare not providedFixes #43
Note
Medium Risk
Changes core connection teardown and auto-reconnect behavior; fixes are targeted but affect all consumers relying on reconnect semantics.
Overview
Fixes
autoReconnect: falsebeing ignored by defaulting with??instead of||, and exposes optionalonClose/onErrorhooks so apps can log disconnects and implement custom backoff.Reconnect lifecycle is tightened: each
connect()stamps aconnectionIdso stale sockets don’t trigger a second reconnect when both error and close fire; user callbacks are wrapped in try/catch so thrown handlers don’t block auto-reconnect.onClosestill runs (and status goes DISCONNECTED) even when the socket is stale, so close code/reason aren’t dropped.Constructor uses optional
args?.sonew RealTimeDataClient()is safe.disconnect()only closes when the socket exists and isn’t already closing/closed.Reviewed by Cursor Bugbot for commit 5ae00c6. Bugbot is set up for automated code reviews on this repo. Configure here.