Repository navigation
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
Description
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 answersGET /message?role=ioswith404, 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).
reconnect(lines 71–86) captures__weak SRWebSocket *socket = _socket, waits 2 s,
then runs[weakSelf start]andif (!socket) { [weakSelf reconnect]; }.start(lines 46–56) begins with[self stop](lines 58–64), which sets_socket = nil
and releases the old socket. So by the time theif (!socket)check runs, the weak
reference is nearly always nil, and the block schedules a second timer unconditionally.- The new socket's failure then arrives through one of two delegate methods:
didFailWithError(lines 93–99) callsreconnectfor every error except
ECONNREFUSED.didCloseWithCode(lines 101–108) always callsreconnect.
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)
- 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. - 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. - Count connections to the port per minute, e.g.
xcrun simctl spawn booted log stream --predicate 'process == "<app>"' | grep -c ...
ornetstat -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 inreconnect. The failure
callbacks already re-arm, andstartalways releases the old socket, so the
check does not do what it appears to do. - Guard
reconnectwith a single_reconnectPendingflag, cleared when the timer
fires, mirroringInspectorPackagerConnection::Impl::reconnect. - Back off exponentially from 2 s to a cap, like the inspector's 120 s.
- Possibly treat
ECONNREFUSEDthe same as other failures once the backoff
exists; the special case only hides the double re-arm today.
Activity
react-native-bot commentedon Oct 11, 2026
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.
Merge pull request #1 from samirakash248-max/samirakash248-max-patch-1