Description
On Android (bridgeless / New Architecture), a WebSocket that is still connecting when the JS runtime is reloaded (DevSettings.reload(), expo-updates reloadAsync(), or any ReactHost.reload) is never closed. When its handshake later completes, its websocketOpen / websocketMessage / websocketClosed events are delivered into the new JS runtime, where they are matched to whichever new WebSocket happens to reuse the same numeric id.
Three things combine:
WebSocketModule.kt adds a socket to webSocketConnections only in WebSocketListener.onOpen (source). invalidate() closes only the sockets in that map, so a socket whose handshake is in flight at reload is never closed, and its OkHttp listener stays live. (close(id) from JS cannot reach it either, for the same reason.)
- The listener emits through
reactApplicationContext.emitDeviceEvent. In bridgeless mode BridgelessReactContext.emitDeviceEvent forwards to reactHost.callFunctionOnModule(...), and hasActiveReactInstance() is reactHost.isInstanceInitialized. Both refer to the host, which now holds the new instance, so the orphaned listener's events reach the reloaded runtime.
Libraries/WebSocket/WebSocket.js keeps let nextWebSocketId = 0 at module scope, so ids restart at 0 in every runtime. The first WebSocket the new runtime opens has the same id as the orphan, and ev.id !== this._socketId does not filter the orphan's events out.
iOS is not affected: RCTWebSocketModule registers the socket in connect, so invalidate closes it.
Observed effect, with a sync client (Rocicorp Zero) that opens a socket at startup and reloads into an OTA update shortly afterwards:
- the new socket receives two
open events (the orphan's and its own), and the client throws because its connect bookkeeping was already consumed by the first;
- the new socket receives the orphan's server
connected message (carrying the orphan's own connection id) and its data messages interleaved with its own, so the client's protocol state machine sees out-of-sequence messages (pokeStart … while still receiving, cookie gaps) and has to tear down and reconnect.
Steps to reproduce
- Android, New Architecture (bridgeless), RN 0.86.3.
- In app code, on startup,
new WebSocket(url) to a server whose handshake takes a few hundred ms (or delay the 101 response on the server), and log every open / message event with the URL the instance was created with.
- Call
DevSettings.reload() (or Updates.reloadAsync()) before the handshake completes.
- In the reloaded runtime, open a new
WebSocket to the same server immediately on startup.
Expected: the pre-reload socket is closed by invalidate(), and the new socket sees exactly one open and only its own messages.
Actual: the new socket (id 0 again) receives the pre-reload socket's open and messages as well as its own.
React Native Version
0.86.3
Affected Platforms
Runtime - Android
Output of npx @react-native-community/cli info
React Native 0.86.3, Expo SDK 57 (bare/prebuild), Hermes, New Architecture (bridgeless) enabled, Android 14–17 devices.
Stacktrace or Logs
Error: Got open event but connect start time is undefined.
(from the client's onOpen handler, receiving a second open on the same WebSocket instance after the server's connected message for a different connection had already been delivered to it)
MANDATORY Reproducer
No standalone repo yet; the four steps above are the whole reproducer. Code references for each link of the chain are given in the description.
Possible fix
WebSocketModule.kt: register the WebSocket returned by client.newWebSocket(...) at connect() time (or in a separate pending map) so invalidate() cancels sockets that are still connecting, and drop listener callbacks once the module is invalidated.
- Independently,
WebSocket.js could start nextWebSocketId at a per-runtime random offset so events from any earlier runtime cannot alias a new socket's id.
Description
On Android (bridgeless / New Architecture), a
WebSocketthat is still connecting when the JS runtime is reloaded (DevSettings.reload(),expo-updatesreloadAsync(), or anyReactHost.reload) is never closed. When its handshake later completes, itswebsocketOpen/websocketMessage/websocketClosedevents are delivered into the new JS runtime, where they are matched to whichever newWebSockethappens to reuse the same numeric id.Three things combine:
WebSocketModule.ktadds a socket towebSocketConnectionsonly inWebSocketListener.onOpen(source).invalidate()closes only the sockets in that map, so a socket whose handshake is in flight at reload is never closed, and its OkHttp listener stays live. (close(id)from JS cannot reach it either, for the same reason.)reactApplicationContext.emitDeviceEvent. In bridgeless modeBridgelessReactContext.emitDeviceEventforwards toreactHost.callFunctionOnModule(...), andhasActiveReactInstance()isreactHost.isInstanceInitialized. Both refer to the host, which now holds the new instance, so the orphaned listener's events reach the reloaded runtime.Libraries/WebSocket/WebSocket.jskeepslet nextWebSocketId = 0at module scope, so ids restart at 0 in every runtime. The firstWebSocketthe new runtime opens has the same id as the orphan, andev.id !== this._socketIddoes not filter the orphan's events out.iOS is not affected:
RCTWebSocketModuleregisters the socket inconnect, soinvalidatecloses it.Observed effect, with a sync client (Rocicorp Zero) that opens a socket at startup and reloads into an OTA update shortly afterwards:
openevents (the orphan's and its own), and the client throws because its connect bookkeeping was already consumed by the first;connectedmessage (carrying the orphan's own connection id) and its data messages interleaved with its own, so the client's protocol state machine sees out-of-sequence messages (pokeStart … while still receiving, cookie gaps) and has to tear down and reconnect.Steps to reproduce
new WebSocket(url)to a server whose handshake takes a few hundred ms (or delay the 101 response on the server), and log everyopen/messageevent with the URL the instance was created with.DevSettings.reload()(orUpdates.reloadAsync()) before the handshake completes.WebSocketto the same server immediately on startup.Expected: the pre-reload socket is closed by
invalidate(), and the new socket sees exactly oneopenand only its own messages.Actual: the new socket (id 0 again) receives the pre-reload socket's
openand messages as well as its own.React Native Version
0.86.3
Affected Platforms
Runtime - Android
Output of
npx @react-native-community/cli infoStacktrace or Logs
(from the client's
onOpenhandler, receiving a secondopenon the sameWebSocketinstance after the server'sconnectedmessage for a different connection had already been delivered to it)MANDATORY Reproducer
No standalone repo yet; the four steps above are the whole reproducer. Code references for each link of the chain are given in the description.
Possible fix
WebSocketModule.kt: register theWebSocketreturned byclient.newWebSocket(...)atconnect()time (or in a separate pending map) soinvalidate()cancels sockets that are still connecting, and drop listener callbacks once the module is invalidated.WebSocket.jscould startnextWebSocketIdat a per-runtime random offset so events from any earlier runtime cannot alias a new socket's id.