Skip to content

iOS dev packager WebSocket (RCTReconnectingWebSocket) multiplies its reconnect timers when the Metro port answers with an HTTP error instead of refusing, exhausting the host's ephemeral ports #58988

Open
samirakash248-max/react-native
#1

Description

@philhradecs

Environment

  • React Native 0.86.3 (file unchanged on 0.87.1 and main as of 2026-10-09)
  • Expo SDK 57 dev client (expo ~57.0.25, expo-dev-client ~57.0.19), debug build
  • iOS Simulator, iPhone 17, iOS 26.5 runtime, Xcode 27.0, macOS 27.0 host
  • How the socket got attached: the dev client was launched against a Metro on
    127.0.0.1:8090. That Metro exited and the app stayed open in the simulator.
    About 20 hours later an unrelated local HTTP server (process-compose's API)
    bound port 8090. It answers GET /message?role=ios with 404, not a WebSocket upgrade.

Observed

From the simulator's unified log (log show --predicate 'process == "<app>"',
counting initial parent-flow lines to :8090 per minute):

Port state Connections per minute
Nothing listening (connection refused), for hours about 58
HTTP server answering 404, minute 1 → 4 115 → 1,289 → 3,153 → 5,806

Within five minutes the host had about 15,000 sockets in TIME_WAIT, the
ephemeral port range was exhausted, and every localhost connection on the
machine (not just the app's) failed with can't assign requested address.

Caveat: the unified log redacts URLs, so it shows the connection rate per
process, not which client made them. The other clients in the dev client were
ruled out by reading their source: InspectorPackagerConnection backs off
exponentially (2 s → 120 s cap), Expo's HMR client does not reconnect on its
own, and the dev launcher's status probe only pings Bonjour-discovered servers.
A later 45 s replay of the same 404 against the still-running app drew zero
connections: by then its reconnect timers had already stopped (cause unknown),
so the storm was not reproduced live. The curve above plus the code path below
is the evidence.

Expected

At most one pending reconnect at a time, with a delay that backs off, and the
same behaviour whether the port refuses or answers with an HTTP error. This is
what the inspector connection in the same app already does
(ReactCommon/jsinspector-modern/InspectorPackagerConnection.cpp, lines 24–26
and 351–391: a reconnectPending_ guard and doubling delay up to MAX_RECONNECT_DELAY).

Code path (packages/react-native/Libraries/WebSocket/RCTReconnectingWebSocket.m)

RCTPackagerConnection.mm builds the URL <scheme>://<host>:<port>/message?role=ios
(socketForLocation, lines 65–93) and calls [_socket start] (line 101).

  1. reconnect (lines 71–86) captures __weak SRWebSocket *socket = _socket, waits 2 s,
    then runs [weakSelf start] and if (!socket) { [weakSelf reconnect]; }.
  2. start (lines 46–56) begins with [self stop] (lines 58–64), which sets _socket = nil
    and releases the old socket. So by the time the if (!socket) check runs, the weak
    reference is nearly always nil, and the block schedules a second timer unconditionally.
  3. The new socket's failure then arrives through one of two delegate methods:
    • didFailWithError (lines 93–99) calls reconnect for every error except
      ECONNREFUSED.
    • didCloseWithCode (lines 101–108) always calls reconnect.

Per 2 s cycle, one pending timer therefore becomes two whenever the failure is
not a refused TCP connection: one from the if (!socket) re-arm, one from the
failure callback. With a refused connection the failure callback is skipped and
the timer count stays flat.

A non-Metro HTTP server is the "not refused" case. SocketRocket turns any
response code ≥ 400 into an NSError in SRWebSocketErrorDomain with code
2132 (SRWebSocket.m, _HTTPHeadersDidFinish; SRError.m,
SRHTTPErrorWithCodeDescription) and delivers it via didFailWithError.
2132 != ECONNREFUSED, so step 3 re-arms. That matches the table: flat while
refused, doubling once something answered.

Unverified detail: the flat phase measured about one connection per second,
while a single surviving timer predicts one per 2 s. Two timers surviving the
refused phase would explain it, but that was not checked.

Minimal reproduction (as far as the evidence supports)

  1. Debug build of any RN 0.86 iOS app (Expo dev client or bare). Run Metro on a
    fixed port, launch the app so the packager socket connects, then stop Metro.
    Leave the app open. Verified on 2026-10-11 as the state that produced the log.
  2. Start any HTTP server that answers 404 on that port, e.g.
    python3 -m http.server 8081. Unverified as a repro: the observed trigger was
    process-compose's API server; a plain 404 server should behave the same, since
    SocketRocket only looks at the status code.
  3. Count connections to the port per minute, e.g.
    xcrun simctl spawn booted log stream --predicate 'process == "<app>"' | grep -c ...
    or netstat -an | grep -c ':8081 .*TIME_WAIT'. Expect the rate to roughly double
    every few seconds instead of staying near one per 2 s.

Suggested fix (a sketch, not a patch)

  • Drop the if (!socket) [weakSelf reconnect] re-arm in reconnect. The failure
    callbacks already re-arm, and start always releases the old socket, so the
    check does not do what it appears to do.
  • Guard reconnect with a single _reconnectPending flag, cleared when the timer
    fires, mirroring InspectorPackagerConnection::Impl::reconnect.
  • Back off exponentially from 2 s to a cap, like the inspector's 120 s.
  • Possibly treat ECONNREFUSED the same as other failures once the backoff
    exists; the special case only hides the double re-arm today.

Activity

  1. react-native-bot commented on Oct 11, 2026

    @react-native-bot
    Collaborator

    Warning

    Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:


    You can read more about about it on our website: How to report a bug.

  2. added a commit that references this issue on Oct 14, 2026
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