meteor / meteor/meteor

ClientStream can send() on a WebSocket that is still CONNECTING after a reconnect race (InvalidStateError)

Open
#14,730 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.