Skip to content

Commit 259682e

Browse files
committed
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.
1 parent 719a870 commit 259682e

5 files changed

Lines changed: 384 additions & 93 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: 34 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,39 @@ 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 () => {
34+
const service = createTestInjector("darwin").resolve<INet>(Net);
35+
const server = net.createServer();
36+
await new Promise<void>((resolve) =>
37+
host
38+
? server.listen(0, host, () => resolve())
39+
: server.listen(0, () => resolve()),
40+
);
41+
const { port } = <net.AddressInfo>server.address();
42+
43+
try {
44+
assert.isFalse(await service.isPortAvailable(port));
45+
} finally {
46+
await new Promise((resolve) => server.close(resolve));
47+
}
48+
});
49+
}
50+
51+
it("returns true for a free port", async () => {
52+
const service = createTestInjector("darwin").resolve<INet>(Net);
53+
const server = net.createServer();
54+
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
55+
const { port } = <net.AddressInfo>server.address();
56+
await new Promise((resolve) => server.close(resolve));
57+
58+
assert.isTrue(await service.isPortAvailable(port));
59+
});
60+
});
61+
2862
describe("waitForPortToListen", () => {
2963
let execCalledCount = 0;
3064
beforeEach(() => {

‎lib/device-sockets/ios/app-debug-socket-proxy-factory.ts‎

Lines changed: 109 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ import { IDictionary, IErrors, INet } from "../../common/declarations";
88
import { injector } from "../../common/yok";
99
import { ITempService } from "../../definitions/temp-service";
1010

11+
const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client";
12+
13+
interface IDebugProxySession {
14+
appDebugSocket: net.Socket;
15+
webSocket?: ws;
16+
}
17+
1118
export class AppDebugSocketProxyFactory
1219
extends EventEmitter
1320
implements IAppDebugSocketProxyFactory
@@ -18,7 +25,6 @@ export class AppDebugSocketProxyFactory
1825
constructor(
1926
private $logger: ILogger,
2027
private $errors: IErrors,
21-
private $lockService: ILockService,
2228
private $options: IOptions,
2329
private $tempService: ITempService,
2430
private $net: INet,
@@ -132,7 +138,6 @@ export class AppDebugSocketProxyFactory
132138
projectName: string,
133139
projectDir: string,
134140
): Promise<ws.Server> {
135-
let clientConnectionLockRelease: () => void;
136141
const cacheKey = `${device.deviceInfo.identifier}-${appId}`;
137142
const existingServer = this.deviceWebServers[cacheKey];
138143
if (existingServer) {
@@ -141,76 +146,83 @@ export class AppDebugSocketProxyFactory
141146
);
142147
}
143148

144-
// NOTE: We will try to provide command line options to select ports, at least on the localhost.
145-
const localPort = await this.$net.getAvailablePortInRange(41000);
146-
147149
this.$logger.info(
148150
"\nSetting up debugger proxy...\nPress Ctrl + C to terminate, or disconnect.\n",
149151
);
150152

151153
// NB: When the inspector frontend connects we might not have connected to the inspector backend yet.
152154
// That's why we use the verifyClient callback of the websocket server to stall the upgrade request until we connect.
153-
// We store the socket that connects us to the device in the upgrade request object itself and later on retrieve it
154-
// in the connection callback.
155-
156-
let currentAppSocket: net.Socket = null;
157-
let currentWebSocket: ws = null;
158-
const server = new ws.Server(<any>{
159-
port: localPort,
160-
verifyClient: async (
155+
// We store the session in the upgrade request object itself and later on retrieve it in the connection callback.
156+
157+
// The runtime serves a single frontend, so the newest client takes over.
158+
// Handlers of a replaced session must leave the device socket alone, as
159+
// by then it belongs to the session that replaced it.
160+
let currentSession: IDebugProxySession = null;
161+
let pendingConnect: Promise<void> = Promise.resolve();
162+
163+
const connectToApp = async (): Promise<IDebugProxySession> => {
164+
const previousSession = currentSession;
165+
currentSession = null;
166+
if (previousSession) {
167+
previousSession.webSocket?.close(1000, REPLACED_CLOSE_REASON);
168+
await device.destroyDebugSocket(appId);
169+
}
170+
171+
const appDebugSocket = await device.getDebugSocket(
172+
appId,
173+
projectName,
174+
projectDir,
175+
);
176+
appDebugSocket.on("error", (err: Error) => {
177+
this.$logger.trace("Error on debugger deviceSocket", err);
178+
});
179+
this.$logger.info("Backend socket created.");
180+
181+
currentSession = { appDebugSocket };
182+
return currentSession;
183+
};
184+
185+
const server = await this.listenOnAvailablePort(41000, {
186+
verifyClient: (
161187
info: any,
162188
callback: (res: boolean, code?: number, message?: string) => void,
163189
) => {
164-
let acceptHandshake = true;
165-
clientConnectionLockRelease = null;
166-
167-
try {
168-
clientConnectionLockRelease = await this.$lockService.lock(
169-
`debug-connection-${device.deviceInfo.identifier}-${appId}.lock`,
170-
);
171-
172-
this.$logger.info("Frontend client connected.");
173-
let appDebugSocket;
174-
if (currentAppSocket) {
175-
currentAppSocket.removeAllListeners();
176-
currentAppSocket = null;
177-
if (currentWebSocket) {
178-
currentWebSocket.removeAllListeners();
179-
currentWebSocket.close();
180-
currentWebSocket = null;
181-
}
182-
await device.destroyDebugSocket(appId);
190+
this.$logger.info("Frontend client connected.");
191+
192+
// Not released on "connection": that never fires for a client that
193+
// drops during the handshake.
194+
pendingConnect = pendingConnect.then(async () => {
195+
try {
196+
info.req["__session"] = await connectToApp();
197+
callback(true);
198+
} catch (err) {
199+
err.deviceIdentifier = device.deviceInfo.identifier;
200+
this.$logger.trace(err);
201+
this.emit(CONNECTION_ERROR_EVENT_NAME, err);
202+
this.$logger.warn(
203+
`Cannot connect to device socket. The error message is '${err.message}'.`,
204+
);
205+
callback(false);
183206
}
184-
appDebugSocket = await device.getDebugSocket(
185-
appId,
186-
projectName,
187-
projectDir,
188-
);
189-
currentAppSocket = appDebugSocket;
190-
this.$logger.info("Backend socket created.");
191-
info.req["__deviceSocket"] = appDebugSocket;
192-
} catch (err) {
193-
if (clientConnectionLockRelease) {
194-
clientConnectionLockRelease();
195-
}
196-
err.deviceIdentifier = device.deviceInfo.identifier;
197-
this.$logger.trace(err);
198-
this.emit(CONNECTION_ERROR_EVENT_NAME, err);
199-
acceptHandshake = false;
200-
this.$logger.warn(
201-
`Cannot connect to device socket. The error message is '${err.message}'.`,
202-
);
203-
}
204-
205-
callback(acceptHandshake);
207+
});
206208
},
207209
});
208210
this.deviceWebServers[cacheKey] = server;
209211
server.on("connection", (webSocket, req) => {
210-
currentWebSocket = webSocket;
211212
const encoding = "utf16le";
212213

213-
const appDebugSocket: net.Socket = (<any>req)["__deviceSocket"];
214+
webSocket.on("error", (err) => {
215+
this.$logger.trace("Error on debugger websocket", err);
216+
});
217+
218+
const session: IDebugProxySession = (<any>req)["__session"];
219+
if (session !== currentSession || session.appDebugSocket.destroyed) {
220+
webSocket.close(1000, REPLACED_CLOSE_REASON);
221+
return;
222+
}
223+
224+
session.webSocket = webSocket;
225+
const appDebugSocket = session.appDebugSocket;
214226
const packets = new MessageUnpackStream();
215227
appDebugSocket.pipe(packets);
216228

@@ -230,14 +242,6 @@ export class AppDebugSocketProxyFactory
230242
}
231243
});
232244

233-
webSocket.on("error", (err) => {
234-
this.$logger.trace("Error on debugger websocket", err);
235-
});
236-
237-
appDebugSocket.on("error", (err) => {
238-
this.$logger.trace("Error on debugger deviceSocket", err);
239-
});
240-
241245
webSocket.on("message", (message) => {
242246
const msg = message.toString();
243247
if (process.env.DEBUG_DEVTOOLS_SOCKETS) {
@@ -253,28 +257,65 @@ export class AppDebugSocketProxyFactory
253257
});
254258

255259
appDebugSocket.on("close", () => {
256-
currentAppSocket = null;
257260
this.$logger.trace("Backend socket closed!");
261+
if (currentSession === session) {
262+
currentSession = null;
263+
}
258264
webSocket.close();
259265
});
260266

261267
webSocket.on("close", async () => {
262-
currentWebSocket = null;
263268
this.$logger.trace("Frontend socket closed!");
264269
appDebugSocket.unpipe(packets);
265270
packets.destroy();
271+
if (currentSession !== session) {
272+
return;
273+
}
274+
275+
currentSession = null;
266276
await device.destroyDebugSocket(appId);
267277
if (!this.$options.watch) {
268278
process.exit(0);
269279
}
270280
});
271-
272-
clientConnectionLockRelease();
273281
});
274282

275283
return server;
276284
}
277285

286+
private async listenOnAvailablePort(
287+
startPort: number,
288+
options: ws.ServerOptions,
289+
): Promise<ws.Server> {
290+
// The port can still get taken after the check, by another CLI for example.
291+
let port = await this.$net.getAvailablePortInRange(startPort);
292+
while (true) {
293+
const result = await new Promise<ws.Server | NodeJS.ErrnoException>(
294+
(resolve) => {
295+
const server = new ws.Server({ ...options, port });
296+
server.once("listening", () => {
297+
server.removeAllListeners("error");
298+
server.on("error", (err: Error) => {
299+
this.$logger.trace("Error on debugger web socket server", err);
300+
});
301+
resolve(server);
302+
});
303+
server.once("error", resolve);
304+
},
305+
);
306+
307+
if (result instanceof ws.Server) {
308+
return result;
309+
}
310+
311+
if (result.code !== "EADDRINUSE") {
312+
throw result;
313+
}
314+
315+
port = await this.$net.getAvailablePortInRange(port + 1);
316+
}
317+
}
318+
278319
public removeAllProxies() {
279320
let deviceId;
280321
for (deviceId in this.deviceWebServers) {

‎lib/services/ios-debugger-port-service.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@ export class IOSDebuggerPortService implements IIOSDebuggerPortService {
143143
private clearTimeout(data: IIOSDebuggerPortData): void {
144144
const storedData =
145145
this.mapDebuggerPortData[`${data.deviceId}${data.appId}`];
146-
if (storedData && typeof storedData.timer === "number") {
147-
clearTimeout(storedData.timer);
146+
if (storedData && storedData.timer) {
147+
clearTimeout(<any>storedData.timer);
148148
}
149149
}
150150
}

0 commit comments

Comments
 (0)