From 54bb71978b01abc58095d7a1a8fe783c7dd6a83e Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 17:23:45 +0100 Subject: [PATCH 1/4] 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. --- lib/common/services/net-service.ts | 43 ++-- .../test/unit-tests/services/net-service.ts | 34 +++ .../ios/app-debug-socket-proxy-factory.ts | 177 ++++++++------ lib/services/ios-debugger-port-service.ts | 4 +- .../app-debug-socket-proxy-factory.ts | 219 ++++++++++++++++++ 5 files changed, 384 insertions(+), 93 deletions(-) create mode 100644 test/device-sockets/app-debug-socket-proxy-factory.ts diff --git a/lib/common/services/net-service.ts b/lib/common/services/net-service.ts index d519265ab6..2c08ee218c 100644 --- a/lib/common/services/net-service.ts +++ b/lib/common/services/net-service.ts @@ -48,35 +48,32 @@ export class Net implements INet { } public async isPortAvailable(port: number): Promise { - return new Promise((resolve, reject) => { - let isResolved = false; - const server = net.createServer(); + // A listener on one address does not stop a bind on another (e.g. the + // wildcard next to the loopback), so each of them has to be free. + for (const host of ["127.0.0.1", "::1", "0.0.0.0", "::"]) { + if (!(await this.canListen(port, host))) { + return false; + } + } - server.on("error", (err: Error) => { - if (!isResolved) { - isResolved = true; - resolve(false); - } - }); + return true; + } - server.once("close", () => { - if (!isResolved) { - // "close" will be emitted right after "error" - isResolved = true; - resolve(true); - } - }); + private canListen(port: number, host: string): Promise { + return new Promise((resolve) => { + const server = net.createServer(); - server.on("listening", (err: Error) => { - if (err && !isResolved) { - isResolved = true; - resolve(true); - } + server.once("error", (err: NodeJS.ErrnoException) => { + // The host has no such address (e.g. IPv6 is disabled), so nothing + // can be listening on it either. + resolve(err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT"); + }); - server.close(); + server.once("listening", () => { + server.close(() => resolve(true)); }); - server.listen(port, "localhost"); + server.listen(port, host); }); } diff --git a/lib/common/test/unit-tests/services/net-service.ts b/lib/common/test/unit-tests/services/net-service.ts index 9d35773158..0714270003 100644 --- a/lib/common/test/unit-tests/services/net-service.ts +++ b/lib/common/test/unit-tests/services/net-service.ts @@ -1,5 +1,6 @@ import { Net } from "../../../services/net-service"; import { assert } from "chai"; +import * as net from "net"; import { Yok } from "../../../yok"; import { ErrorsStub, CommonLoggerStub } from "../stubs"; import { EOL } from "os"; @@ -25,6 +26,39 @@ describe("net", () => { return testInjector; }; + describe("isPortAvailable", () => { + const hosts = [undefined, "127.0.0.1", "::1", "0.0.0.0", "::"]; + + for (const host of hosts) { + it(`returns false for a port taken on ${host || "the default address"}`, async () => { + const service = createTestInjector("darwin").resolve(Net); + const server = net.createServer(); + await new Promise((resolve) => + host + ? server.listen(0, host, () => resolve()) + : server.listen(0, () => resolve()), + ); + const { port } = server.address(); + + try { + assert.isFalse(await service.isPortAvailable(port)); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + } + + it("returns true for a free port", async () => { + const service = createTestInjector("darwin").resolve(Net); + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, () => resolve())); + const { port } = server.address(); + await new Promise((resolve) => server.close(resolve)); + + assert.isTrue(await service.isPortAvailable(port)); + }); + }); + describe("waitForPortToListen", () => { let execCalledCount = 0; beforeEach(() => { diff --git a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts index 10ddd50209..fe0f832a50 100644 --- a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts +++ b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts @@ -8,6 +8,13 @@ import { IDictionary, IErrors, INet } from "../../common/declarations"; import { injector } from "../../common/yok"; import { ITempService } from "../../definitions/temp-service"; +const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; + +interface IDebugProxySession { + appDebugSocket: net.Socket; + webSocket?: ws; +} + export class AppDebugSocketProxyFactory extends EventEmitter implements IAppDebugSocketProxyFactory @@ -18,7 +25,6 @@ export class AppDebugSocketProxyFactory constructor( private $logger: ILogger, private $errors: IErrors, - private $lockService: ILockService, private $options: IOptions, private $tempService: ITempService, private $net: INet, @@ -132,7 +138,6 @@ export class AppDebugSocketProxyFactory projectName: string, projectDir: string, ): Promise { - let clientConnectionLockRelease: () => void; const cacheKey = `${device.deviceInfo.identifier}-${appId}`; const existingServer = this.deviceWebServers[cacheKey]; if (existingServer) { @@ -141,76 +146,83 @@ export class AppDebugSocketProxyFactory ); } - // NOTE: We will try to provide command line options to select ports, at least on the localhost. - const localPort = await this.$net.getAvailablePortInRange(41000); - this.$logger.info( "\nSetting up debugger proxy...\nPress Ctrl + C to terminate, or disconnect.\n", ); // NB: When the inspector frontend connects we might not have connected to the inspector backend yet. // That's why we use the verifyClient callback of the websocket server to stall the upgrade request until we connect. - // We store the socket that connects us to the device in the upgrade request object itself and later on retrieve it - // in the connection callback. - - let currentAppSocket: net.Socket = null; - let currentWebSocket: ws = null; - const server = new ws.Server({ - port: localPort, - verifyClient: async ( + // We store the session in the upgrade request object itself and later on retrieve it in the connection callback. + + // The runtime serves a single frontend, so the newest client takes over. + // Handlers of a replaced session must leave the device socket alone, as + // by then it belongs to the session that replaced it. + let currentSession: IDebugProxySession = null; + let pendingConnect: Promise = Promise.resolve(); + + const connectToApp = async (): Promise => { + const previousSession = currentSession; + currentSession = null; + if (previousSession) { + previousSession.webSocket?.close(1000, REPLACED_CLOSE_REASON); + await device.destroyDebugSocket(appId); + } + + const appDebugSocket = await device.getDebugSocket( + appId, + projectName, + projectDir, + ); + appDebugSocket.on("error", (err: Error) => { + this.$logger.trace("Error on debugger deviceSocket", err); + }); + this.$logger.info("Backend socket created."); + + currentSession = { appDebugSocket }; + return currentSession; + }; + + const server = await this.listenOnAvailablePort(41000, { + verifyClient: ( info: any, callback: (res: boolean, code?: number, message?: string) => void, ) => { - let acceptHandshake = true; - clientConnectionLockRelease = null; - - try { - clientConnectionLockRelease = await this.$lockService.lock( - `debug-connection-${device.deviceInfo.identifier}-${appId}.lock`, - ); - - this.$logger.info("Frontend client connected."); - let appDebugSocket; - if (currentAppSocket) { - currentAppSocket.removeAllListeners(); - currentAppSocket = null; - if (currentWebSocket) { - currentWebSocket.removeAllListeners(); - currentWebSocket.close(); - currentWebSocket = null; - } - await device.destroyDebugSocket(appId); + this.$logger.info("Frontend client connected."); + + // Not released on "connection": that never fires for a client that + // drops during the handshake. + pendingConnect = pendingConnect.then(async () => { + try { + info.req["__session"] = await connectToApp(); + callback(true); + } catch (err) { + err.deviceIdentifier = device.deviceInfo.identifier; + this.$logger.trace(err); + this.emit(CONNECTION_ERROR_EVENT_NAME, err); + this.$logger.warn( + `Cannot connect to device socket. The error message is '${err.message}'.`, + ); + callback(false); } - appDebugSocket = await device.getDebugSocket( - appId, - projectName, - projectDir, - ); - currentAppSocket = appDebugSocket; - this.$logger.info("Backend socket created."); - info.req["__deviceSocket"] = appDebugSocket; - } catch (err) { - if (clientConnectionLockRelease) { - clientConnectionLockRelease(); - } - err.deviceIdentifier = device.deviceInfo.identifier; - this.$logger.trace(err); - this.emit(CONNECTION_ERROR_EVENT_NAME, err); - acceptHandshake = false; - this.$logger.warn( - `Cannot connect to device socket. The error message is '${err.message}'.`, - ); - } - - callback(acceptHandshake); + }); }, }); this.deviceWebServers[cacheKey] = server; server.on("connection", (webSocket, req) => { - currentWebSocket = webSocket; const encoding = "utf16le"; - const appDebugSocket: net.Socket = (req)["__deviceSocket"]; + webSocket.on("error", (err) => { + this.$logger.trace("Error on debugger websocket", err); + }); + + const session: IDebugProxySession = (req)["__session"]; + if (session !== currentSession || session.appDebugSocket.destroyed) { + webSocket.close(1000, REPLACED_CLOSE_REASON); + return; + } + + session.webSocket = webSocket; + const appDebugSocket = session.appDebugSocket; const packets = new MessageUnpackStream(); appDebugSocket.pipe(packets); @@ -230,14 +242,6 @@ export class AppDebugSocketProxyFactory } }); - webSocket.on("error", (err) => { - this.$logger.trace("Error on debugger websocket", err); - }); - - appDebugSocket.on("error", (err) => { - this.$logger.trace("Error on debugger deviceSocket", err); - }); - webSocket.on("message", (message) => { const msg = message.toString(); if (process.env.DEBUG_DEVTOOLS_SOCKETS) { @@ -253,28 +257,65 @@ export class AppDebugSocketProxyFactory }); appDebugSocket.on("close", () => { - currentAppSocket = null; this.$logger.trace("Backend socket closed!"); + if (currentSession === session) { + currentSession = null; + } webSocket.close(); }); webSocket.on("close", async () => { - currentWebSocket = null; this.$logger.trace("Frontend socket closed!"); appDebugSocket.unpipe(packets); packets.destroy(); + if (currentSession !== session) { + return; + } + + currentSession = null; await device.destroyDebugSocket(appId); if (!this.$options.watch) { process.exit(0); } }); - - clientConnectionLockRelease(); }); return server; } + private async listenOnAvailablePort( + startPort: number, + options: ws.ServerOptions, + ): Promise { + // The port can still get taken after the check, by another CLI for example. + let port = await this.$net.getAvailablePortInRange(startPort); + while (true) { + const result = await new Promise( + (resolve) => { + const server = new ws.Server({ ...options, port }); + server.once("listening", () => { + server.removeAllListeners("error"); + server.on("error", (err: Error) => { + this.$logger.trace("Error on debugger web socket server", err); + }); + resolve(server); + }); + server.once("error", resolve); + }, + ); + + if (result instanceof ws.Server) { + return result; + } + + if (result.code !== "EADDRINUSE") { + throw result; + } + + port = await this.$net.getAvailablePortInRange(port + 1); + } + } + public removeAllProxies() { let deviceId; for (deviceId in this.deviceWebServers) { diff --git a/lib/services/ios-debugger-port-service.ts b/lib/services/ios-debugger-port-service.ts index ca1c1c5a40..4146453219 100644 --- a/lib/services/ios-debugger-port-service.ts +++ b/lib/services/ios-debugger-port-service.ts @@ -143,8 +143,8 @@ export class IOSDebuggerPortService implements IIOSDebuggerPortService { private clearTimeout(data: IIOSDebuggerPortData): void { const storedData = this.mapDebuggerPortData[`${data.deviceId}${data.appId}`]; - if (storedData && typeof storedData.timer === "number") { - clearTimeout(storedData.timer); + if (storedData && storedData.timer) { + clearTimeout(storedData.timer); } } } diff --git a/test/device-sockets/app-debug-socket-proxy-factory.ts b/test/device-sockets/app-debug-socket-proxy-factory.ts new file mode 100644 index 0000000000..91e01426b6 --- /dev/null +++ b/test/device-sockets/app-debug-socket-proxy-factory.ts @@ -0,0 +1,219 @@ +import { assert } from "chai"; +import * as net from "net"; +import * as ws from "ws"; +import { MessageUnpackStream } from "ios-device-lib"; +import { AppDebugSocketProxyFactory } from "../../lib/device-sockets/ios/app-debug-socket-proxy-factory"; + +const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; + +const encode = (message: string): Buffer => { + const length = Buffer.byteLength(message, "utf16le"); + const payload = Buffer.allocUnsafe(length + 4); + payload.writeInt32BE(length, 0); + payload.write(message, 4, length, "utf16le"); + return payload; +}; + +describe("AppDebugSocketProxyFactory", () => { + let backend: net.Server; + let backendPort: number; + let backendConnections: number; + let proxy: any; + let proxies: ws.Server[]; + let device: any; + + // Echoes every framed message, as the runtime answers each CDP request. + const startBackend = () => + new Promise((resolve) => { + backend = net.createServer((socket) => { + backendConnections++; + const packets = new MessageUnpackStream(); + socket.pipe(packets); + packets.on("data", (buffer: Buffer) => { + socket.write(encode(buffer.toString("utf16le"))); + }); + socket.on("error", () => {}); + }); + backend.listen(0, "127.0.0.1", () => { + backendPort = (backend.address()).port; + resolve(); + }); + }); + + const createDevice = () => { + const cachedSockets: { [appId: string]: net.Socket } = {}; + return { + deviceInfo: { identifier: "device" }, + getDebugSocket: async (appId: string) => { + if (!cachedSockets[appId]) { + cachedSockets[appId] = net.connect(backendPort, "127.0.0.1"); + } + return cachedSockets[appId]; + }, + destroyDebugSocket: async (appId: string) => { + cachedSockets[appId]?.destroy(); + cachedSockets[appId] = null; + }, + }; + }; + + const createProxy = (getAvailablePortInRange?: () => Promise) => + new AppDebugSocketProxyFactory( + { info: () => {}, trace: () => {}, warn: () => {} }, + { fail: (message: string) => assert.fail(message) }, + { watch: true }, + {}, + { + getAvailablePortInRange: + getAvailablePortInRange || + (async () => { + const server = net.createServer(); + await new Promise((r) => server.listen(0, () => r())); + const { port } = server.address(); + await new Promise((r) => server.close(r)); + return port; + }), + }, + ); + + const startProxy = async (factory: any = proxy) => { + const server: ws.Server = await factory.ensureWebSocketProxy( + device, + "app", + "project", + "/project", + ); + proxies.push(server); + return (server.address()).port; + }; + + const open = (port: number) => + new Promise((resolve, reject) => { + const client = new ws(`ws://127.0.0.1:${port}`); + client.once("open", () => resolve(client)); + client.once("error", reject); + }); + + const roundTrip = (client: ws, message: string) => + new Promise((resolve, reject) => { + client.once("message", (data) => resolve(data.toString())); + client.once("close", () => reject(new Error("closed before the reply"))); + client.send(message); + }); + + const closeReason = (client: ws) => + new Promise((resolve) => { + client.once("close", (_code, reason) => resolve(reason.toString())); + }); + + beforeEach(async () => { + backendConnections = 0; + proxies = []; + await startBackend(); + device = createDevice(); + proxy = createProxy(); + }); + + afterEach(async () => { + await device.destroyDebugSocket("app"); + proxies.forEach((server) => { + server.clients.forEach((client) => client.terminate()); + server.close(); + }); + await new Promise((resolve) => backend.close(resolve)); + }); + + it("moves to the next port when another server takes the checked one", async () => { + const blocker = net.createServer(); + await new Promise((resolve) => blocker.listen(0, () => resolve())); + const blockedPort = (blocker.address()).port; + const ports = [blockedPort]; + const factory = createProxy(async () => { + if (ports.length) { + return ports.shift(); + } + const server = net.createServer(); + await new Promise((r) => server.listen(0, () => r())); + const { port } = server.address(); + await new Promise((r) => server.close(r)); + return port; + }); + + const port = await startProxy(factory); + await new Promise((resolve) => blocker.close(resolve)); + + assert.notEqual(port, blockedPort); + const client = await open(port); + assert.equal(await roundTrip(client, "hello"), "hello"); + }); + + it("serves a client after the previous one disconnected", async () => { + const port = await startProxy(); + for (let i = 0; i < 3; i++) { + const client = await open(port); + assert.equal(await roundTrip(client, `message ${i}`), `message ${i}`); + client.close(); + } + + const client = await open(port); + assert.equal(await roundTrip(client, "last"), "last"); + }); + + it("still serves clients after one drops during the handshake", async () => { + const port = await startProxy(); + await new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.write( + "GET / HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ); + setImmediate(() => { + socket.destroy(); + resolve(); + }); + }); + }); + + const client = await open(port); + assert.equal(await roundTrip(client, "hello"), "hello"); + }); + + it("gives the backend to the newest client and tells the previous one why", async () => { + const port = await startProxy(); + const first = await open(port); + const firstClosed = closeReason(first); + + const second = await open(port); + + assert.equal(await firstClosed, REPLACED_CLOSE_REASON); + assert.equal(await roundTrip(second, "hello"), "hello"); + assert.equal(backendConnections, 2); + }); + + it("keeps the new client's backend when the replaced client closes late", async () => { + const port = await startProxy(); + const first = await open(port); + const opening = open(port); + first.terminate(); + + const second = await opening; + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(second.readyState, ws.OPEN); + assert.equal(await roundTrip(second, "hello"), "hello"); + }); + + it("leaves only the newest of simultaneous clients connected", async () => { + const port = await startProxy(); + const clients = await Promise.all( + [0, 1, 2, 3].map(() => open(port).catch((): ws => null)), + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const connected = clients.filter( + (client) => client && client.readyState === ws.OPEN, + ); + assert.lengthOf(connected, 1); + assert.equal(await roundTrip(connected[0], "hello"), "hello"); + }); +}); From 88cb51d2c18029a70d04d4e7632b8e7f32c1fded Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 18:39:43 +0100 Subject: [PATCH 2/4] 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. --- .../ios/app-debug-socket-proxy-factory.ts | 108 ++++++++++++++---- .../app-debug-socket-proxy-factory.ts | 79 ++++++++++++- 2 files changed, 158 insertions(+), 29 deletions(-) diff --git a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts index fe0f832a50..912b8dada3 100644 --- a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts +++ b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts @@ -10,6 +10,29 @@ import { ITempService } from "../../definitions/temp-service"; const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; +/** + * The debugger can run any code in the app, and any web page can try to open a + * WebSocket to this port. Clients that aren't browsers, like VS Code's debug + * adapter, send no origin. + */ +export function isAllowedDebuggerOrigin(origin?: string): boolean { + if (!origin) { + return true; + } + + try { + const { protocol, hostname, origin: urlOrigin } = new URL(origin); + return ( + protocol === "devtools:" || + protocol === "chrome-devtools:" || + urlOrigin === "https://chrome-devtools-frontend.appspot.com" || + ["localhost", "127.0.0.1", "[::1]"].includes(hostname) + ); + } catch { + return false; + } +} + interface IDebugProxySession { appDebugSocket: net.Socket; webSocket?: ws; @@ -182,11 +205,19 @@ export class AppDebugSocketProxyFactory return currentSession; }; - const server = await this.listenOnAvailablePort(41000, { + const servers = await this.listenOnAvailablePort(41000, { verifyClient: ( info: any, callback: (res: boolean, code?: number, message?: string) => void, ) => { + if (!isAllowedDebuggerOrigin(info.origin)) { + this.$logger.warn( + `Rejected a debugger connection from origin '${info.origin}'.`, + ); + callback(false, 403, "Origin not allowed"); + return; + } + this.$logger.info("Frontend client connected."); // Not released on "connection": that never fires for a client that @@ -207,8 +238,9 @@ export class AppDebugSocketProxyFactory }); }, }); + const server = servers[0]; this.deviceWebServers[cacheKey] = server; - server.on("connection", (webSocket, req) => { + const onConnection = (webSocket: ws, req: any) => { const encoding = "utf16le"; webSocket.on("error", (err) => { @@ -278,6 +310,12 @@ export class AppDebugSocketProxyFactory process.exit(0); } }); + }; + for (const listeningServer of servers) { + listeningServer.on("connection", onConnection); + } + server.once("close", () => { + servers.slice(1).forEach((other) => other.close()); }); return server; @@ -286,36 +324,56 @@ export class AppDebugSocketProxyFactory private async listenOnAvailablePort( startPort: number, options: ws.ServerOptions, - ): Promise { + ): Promise { // The port can still get taken after the check, by another CLI for example. let port = await this.$net.getAvailablePortInRange(startPort); while (true) { - const result = await new Promise( - (resolve) => { - const server = new ws.Server({ ...options, port }); - server.once("listening", () => { - server.removeAllListeners("error"); - server.on("error", (err: Error) => { - this.$logger.trace("Error on debugger web socket server", err); - }); - resolve(server); - }); - server.once("error", resolve); - }, - ); - - if (result instanceof ws.Server) { - return result; - } - - if (result.code !== "EADDRINUSE") { - throw result; + const servers: ws.Server[] = []; + try { + // Only reachable from this machine. Clients told to use "localhost" + // may end up on either loopback address. + servers.push(await this.listen("127.0.0.1", port, options)); + const ipv6Server = await this.listen("::1", port, options).catch( + (err: NodeJS.ErrnoException): ws.Server => { + if (err.code === "EADDRINUSE") { + throw err; + } + this.$logger.trace("Not listening on ::1", err); + return null; + }, + ); + if (ipv6Server) { + servers.push(ipv6Server); + } + return servers; + } catch (err) { + servers.forEach((server) => server.close()); + if (err.code !== "EADDRINUSE") { + throw err; + } + port = await this.$net.getAvailablePortInRange(port + 1); } - - port = await this.$net.getAvailablePortInRange(port + 1); } } + private listen( + host: string, + port: number, + options: ws.ServerOptions, + ): Promise { + return new Promise((resolve, reject) => { + const server = new ws.Server({ ...options, host, port }); + server.once("listening", () => { + server.removeAllListeners("error"); + server.on("error", (err: Error) => { + this.$logger.trace("Error on debugger web socket server", err); + }); + resolve(server); + }); + server.once("error", reject); + }); + } + public removeAllProxies() { let deviceId; for (deviceId in this.deviceWebServers) { diff --git a/test/device-sockets/app-debug-socket-proxy-factory.ts b/test/device-sockets/app-debug-socket-proxy-factory.ts index 91e01426b6..9497f914e0 100644 --- a/test/device-sockets/app-debug-socket-proxy-factory.ts +++ b/test/device-sockets/app-debug-socket-proxy-factory.ts @@ -1,8 +1,12 @@ import { assert } from "chai"; import * as net from "net"; +import * as os from "os"; import * as ws from "ws"; import { MessageUnpackStream } from "ios-device-lib"; -import { AppDebugSocketProxyFactory } from "../../lib/device-sockets/ios/app-debug-socket-proxy-factory"; +import { + AppDebugSocketProxyFactory, + isAllowedDebuggerOrigin, +} from "../../lib/device-sockets/ios/app-debug-socket-proxy-factory"; const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; @@ -87,13 +91,18 @@ describe("AppDebugSocketProxyFactory", () => { return (server.address()).port; }; - const open = (port: number) => + const open = (port: number, options?: ws.ClientOptions, host = "127.0.0.1") => new Promise((resolve, reject) => { - const client = new ws(`ws://127.0.0.1:${port}`); + const client = new ws(`ws://${host}:${port}`, options); client.once("open", () => resolve(client)); client.once("error", reject); }); + const assertRejects = async (promise: Promise) => { + const client = await promise.catch((): ws => null); + assert.isNull(client); + }; + const roundTrip = (client: ws, message: string) => new Promise((resolve, reject) => { client.once("message", (data) => resolve(data.toString())); @@ -125,7 +134,9 @@ describe("AppDebugSocketProxyFactory", () => { it("moves to the next port when another server takes the checked one", async () => { const blocker = net.createServer(); - await new Promise((resolve) => blocker.listen(0, () => resolve())); + await new Promise((resolve) => + blocker.listen(0, "127.0.0.1", () => resolve()), + ); const blockedPort = (blocker.address()).port; const ports = [blockedPort]; const factory = createProxy(async () => { @@ -147,6 +158,34 @@ describe("AppDebugSocketProxyFactory", () => { assert.equal(await roundTrip(client, "hello"), "hello"); }); + it("listens on both loopback addresses and nothing else", async () => { + const port = await startProxy(); + + for (const host of ["127.0.0.1", "[::1]"]) { + const client = await open(port, undefined, host); + assert.equal(await roundTrip(client, host), host); + } + + const addresses = Object.values(os.networkInterfaces()) + .flat() + .filter((address) => !address.internal && address.family === "IPv4"); + for (const { address } of addresses) { + await assertRejects(open(port, undefined, address)); + } + }); + + it("turns away web pages but not DevTools or clients without an origin", async () => { + const port = await startProxy(); + + await assertRejects(open(port, { origin: "https://evil.example" })); + assert.equal(backendConnections, 0); + + const devtools = await open(port, { origin: "devtools://devtools" }); + assert.equal(await roundTrip(devtools, "hello"), "hello"); + const noOrigin = await open(port); + assert.equal(await roundTrip(noOrigin, "hello"), "hello"); + }); + it("serves a client after the previous one disconnected", async () => { const port = await startProxy(); for (let i = 0; i < 3; i++) { @@ -216,4 +255,36 @@ describe("AppDebugSocketProxyFactory", () => { assert.lengthOf(connected, 1); assert.equal(await roundTrip(connected[0], "hello"), "hello"); }); + + describe("isAllowedDebuggerOrigin", () => { + const allowed = [ + undefined, + "", + "devtools://devtools", + "chrome-devtools://devtools", + "https://chrome-devtools-frontend.appspot.com", + "http://localhost:5173", + "http://127.0.0.1:8080", + "http://[::1]:3000", + ]; + for (const origin of allowed) { + it(`allows ${origin || "no origin"}`, () => { + assert.isTrue(isAllowedDebuggerOrigin(origin)); + }); + } + + const rejected = [ + "https://evil.example", + "http://localhost.evil.example", + "http://127.0.0.1.evil.example", + "https://chrome-devtools-frontend.appspot.com.evil.example", + "null", + "file://", + ]; + for (const origin of rejected) { + it(`rejects ${origin}`, () => { + assert.isFalse(isAllowedDebuggerOrigin(origin)); + }); + } + }); }); From 1aa7b02f6ba86f6fb1e65a9ac7957617baeb9f25 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 20:24:33 +0100 Subject: [PATCH 3/4] 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/unit-tests/services/net-service.ts | 16 +++++--- .../ios/app-debug-socket-proxy-factory.ts | 3 -- .../app-debug-socket-proxy-factory.ts | 40 +++++++++++++++++-- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/lib/common/test/unit-tests/services/net-service.ts b/lib/common/test/unit-tests/services/net-service.ts index 0714270003..35e0553982 100644 --- a/lib/common/test/unit-tests/services/net-service.ts +++ b/lib/common/test/unit-tests/services/net-service.ts @@ -30,14 +30,18 @@ describe("net", () => { const hosts = [undefined, "127.0.0.1", "::1", "0.0.0.0", "::"]; for (const host of hosts) { - it(`returns false for a port taken on ${host || "the default address"}`, async () => { + it(`returns false for a port taken on ${host || "the default address"}`, async function () { const service = createTestInjector("darwin").resolve(Net); const server = net.createServer(); - await new Promise((resolve) => - host - ? server.listen(0, host, () => resolve()) - : server.listen(0, () => resolve()), - ); + const listening = await new Promise((resolve) => { + server.once("error", () => resolve(false)); + const onListening = () => resolve(true); + host ? server.listen(0, host, onListening) : server.listen(0, onListening); + }); + if (!listening) { + this.skip(); + return; + } const { port } = server.address(); try { diff --git a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts index 912b8dada3..832d53f9b1 100644 --- a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts +++ b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts @@ -290,9 +290,6 @@ export class AppDebugSocketProxyFactory appDebugSocket.on("close", () => { this.$logger.trace("Backend socket closed!"); - if (currentSession === session) { - currentSession = null; - } webSocket.close(); }); diff --git a/test/device-sockets/app-debug-socket-proxy-factory.ts b/test/device-sockets/app-debug-socket-proxy-factory.ts index 9497f914e0..30d28473d2 100644 --- a/test/device-sockets/app-debug-socket-proxy-factory.ts +++ b/test/device-sockets/app-debug-socket-proxy-factory.ts @@ -1,6 +1,7 @@ import { assert } from "chai"; import * as net from "net"; import * as os from "os"; +import * as sinon from "sinon"; import * as ws from "ws"; import { MessageUnpackStream } from "ios-device-lib"; import { @@ -18,6 +19,11 @@ const encode = (message: string): Buffer => { return payload; }; +const hasIPv6Loopback = () => + Object.values(os.networkInterfaces()) + .flat() + .some((address) => address.internal && address.family === "IPv6"); + describe("AppDebugSocketProxyFactory", () => { let backend: net.Server; let backendPort: number; @@ -61,11 +67,14 @@ describe("AppDebugSocketProxyFactory", () => { }; }; - const createProxy = (getAvailablePortInRange?: () => Promise) => + const createProxy = ( + getAvailablePortInRange?: () => Promise, + watch = true, + ) => new AppDebugSocketProxyFactory( { info: () => {}, trace: () => {}, warn: () => {} }, { fail: (message: string) => assert.fail(message) }, - { watch: true }, + { watch }, {}, { getAvailablePortInRange: @@ -161,7 +170,11 @@ describe("AppDebugSocketProxyFactory", () => { it("listens on both loopback addresses and nothing else", async () => { const port = await startProxy(); - for (const host of ["127.0.0.1", "[::1]"]) { + const hosts = ["127.0.0.1"]; + if (hasIPv6Loopback()) { + hosts.push("[::1]"); + } + for (const host of hosts) { const client = await open(port, undefined, host); assert.equal(await roundTrip(client, host), host); } @@ -229,6 +242,27 @@ describe("AppDebugSocketProxyFactory", () => { assert.equal(backendConnections, 2); }); + it("cleans up and exits without watch when the app goes away", async () => { + const exit = sinon.stub(process, "exit"); + const destroyDebugSocket = sinon.spy(device, "destroyDebugSocket"); + try { + const port = await startProxy(createProxy(undefined, false)); + const client = await open(port); + const closed = closeReason(client); + const appSocket = await device.getDebugSocket("app"); + + appSocket.destroy(); + await closed; + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.isTrue(destroyDebugSocket.called); + assert.isTrue(exit.calledWith(0)); + } finally { + exit.restore(); + destroyDebugSocket.restore(); + } + }); + it("keeps the new client's backend when the replaced client closes late", async () => { const port = await startProxy(); const first = await open(port); From 572af5297aa74b305a973eafd7851173c5702c3a Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 20:50:34 +0100 Subject: [PATCH 4/4] test(net): skip unavailable addresses through vitest's test context --- lib/common/test/unit-tests/services/net-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/common/test/unit-tests/services/net-service.ts b/lib/common/test/unit-tests/services/net-service.ts index 35e0553982..73751f1a00 100644 --- a/lib/common/test/unit-tests/services/net-service.ts +++ b/lib/common/test/unit-tests/services/net-service.ts @@ -30,7 +30,7 @@ describe("net", () => { const hosts = [undefined, "127.0.0.1", "::1", "0.0.0.0", "::"]; for (const host of hosts) { - it(`returns false for a port taken on ${host || "the default address"}`, async function () { + it(`returns false for a port taken on ${host || "the default address"}`, async (context: any) => { const service = createTestInjector("darwin").resolve(Net); const server = net.createServer(); const listening = await new Promise((resolve) => { @@ -39,7 +39,7 @@ describe("net", () => { host ? server.listen(0, host, onListening) : server.listen(0, onListening); }); if (!listening) { - this.skip(); + context.skip(); return; } const { port } = server.address();