Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Expo-go][Android] Fix Debugger #849

Merged
merged 7 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 55 additions & 24 deletions packages/vscode-extension/src/project/metro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { shouldUseExpoCLI } from "../utilities/expoCli";
import { Devtools } from "./devtools";
import { getLaunchConfiguration } from "../utilities/launchConfiguration";
import { EXPO_GO_BUNDLE_ID, EXPO_GO_PACKAGE_NAME } from "../builders/expoGo";
import { connectCDPAndEval } from "../utilities/connectCDPAndEval";

export interface MetroDelegate {
onBundleError(): void;
Expand Down Expand Up @@ -349,7 +350,39 @@ export class Metro implements Disposable {
);
}

private lookupWsAddressForNewDebugger(listJson: CDPTargetDescription[]) {
private async isActiveExpoGoAppRuntime(webSocketDebuggerUrl: string) {
// This method checks for a global variable that is set in the expo host runtime.
// We expect this variable to not be present in the main app runtime.
const HIDE_FROM_INSPECTOR_ENV = "(globalThis.__expo_hide_from_inspector__ || 'runtime')";
try {
const result = await connectCDPAndEval(webSocketDebuggerUrl, HIDE_FROM_INSPECTOR_ENV);
if (result === "runtime") {
return true;
}
} catch (e) {
Logger.warn(
"Error checking expo go runtime",
webSocketDebuggerUrl,
"(this could be stale/inactive runtime)",
e
);
}
return false;
}

private fixupWebSocketDebuggerUrl(websocketAddress: string) {
// CDP websocket addresses come from metro and in some configurations they
// still use the default port instead of the ephemeral port that we force metro to use.
// We override the port and host to match the current metro address.
const websocketDebuggerUrl = new URL(websocketAddress);
// replace port number with metro port number:
websocketDebuggerUrl.port = this._port.toString();
// replace host with localhost:
websocketDebuggerUrl.host = "localhost";
return websocketDebuggerUrl.toString();
}

private async lookupWsAddressForNewDebugger(listJson: CDPTargetDescription[]) {
// In the new debugger, ids are generated in the following format: "deviceId-pageId"
// but unlike with the old debugger, deviceId is a hex string (UUID most likely)
// that is stable between reloads.
Expand All @@ -362,17 +395,21 @@ export class Metro implements Disposable {
const description = newDebuggerPages[0].description;
const isExpoGo = description === EXPO_GO_BUNDLE_ID || description === EXPO_GO_PACKAGE_NAME;
if (isExpoGo) {
// Expo go apps using the new debugger would report at least two pages, first one being the Expo Go
// host runtime.
// If we detect Expo Go package (using description field), we want to pick the second page
// otherwise we pick the first one
// When only one page is listed, it means that the app runtime hasn't registered yet in which case
// we return undefined which triggers a retry mechanism.
if (newDebuggerPages.length === 1) {
return undefined;
} else {
return newDebuggerPages[1].webSocketDebuggerUrl;
// Expo go apps using the new debugger could report more then one page,
// if it exist the first one being the Expo Go host runtime.
// more over expo go on android has a bug causing newDebuggerPages
// from previously run applications to leak if the host application
// was not stopped.
// to solve both issues we check if the runtime is part of
// the host application process and select the last one that
// is not. To perform this check we use expo host functionality
// introduced in https://github.com/expo/expo/pull/32322/files
for (const newDebuggerPage of newDebuggerPages.reverse()) {
if (await this.isActiveExpoGoAppRuntime(newDebuggerPage.webSocketDebuggerUrl)) {
return newDebuggerPage.webSocketDebuggerUrl;
}
}
return undefined;
}
return newDebuggerPages[0].webSocketDebuggerUrl;
}
Expand All @@ -384,26 +421,20 @@ export class Metro implements Disposable {
const list = await fetch(`http://localhost:${this._port}/json/list`);
const listJson = await list.json();

// fixup websocket addresses on the list
for (const page of listJson) {
page.webSocketDebuggerUrl = this.fixupWebSocketDebuggerUrl(page.webSocketDebuggerUrl);
}

// When there are pages that are identified as belonging to the new debugger, we
// assume the use of the new debugger and use new debugger logic to determine the websocket address.
this.usesNewDebugger = this.filterNewDebuggerPages(listJson).length > 0;

let websocketAddress = this.usesNewDebugger
? this.lookupWsAddressForNewDebugger(listJson)
? await this.lookupWsAddressForNewDebugger(listJson)
: this.lookupWsAddressForOldDebugger(listJson);

if (websocketAddress) {
// Port and host in webSocketDebuggerUrl are set manually to match current metro address,
// because we always know what the correct one is and some versions of RN are sending out wrong port (0 or 8081)
const websocketDebuggerUrl = new URL(websocketAddress);
// replace port number with metro port number:
websocketDebuggerUrl.port = this._port.toString();
// replace host with localhost:
websocketDebuggerUrl.host = "localhost";
return websocketDebuggerUrl.toString();
}

return undefined;
return websocketAddress;
}
}

Expand Down
71 changes: 71 additions & 0 deletions packages/vscode-extension/src/utilities/connectCDPAndEval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import WebSocket from "ws";
import { Logger } from "../Logger";

export async function connectCDPAndEval(
webSocketDebuggerUrl: string,
source: string,
timeoutMs: number = 2000
): Promise<string | undefined> {
const REQUEST_ID = 0;

return new Promise((resolve, reject) => {
let settled = false;
const ws = new WebSocket(webSocketDebuggerUrl);

const timeoutHandle = setTimeout(() => {
reject(new Error("Request timeout"));
settled = true;
ws.close();
}, timeoutMs);

const settleConnection = () => {
settled = true;
clearTimeout(timeoutHandle);
ws.close();
};

ws.on("open", () => {
ws.send(
JSON.stringify({
id: REQUEST_ID,
method: "Runtime.evaluate",
params: { expression: source },
})
);
});

ws.on("error", (e) => {
reject(e);
settleConnection();
});

ws.on("close", () => {
if (!settled) {
reject(new Error("WebSocket closed before response was received."));
clearTimeout(timeoutHandle);
}
});

ws.on("message", (data) => {
Logger.debug(
`[evaluateJsFromCdpAsync] message received from ${webSocketDebuggerUrl}: ${data.toString()}`
);
try {
const response = JSON.parse(data.toString());
if (response.id === REQUEST_ID) {
if (response.error) {
reject(new Error(response.error.message));
} else if (response.result.result.type === "string") {
resolve(response.result.result.value);
} else {
resolve(undefined);
}
settleConnection();
}
} catch (e) {
reject(e);
settleConnection();
}
});
});
}
Loading