Skip to content

Commit cd1305d

Browse files
vzaidmanmeta-codesync[bot]
authored andcommitted
Close DevTools sessions when the debugged page is removed (#58927)
Summary: Pull Request resolved: #58927 Notice: react/react-native-devtools-frontend#285 needs to land to display the human readable error properly in the disconnection dialog, but this fix works regardless. When a React Native host is destroyed while DevTools is attached (e.g. the app recreates its React Native instance), the DevTools frontend stayed open in a broken state: the console stopped receiving output, breakpoints were not respected, and no "disconnected" dialog was shown. Two issues combined: 1. `InspectorPackagerConnection` disconnected the local sessions when a page was removed, but the resulting `disconnect` notification was dropped because the session had already been erased, so the dev server was never told that the session ended. 2. The inspector proxy ignored device `disconnect` events for pages with the `nativePageReloads` capability, including connection rejections (e.g. when a debugger session is handed off to a reconnected device that no longer has the page), so the debugger WebSocket stayed open. This change: - Sends a `disconnect` event to the dev server for every session when a page is removed. - Closes the debugger connection in the inspector proxy with a new `[PAGE_REMOVED]` close reason when the device ends or rejects a session on a `nativePageReloads` page, so the frontend shows its disconnected dialog. - Adds a `DisconnectEventFromDevice` type with an optional `sessionId`, matching the documented protocol for legacy devices. - Adds a test ensuring all close reasons fit within the 123-byte WebSocket close frame limit. Changelog: [General][Fixed] - Fix React Native DevTools staying open but unresponsive after the debugged React Native instance is destroyed Differential Revision: D123882926
1 parent 8d658f4 commit cd1305d

11 files changed

Lines changed: 263 additions & 15 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import {WS_CLOSE_REASON} from '../inspector-proxy/Device';
12+
13+
// WebSocket close frames limit the reason to 123 bytes (RFC 6455, 5.5).
14+
const MAX_CLOSE_REASON_BYTES = 123;
15+
16+
describe('inspector proxy WebSocket close reasons', () => {
17+
test.each(Object.entries(WS_CLOSE_REASON))(
18+
'%s fits in a close frame',
19+
(_, reason) => {
20+
expect(Buffer.byteLength(reason)).toBeLessThanOrEqual(
21+
MAX_CLOSE_REASON_BYTES,
22+
);
23+
},
24+
);
25+
});

‎packages/dev-middleware/src/__tests__/InspectorProxyDeviceHandoff-test.js‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import type {
1212
GetPagesResponse,
1313
JsonPagesListResponse,
1414
} from '../inspector-proxy/types';
15-
import type {DeviceMock} from './InspectorDeviceUtils';
1615

16+
import {WS_CLOSE_REASON} from '../inspector-proxy/Device';
1717
import {fetchJson} from './FetchUtils';
1818
import {createDebuggerMock} from './InspectorDebuggerUtils';
19-
import {createDeviceMock} from './InspectorDeviceUtils';
19+
import {DeviceMock, createDeviceMock} from './InspectorDeviceUtils';
2020
import {sendFromDebuggerToTarget} from './InspectorProtocolUtils';
2121
import {withAbortSignalForEachTest} from './ResourceUtils';
2222
import {withServerForEachTest} from './ServerUtils';
@@ -301,6 +301,64 @@ describe('inspector-proxy device socket handoff', () => {
301301
}
302302
});
303303

304+
test("debugger handed off to a device that rejects the page with 'nativePageReloads' is disconnected", async () => {
305+
let device1, device2, debugger_, webSocketDebuggerUrl;
306+
try {
307+
({
308+
device: device1,
309+
pageList: [{webSocketDebuggerUrl}],
310+
} = await connectDevice(
311+
'/inspector/device?device=device&name=foo&app=bar',
312+
[
313+
{
314+
...PAGE_DEFAULTS,
315+
capabilities: {
316+
nativePageReloads: true,
317+
},
318+
},
319+
],
320+
));
321+
322+
const connectedDebugger = await createDebuggerMock(
323+
webSocketDebuggerUrl,
324+
autoCleanup.signal,
325+
);
326+
debugger_ = connectedDebugger;
327+
await until(() => expect(device1.connect).toBeCalled());
328+
const debuggerClosed = new Promise<{code: number, reason: string}>(
329+
resolve =>
330+
connectedDebugger.socket.once(
331+
'close',
332+
(code: number, reason: Buffer) =>
333+
resolve({code, reason: reason.toString()}),
334+
),
335+
);
336+
337+
// The reconnecting device no longer has page1, so it rejects the
338+
// handed-off session.
339+
const rejectingDevice = new DeviceMock(
340+
serverRef.serverBaseWsUrl +
341+
'/inspector/device?device=device&name=foo&app=bar',
342+
autoCleanup.signal,
343+
);
344+
device2 = rejectingDevice;
345+
rejectingDevice.connect.mockImplementation(({payload}) =>
346+
rejectingDevice.send({event: 'disconnect', payload}),
347+
);
348+
await rejectingDevice.ready();
349+
350+
await until(() => expect(rejectingDevice.connect).toBeCalled());
351+
expect(await debuggerClosed).toEqual({
352+
code: 1000,
353+
reason: WS_CLOSE_REASON.PAGE_REMOVED,
354+
});
355+
} finally {
356+
device1?.close();
357+
device2?.close();
358+
debugger_?.close();
359+
}
360+
});
361+
304362
test.each([
305363
['app', 'name'],
306364
['name', 'app'],

‎packages/dev-middleware/src/__tests__/InspectorProxyReactNativeReloads-test.js‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* @format
99
*/
1010

11+
import {WS_CLOSE_REASON} from '../inspector-proxy/Device';
1112
import {fetchJson} from './FetchUtils';
1213
import {createDebuggerMock} from './InspectorDebuggerUtils';
1314
import {createDeviceMock} from './InspectorDeviceUtils';
@@ -401,6 +402,52 @@ describe('inspector proxy React Native reloads', () => {
401402
}
402403
});
403404

405+
test.each([
406+
['with', true],
407+
['without', false],
408+
])(
409+
"device disconnect event %s session ID closes the debugger connection when target has 'nativePageReloads' capability flag",
410+
async (_, includeSessionId) => {
411+
const {device, debugger_, sessionId} = await createAndConnectTarget(
412+
serverRef,
413+
autoCleanup.signal,
414+
{
415+
app: 'bar-app',
416+
id: 'page1',
417+
title: 'bar-title',
418+
vm: 'bar-vm',
419+
capabilities: {
420+
nativePageReloads: true,
421+
},
422+
},
423+
);
424+
const debuggerClosed = new Promise<{code: number, reason: string}>(
425+
resolve =>
426+
debugger_.socket.once('close', (code: number, reason: Buffer) =>
427+
resolve({code, reason: reason.toString()}),
428+
),
429+
);
430+
431+
try {
432+
device.send({
433+
event: 'disconnect',
434+
payload: {
435+
pageId: 'page1',
436+
...(includeSessionId ? {sessionId} : {}),
437+
},
438+
});
439+
expect(await debuggerClosed).toEqual({
440+
code: 1000,
441+
reason: WS_CLOSE_REASON.PAGE_REMOVED,
442+
});
443+
expect(debugger_.handle).not.toBeCalledWith({method: 'reload'});
444+
} finally {
445+
device.close();
446+
debugger_.close();
447+
}
448+
},
449+
);
450+
404451
test("disabled when target has 'nativePageReloads' capability flag", async () => {
405452
let device1;
406453
try {

‎packages/dev-middleware/src/inspector-proxy/Device.js‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ const WS_CLOSURE_CODE = {
4747
// https://github.com/facebook/react-native-devtools-frontend/blob/3d17e0fd462dc698db34586697cce2371b25e0d3/front_end/ui/legacy/components/utils/TargetDetachedDialog.ts#L50-L64
4848
export const WS_CLOSE_REASON = {
4949
PAGE_NOT_FOUND: '[PAGE_NOT_FOUND] Debugger page not found',
50+
PAGE_REMOVED:
51+
'[PAGE_REMOVED] The React Native instance being debugged no longer exists. Relaunch DevTools to debug a new instance.',
5052
CONNECTION_LOST: '[CONNECTION_LOST] Connection lost to corresponding device',
5153
RECREATING_DEVICE: '[RECREATING_DEVICE] Recreating device connection',
5254
NEW_DEBUGGER_OPENED:
@@ -606,8 +608,8 @@ export default class Device {
606608
}
607609
}
608610
} else if (message.event === 'disconnect') {
609-
// Device sends disconnect events only when page is reloaded or
610-
// if debugger socket was disconnected.
611+
// Device sends disconnect events when a legacy page is reloaded, or when
612+
// it ends or rejects a debugger session (e.g. the page was removed).
611613
const pageId = message.payload.pageId;
612614
const sessionId = message.payload.sessionId;
613615

@@ -616,6 +618,19 @@ export default class Device {
616618
const page: ?Page = this.#pages.get(pageId);
617619

618620
if (page != null && this.#pageHasCapability(page, 'nativePageReloads')) {
621+
for (const [sid, debuggerConnection] of this.#debuggerConnections) {
622+
if (
623+
sessionId != null
624+
? sid === sessionId
625+
: debuggerConnection.pageId === pageId
626+
) {
627+
this.#debuggerConnections.delete(sid);
628+
debuggerConnection.socket.close(
629+
WS_CLOSURE_CODE.NORMAL,
630+
WS_CLOSE_REASON.PAGE_REMOVED,
631+
);
632+
}
633+
}
619634
return;
620635
}
621636

‎packages/dev-middleware/src/inspector-proxy/__docs__/README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,12 @@ Debugger Proxy Device
175175
**Connection Rejection:**
176176

177177
If a device cannot accept a `connect` (e.g., page doesn't exist), it should send
178-
a `disconnect` back to the proxy for that `pageId`.
178+
a `disconnect` back to the proxy for that `pageId`. Devices also send a
179+
`disconnect` for each session when a page is removed.
180+
181+
For targets with `nativePageReloads`, the proxy closes the debugger connection
182+
with `[PAGE_REMOVED]` when it receives a `disconnect` for its session (or for
183+
its page, if `sessionId` is omitted).
179184

180185
### Connection Semantics
181186

@@ -215,6 +220,7 @@ The proxy uses specific close reasons that DevTools frontends may recognize:
215220
| Reason | Context |
216221
| ----------------------- | --------------------------------------- |
217222
| `[PAGE_NOT_FOUND]` | Debugger connected to non-existent page |
223+
| `[PAGE_REMOVED]` | Device ended or rejected the session |
218224
| `[CONNECTION_LOST]` | Device disconnected |
219225
| `[RECREATING_DEVICE]` | Device is reconnecting |
220226
| `[NEW_DEBUGGER_OPENED]` | Another debugger took over this page |

‎packages/dev-middleware/src/inspector-proxy/types.js‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ export type DisconnectRequest = Readonly<{
9696
payload: Readonly<{pageId: string, sessionId: string}>,
9797
}>;
9898

99+
// Event sent from Device to Inspector Proxy when a debugger session ends or is
100+
// rejected. Legacy devices omit the sessionId.
101+
export type DisconnectEventFromDevice = Readonly<{
102+
event: 'disconnect',
103+
payload: Readonly<{pageId: string, sessionId?: string}>,
104+
}>;
105+
99106
// Request sent from Inspector Proxy to Device to get a list of pages.
100107
export type GetPagesRequest = {event: 'getPages'};
101108

@@ -107,7 +114,7 @@ export type GetPagesResponse = {
107114

108115
// Union type for all possible messages sent from device to Inspector Proxy.
109116
export type MessageFromDevice =
110-
GetPagesResponse | WrappedEventFromDevice | DisconnectRequest;
117+
GetPagesResponse | WrappedEventFromDevice | DisconnectEventFromDevice;
111118

112119
// Union type for all possible messages sent from Inspector Proxy to device.
113120
export type MessageToDevice =

‎packages/react-native/ReactCommon/jsinspector-modern/InspectorPackagerConnection.cpp‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,7 @@ void InspectorPackagerConnection::Impl::handleConnect(
167167
// be a no op (because the session is not added to
168168
// `inspectorSessionsByPage_`), so let's always notify the remote client
169169
// of the disconnection ourselves.
170-
folly::dynamic disconnectPayload =
171-
folly::dynamic::object("pageId", pageId)("sessionId", proxySessionId);
172-
sendToPackager(
173-
folly::dynamic::object("event", "disconnect")(
174-
"payload", std::move(disconnectPayload)));
170+
sendDisconnectToPackager(pageId, proxySessionId);
175171
return;
176172
}
177173
pageIt->second.emplace(
@@ -317,13 +313,18 @@ void InspectorPackagerConnection::Impl::didClose() {
317313
}
318314

319315
void InspectorPackagerConnection::Impl::onPageRemoved(int pageId) {
320-
auto pageIt = inspectorSessionsByPage_.find(std::to_string(pageId));
316+
auto pageIdString = std::to_string(pageId);
317+
auto pageIt = inspectorSessionsByPage_.find(pageIdString);
321318

322319
while (pageIt != inspectorSessionsByPage_.end() && !pageIt->second.empty()) {
320+
auto proxySessionId = pageIt->second.begin()->first;
323321
pageIt = disconnectSession({
324322
pageIt,
325323
pageIt->second.begin(),
326324
});
325+
// RemoteConnection::onDisconnect() is a no op once the session is
326+
// removed, so notify the remote client ourselves.
327+
sendDisconnectToPackager(pageIdString, proxySessionId);
327328
}
328329
}
329330

@@ -407,6 +408,16 @@ void InspectorPackagerConnection::Impl::sendToPackager(
407408
webSocket_->send(folly::toJson(message));
408409
}
409410

411+
void InspectorPackagerConnection::Impl::sendDisconnectToPackager(
412+
const std::string& pageId,
413+
const std::string& proxySessionId) {
414+
sendToPackager(
415+
folly::dynamic::object("event", "disconnect")(
416+
"payload",
417+
folly::dynamic::object("pageId", pageId)(
418+
"sessionId", proxySessionId)));
419+
}
420+
410421
void InspectorPackagerConnection::Impl::scheduleSendToPackager(
411422
folly::dynamic message,
412423
SessionId sourceSessionId,

‎packages/react-native/ReactCommon/jsinspector-modern/InspectorPackagerConnectionImpl.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ class InspectorPackagerConnection::Impl : public IWebSocketDelegate,
106106
void closeAllConnections();
107107
void disposeWebSocket();
108108
void sendToPackager(const folly::dynamic &message);
109+
void sendDisconnectToPackager(const std::string &pageId, const std::string &proxySessionId);
109110

110111
void abort(std::optional<int> posixCode, const std::string &message, const std::string &cause);
111112

‎packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionMultiSessionTest.cpp‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ TEST_F(
111111
mockCallsMustBeInSequence.reset();
112112
EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
113113
EXPECT_CALL(*localConnections_[1], disconnect()).RetiresOnSaturation();
114+
expectDisconnectSentToPackager(pageId, "session-1");
115+
expectDisconnectSentToPackager(pageId, "session-2");
114116
getInspectorInstance().removePage(pageId);
115117
}
116118

@@ -193,6 +195,8 @@ TEST_F(
193195
mockCallsMustBeInSequence.reset();
194196
EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
195197
EXPECT_CALL(*localConnections_[1], disconnect()).RetiresOnSaturation();
198+
expectDisconnectSentToPackager(pageId, "session-1");
199+
expectDisconnectSentToPackager(pageId, "session-2");
196200
getInspectorInstance().removePage(pageId);
197201
}
198202

@@ -269,6 +273,7 @@ TEST_F(InspectorPackagerConnectionMultiSessionTest, TestDisconnectBySessionId) {
269273

270274
// Clean up
271275
EXPECT_CALL(*localConnections_[1], disconnect()).RetiresOnSaturation();
276+
expectDisconnectSentToPackager(pageId, "session-2");
272277
getInspectorInstance().removePage(pageId);
273278
}
274279

@@ -323,6 +328,7 @@ TEST_F(
323328
localConnections_[0]->getRemoteConnection().onDisconnect();
324329

325330
EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
331+
expectDisconnectSentToPackager(pageId, "my-session");
326332
getInspectorInstance().removePage(pageId);
327333
}
328334

@@ -493,13 +499,17 @@ TEST_F(
493499
toJson(std::to_string(pageId))));
494500
ASSERT_TRUE(localConnections_[2]);
495501

496-
// Remove the page - all sessions should be disconnected.
497-
// This is not guaranteed to be in order, so tear down our InSequence guard.
502+
// Remove the page - all sessions should be disconnected and reported to the
503+
// packager. This is not guaranteed to be in order, so tear down our
504+
// InSequence guard.
498505
mockCallsMustBeInSequence.reset();
499506

500507
EXPECT_CALL(*localConnections_[0], disconnect());
501508
EXPECT_CALL(*localConnections_[1], disconnect());
502509
EXPECT_CALL(*localConnections_[2], disconnect());
510+
expectDisconnectSentToPackager(pageId, "session-1");
511+
expectDisconnectSentToPackager(pageId, "session-2");
512+
expectDisconnectSentToPackager(pageId, "session-3");
503513
getInspectorInstance().removePage(pageId);
504514

505515
EXPECT_FALSE(localConnections_[0]);
@@ -592,6 +602,8 @@ TEST_F(
592602
mockCallsMustBeInSequence.reset();
593603
EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
594604
EXPECT_CALL(*localConnections_[1], disconnect()).RetiresOnSaturation();
605+
expectDisconnectSentToPackager(pageId);
606+
expectDisconnectSentToPackager(pageId, "session-1");
595607
getInspectorInstance().removePage(pageId);
596608
}
597609

@@ -686,6 +698,7 @@ TEST_F(
686698

687699
// Clean up
688700
EXPECT_CALL(*localConnections_[2], disconnect()).RetiresOnSaturation();
701+
expectDisconnectSentToPackager(pageId);
689702
getInspectorInstance().removePage(pageId);
690703
}
691704

0 commit comments

Comments
 (0)