Skip to content

[Android] WebSocket still connecting at reload is never closed; its events reach the new runtime under a reused socket id #58895

Description

@pawarren

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:

  1. 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.)
  2. 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.
  3. 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

  1. Android, New Architecture (bridgeless), RN 0.86.3.
  2. 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.
  3. Call DevSettings.reload() (or Updates.reloadAsync()) before the handshake completes.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: Author FeedbackNeeds: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions