Skip to content

Commit 88343d2

Browse files
authored
fix(debug-ios): make the CDP proxy pick a free port and survive reconnects (#6177)
* fix(debug-ios): make the CDP proxy pick a free port and survive reconnects The port check bound localhost while the server binds every interface, and on macOS those do not conflict, so a taken port passed the check and the listen crashed the CLI. The check now tests all four addresses and the proxy moves on if the bind still fails. The connection lock was released on the connection event, which never fires for a client that drops during the handshake, so every later client timed out until the CLI exited. A late close from a replaced client could also destroy the socket its successor had just received. Connections now run through an in-process queue, and each session only cleans up after itself; a replaced client is closed with a reason. Also clears the port service's timers, whose number type check never matched in Node, which produced false port warnings. * fix(debug-ios): only accept debugger connections from this machine and DevTools The proxy listened on every interface with no origin check, while the debugger can run any code in the app. Anything on the network, or any web page, could connect. It now binds the two loopback addresses and rejects browser origins other than DevTools and localhost. * fix(debug-ios): clean up and exit when the app's socket closes The backend close handler cleared the session before closing the WebSocket, so the WebSocket's close handler no longer saw it as current and skipped destroying the socket and exiting without --watch. * test(net): skip unavailable addresses through vitest's test context
1 parent 6a62533 commit 88343d2

5 files changed

Lines changed: 546 additions & 91 deletions

File tree

‎lib/common/services/net-service.ts‎

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -48,35 +48,32 @@ export class Net implements INet {
4848
}
4949

5050
public async isPortAvailable(port: number): Promise<boolean> {
51-
return new Promise<boolean>((resolve, reject) => {
52-
let isResolved = false;
53-
const server = net.createServer();
51+
// A listener on one address does not stop a bind on another (e.g. the
52+
// wildcard next to the loopback), so each of them has to be free.
53+
for (const host of ["127.0.0.1", "::1", "0.0.0.0", "::"]) {
54+
if (!(await this.canListen(port, host))) {
55+
return false;
56+
}
57+
}
5458

55-
server.on("error", (err: Error) => {
56-
if (!isResolved) {
57-
isResolved = true;
58-
resolve(false);
59-
}
60-
});
59+
return true;
60+
}
6161

62-
server.once("close", () => {
63-
if (!isResolved) {
64-
// "close" will be emitted right after "error"
65-
isResolved = true;
66-
resolve(true);
67-
}
68-
});
62+
private canListen(port: number, host: string): Promise<boolean> {
63+
return new Promise<boolean>((resolve) => {
64+
const server = net.createServer();
6965

70-
server.on("listening", (err: Error) => {
71-
if (err && !isResolved) {
72-
isResolved = true;
73-
resolve(true);
74-
}
66+
server.once("error", (err: NodeJS.ErrnoException) => {
67+
// The host has no such address (e.g. IPv6 is disabled), so nothing
68+
// can be listening on it either.
69+
resolve(err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT");
70+
});
7571

76-
server.close();
72+
server.once("listening", () => {
73+
server.close(() => resolve(true));
7774
});
7875

79-
server.listen(port, "localhost");
76+
server.listen(port, host);
8077
});
8178
}
8279

‎lib/common/test/unit-tests/services/net-service.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Net } from "../../../services/net-service";
22
import { assert } from "chai";
3+
import * as net from "net";
34
import { Yok } from "../../../yok";
45
import { ErrorsStub, CommonLoggerStub } from "../stubs";
56
import { EOL } from "os";
@@ -25,6 +26,43 @@ describe("net", () => {
2526
return testInjector;
2627
};
2728

29+
describe("isPortAvailable", () => {
30+
const hosts = [undefined, "127.0.0.1", "::1", "0.0.0.0", "::"];
31+
32+
for (const host of hosts) {
33+
it(`returns false for a port taken on ${host || "the default address"}`, async (context: any) => {
34+
const service = createTestInjector("darwin").resolve<INet>(Net);
35+
const server = net.createServer();
36+
const listening = await new Promise<boolean>((resolve) => {
37+
server.once("error", () => resolve(false));
38+
const onListening = () => resolve(true);
39+
host ? server.listen(0, host, onListening) : server.listen(0, onListening);
40+
});
41+
if (!listening) {
42+
context.skip();
43+
return;
44+
}
45+
const { port } = <net.AddressInfo>server.address();
46+
47+
try {
48+
assert.isFalse(await service.isPortAvailable(port));
49+
} finally {
50+
await new Promise((resolve) => server.close(resolve));
51+
}
52+
});
53+
}
54+
55+
it("returns true for a free port", async () => {
56+
const service = createTestInjector("darwin").resolve<INet>(Net);
57+
const server = net.createServer();
58+
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
59+
const { port } = <net.AddressInfo>server.address();
60+
await new Promise((resolve) => server.close(resolve));
61+
62+
assert.isTrue(await service.isPortAvailable(port));
63+
});
64+
});
65+
2866
describe("waitForPortToListen", () => {
2967
let execCalledCount = 0;
3068
beforeEach(() => {

0 commit comments

Comments
 (0)