Inspector proxy: preserve ordering of messages from device (#43639)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/43639

Currently, the `react-native/dev-middleware` inspector proxy intercepts `Debugger.scriptParsed` notifications from the target and replaces `sourceMapURL` with a data uri, via an async fetch from Metro. During this async fetch, other notifications from the debugger may pass through the proxy, which results in the frontend receiving them before `Debugger.scriptParsed`.

This reordering causes problems in breakpoint resolution and pausing, because `Debugger.breakpointResolved` and `Debugger.paused` events may reference `scriptId`s unknown to the frontend while the corresponding `Debugger.scriptParsed` is delayed.

In particular, breakpoint UI state and backend state can fall out of sync, and breakpoints hit may open to the incorrect source location.

This diff modifies the proxy to use a simple per-target promise queue to ensure messages are handled in the order they were received from the target.

Changelog:
[General][Fixed] Fix breakpoints opening to incorrect location or disappearing from debugger frontend UI.

Reviewed By: motiz88

Differential Revision: D55200617

fbshipit-source-id: 27c95f822266875ed668d0bf8a525da49554cafd
This commit is contained in:
Rob Hogan
2024-03-25 13:02:00 -07:00
committed by Facebook GitHub Bot
parent 152f5f4a47
commit ac714b1c33
2 changed files with 74 additions and 22 deletions
@@ -95,6 +95,52 @@ describe.each(['HTTP', 'HTTPS'])(
}
});
test('async source map fetching does not reorder events', async () => {
serverRef.app.use(
'/source-map',
serveStaticJson({
version: 3,
// Mojibake insurance.
file: '\u2757.js',
}),
);
const {device, debugger_} = await createAndConnectTarget(
serverRef,
autoCleanup.signal,
{
app: 'bar-app',
id: 'page1',
title: 'bar-title',
vm: 'bar-vm',
},
);
try {
await Promise.all([
sendFromTargetToDebugger(device, debugger_, 'page1', {
method: 'Debugger.scriptParsed',
params: {
sourceMapURL: `${serverRef.serverBaseUrl}/source-map`,
},
}),
sendFromTargetToDebugger(device, debugger_, 'page1', {
method: 'Debugger.aSubsequentEvent',
}),
]);
expect(debugger_.handle).toHaveBeenNthCalledWith(1, {
method: 'Debugger.scriptParsed',
params: {
sourceMapURL: expect.stringMatching(/^data:/),
},
});
expect(debugger_.handle).toHaveBeenNthCalledWith(2, {
method: 'Debugger.aSubsequentEvent',
});
} finally {
device.close();
debugger_.close();
}
});
test('handling of failure to fetch source map', async () => {
const {device, debugger_} = await createAndConnectTarget(
serverRef,
@@ -76,6 +76,11 @@ export default class Device {
// Package name of the app.
#app: string;
// Sequences async processing of messages from device to preserve order. Only
// necessary while we need to accommodate #processMessageFromDeviceLegacy's
// async fetch.
#messageFromDeviceQueue: Promise<void> = Promise.resolve();
// Stores socket connection between Inspector Proxy and device.
#deviceSocket: WS;
@@ -135,20 +140,24 @@ export default class Device {
// $FlowFixMe[incompatible-call]
this.#deviceSocket.on('message', (message: string) => {
const parsedMessage = JSON.parse(message);
if (parsedMessage.event === 'getPages') {
// There's a 'getPages' message every second, so only show them if they change
if (message !== this.#lastGetPagesMessage) {
debug(
'(Debugger) (Proxy) <- (Device), getPages ping has changed: ' +
message,
);
this.#lastGetPagesMessage = message;
}
} else {
debug('(Debugger) (Proxy) <- (Device): ' + message);
}
this.#handleMessageFromDevice(parsedMessage);
this.#messageFromDeviceQueue = this.#messageFromDeviceQueue.then(
async () => {
const parsedMessage = JSON.parse(message);
if (parsedMessage.event === 'getPages') {
// There's a 'getPages' message every second, so only show them if they change
if (message !== this.#lastGetPagesMessage) {
debug(
'(Debugger) (Proxy) <- (Device), getPages ping has changed: ' +
message,
);
this.#lastGetPagesMessage = message;
}
} else {
debug('(Debugger) (Proxy) <- (Device): ' + message);
}
await this.#handleMessageFromDevice(parsedMessage);
},
);
});
// Sends 'getPages' request to device every PAGES_POLLING_INTERVAL milliseconds.
this.#pagesPollingIntervalId = setInterval(
@@ -395,7 +404,7 @@ export default class Device {
// In the future more logic will be added to this method for modifying
// some of the messages (like updating messages with source maps and file
// locations).
#handleMessageFromDevice(message: MessageFromDevice) {
async #handleMessageFromDevice(message: MessageFromDevice) {
if (message.event === 'getPages') {
this.#pages = new Map(
message.payload.map(({capabilities, ...page}) => [
@@ -498,16 +507,13 @@ export default class Device {
return;
}
// Wrapping just to make flow happy :)
// $FlowFixMe[unused-promise]
this.#processMessageFromDeviceLegacy(
await this.#processMessageFromDeviceLegacy(
parsedPayload,
debuggerConnection,
pageId,
).then(() => {
const messageToSend = JSON.stringify(parsedPayload);
debuggerSocket.send(messageToSend);
});
);
const messageToSend = JSON.stringify(parsedPayload);
debuggerSocket.send(messageToSend);
} else {
debuggerSocket.send(message.payload.wrappedEvent);
}