ClientStream can send() on a WebSocket that is still CONNECTING after a reconnect race (InvalidStateError)
- Dominant language
- JavaScript
- Stars
- 44.8k
- Forks
- 5.2k
- Avg merge
- 3d 12h
- Merged PRs (30d)
- 25
Description
## Summary
On the browser `ClientStream`, a reconnect that replaces the socket can still deliver the **previous** socket's `onopen`. That handler marks the stream `connected` and DDP `onReset` immediately calls `send()`. `this.socket` is already the **new** WebSocket, whose `readyState` is still `CONNECTING`, so the browser throws:
```
InvalidStateError: Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.
```
This is uncaught. Because `onReset` is the only place the DDP `connect` message is sent, the exception can abort session setup. The later `onopen` on the new socket then hits `_connected()`'s “already connected” early return and does not send `connect` again.
Seen in production on Meteor 3.5.2 with `ddp-server.transport: "uws"` (native WebSocket). The same code is still on `devel`.
## Environment
- Meteor 3.5.2 (`socket-stream-client@0.7.1`, `ddp-client@3.4.1`)
- Also present on current `devel` `packages/socket-stream-client/browser.js`
- Easier to observe with `DDP_TRANSPORT=uws` / `Meteor.settings.packages["ddp-server"].transport = "uws"` because `this.socket` is a native `WebSocket` and Chrome throws this exact `InvalidStateError`
- More frequent on flaky networks, tab resume, and mobile WebViews (handshake + reconnect overlap)
## Stack (production, mapped back to source)
```
ClientStream.send
Connection._send
queue_stub_helpers queued _send callback
ConnectionStreamHandlers.onReset
ClientStream._connected
WebSocket.onopen
```
`ClientStream.send` only checks the Meteor flag, not `readyState`:
https://github.com/meteor/meteor/blob/devel/packages/socket-stream-client/browser.js#L56-L60
```js
send(data) {
if (this.currentStatus.connected) {
this.socket.send(data);
}
}
```
`onopen` always calls `_connected()` and does not check that the event belongs to the current socket:
https://github.com/meteor/meteor/blob/devel/packages/socket-stream-client/browser.js#L187-L190
```js
this.socket.onopen = data => {
this.lastError = null;
this._connected();
};
```
`_cleanup` no-ops `onmessage` / `onclose` / `onerror` / `onheartbeat`, but **does not clear `onopen`**:
https://github.com/meteor/meteor/blob/devel/packages/socket-stream-client/browser.js#L91-L96
```js
this.socket.onmessage = this.socket.onclose = this.socket.onerror = this.socket.onheartbeat = () => {};
this.socket.close();
this.socket = null;
```
`_connected()` then fires `reset`, and `onReset` is the only place the DDP `connect` frame is sent:
https://github.com/meteor/meteor/blob/devel/packages/ddp-client/common/connection_stream_handlers.js
## Race
1. WS1 is created and starts the handshake (`CONNECTING`).
2. Connect timeout, `window` `online`, or `reconnect()` runs `_launchConnection()` → `_cleanup(WS1)` → `WS1.close()` (async) → create WS2 (`CONNECTING`) and assign a new `onopen`.
3. WS1's handshake already completed (or completes as `close()` runs). Its `open` is already queued, and its `onopen` was never cleared.
4. WS1 `onopen` runs → `_connected()` sets `currentStatus.connected = true` → `onReset()` → `this.socket.send(...)`.
5. `this.socket` is WS2, still `CONNECTING` → `InvalidStateError`.
6. If `onReset` throws, DDP `connect` / resent subs / flushed `_sendQueued` messages may not go out.
7. WS2 later opens → `_connected()` returns early because the stream is already marked connected → no second `connect` message.
`send()`'s comment says data sent while not connected is dropped and replayed on `reset`. Here the stream **thinks** it is connected, so the send is attempted and throws instead of being dropped.
## Expected
- A stale `onopen` from a replaced socket must be ignored.
- `send()` must not call `WebSocket.send` unless that socket is `OPEN`.
- A failed / aborted `onReset` must not leave the stream permanently “connected” without a DDP handshake.
## Actual
Uncaught `InvalidStateError` during reconnect. DDP can sit in a half-open state (`status` connected, no session) until a later heartbeat / timeout.
## AI Suggested fix
All three are cheap and complementary:
```js
// 1. Detach onopen in _cleanup (same as the other handlers)
this.socket.onopen = this.socket.onmessage = this.socket.onclose =
this.socket.onerror = this.socket.onheartbeat = () => {};
// 2. Ignore stale open events
this.socket.onopen = event => {
if (this.socket !== event.target) return;
this.lastError = null;
this._connected();
};
// 3. Don't send unless the current socket is actually open
send(data) {
if (this.currentStatus.connected &&
this.socket &&
this.socket.readyState === this.socket.OPEN) {
this.socket.send(data);
}
}
```
SockJS also uses `readyState === 1` for `OPEN`, so the `readyState` guard is valid for both transports.
A unit test with a fake WebSocket: start WS1 in `CONNECTING`, call `_launchConnection()` again, fire `onopen` on WS1, assert no throw and that `connected` stays false until WS2 opens.
## Related
- #10555 / #6365 were SockJS `INVALID_STATE_ERR` from `_didClose` / SockJS `send` while `CONNECTING`. Different throw site and message. Maintainers treated those as mostly harmless. This path is the **native** `WebSocket.send` from `ClientStream.send` during `onReset`, and it can skip the DDP handshake.
- Recent `socket-stream-client` work (#14532, #14534, #14546) tightened disconnect / reconnect / heartbeat behavior but did not detach `onopen` or check `readyState` before `send`.
Contributor guide
Research direction
Start with packages/socket-stream-client/browser.js, especially ClientStream.send, _cleanup, and onopen; trace reset through packages/ddp-client/common/connection_stream_handlers.js. Reproduce the fake-WebSocket reconnect race described in the issue, then add a regression test showing stale WS1 is ignored and WS2 completes setup without an invalid send.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- networking, web-dev
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100