From 719a870d349ff255d3888f3cfa5f10189b88d26b Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 17:23:45 +0100 Subject: [PATCH 1/3] fix(ios): launch the app once when restarting it on a device restartApplication launched the app and then connected to its debug socket with ensureAppStarted, which launched it again with --terminate-existing and killed the first process. The socket now connects to the running instance. A launch waiting for a debugger keeps the second start, since that instance cannot answer the attach request. --- .../livesync/ios-device-livesync-service.ts | 13 ++-- .../livesync/ios-device-livesync-service.ts | 62 +++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 test/services/livesync/ios-device-livesync-service.ts diff --git a/lib/services/livesync/ios-device-livesync-service.ts b/lib/services/livesync/ios-device-livesync-service.ts index 78c3150ff9..8182663889 100644 --- a/lib/services/livesync/ios-device-livesync-service.ts +++ b/lib/services/livesync/ios-device-livesync-service.ts @@ -58,7 +58,8 @@ export class IOSDeviceLiveSyncService } private async setupSocketIfNeeded( - projectData: IProjectData + projectData: IProjectData, + ensureAppStarted: boolean = true ): Promise { // TODO: persist the sockets per app in order to support LiveSync on multiple apps on the same device if (this.socket) { @@ -67,8 +68,6 @@ export class IOSDeviceLiveSyncService const appId = projectData.projectIdentifiers.ios; try { - // TODO: temp workaround till we setup the sockets along with the app start - const ensureAppStarted = true; this.socket = await this.device.getDebugSocket( appId, projectData.projectName, @@ -217,7 +216,13 @@ export class IOSDeviceLiveSyncService if (liveSyncInfo.useHotModuleReload) { // enable HOT updates - await this.setupSocketIfNeeded(projectData); + // Starting the app again here would terminate the instance launched + // above. Only a launch that waits for a debugger still needs it, as + // that instance cannot answer the attach request. + await this.setupSocketIfNeeded( + projectData, + !!liveSyncInfo.waitForDebugger + ); } } diff --git a/test/services/livesync/ios-device-livesync-service.ts b/test/services/livesync/ios-device-livesync-service.ts new file mode 100644 index 0000000000..5ac2b57985 --- /dev/null +++ b/test/services/livesync/ios-device-livesync-service.ts @@ -0,0 +1,62 @@ +import * as assert from "assert"; +import { IOSDeviceLiveSyncService } from "../../../lib/services/livesync/ios-device-livesync-service"; + +describe("IOSDeviceLiveSyncService", () => { + const projectData: any = { + projectName: "app", + projectDir: "/app", + projectIdentifiers: { ios: "org.app" }, + }; + + const restart = async (waitForDebugger: boolean) => { + const calls: string[] = []; + const ensureAppStartedValues: boolean[] = []; + const device: any = { + applicationManager: { + restartApplication: async () => { + calls.push("restart"); + }, + }, + getDebugSocket: async ( + _appId: string, + _projectName: string, + _projectDir: string, + ensureAppStarted: boolean + ) => { + calls.push("socket"); + ensureAppStartedValues.push(ensureAppStarted); + return { on: () => {}, removeAllListeners: () => {} }; + }, + }; + const service = new (IOSDeviceLiveSyncService)( + { trace: () => {} }, + {}, + {}, + {}, + {}, + {}, + device + ); + + await service.restartApplication(projectData, { + deviceAppData: { appIdentifier: "org.app" }, + useHotModuleReload: true, + waitForDebugger, + }); + + return { calls, ensureAppStartedValues }; + }; + + it("connects to the app it restarted without starting it again", async () => { + const { calls, ensureAppStartedValues } = await restart(false); + + assert.deepStrictEqual(calls, ["restart", "socket"]); + assert.deepStrictEqual(ensureAppStartedValues, [false]); + }); + + it("starts the app again when the restarted instance waits for a debugger", async () => { + const { ensureAppStartedValues } = await restart(true); + + assert.deepStrictEqual(ensureAppStartedValues, [true]); + }); +}); From aa9454721b745371744681331ac3828c7038dd80 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 22:27:34 +0100 Subject: [PATCH 2/3] fix(ios): keep an app that waits for a debugger running The socket setup started the app a second time for debug-brk, replacing the instance waiting for the debugger with one that ran straight on. --- lib/services/livesync/ios-device-livesync-service.ts | 8 ++------ test/services/livesync/ios-device-livesync-service.ts | 7 ++++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/services/livesync/ios-device-livesync-service.ts b/lib/services/livesync/ios-device-livesync-service.ts index 8182663889..8668c0fca0 100644 --- a/lib/services/livesync/ios-device-livesync-service.ts +++ b/lib/services/livesync/ios-device-livesync-service.ts @@ -217,12 +217,8 @@ export class IOSDeviceLiveSyncService if (liveSyncInfo.useHotModuleReload) { // enable HOT updates // Starting the app again here would terminate the instance launched - // above. Only a launch that waits for a debugger still needs it, as - // that instance cannot answer the attach request. - await this.setupSocketIfNeeded( - projectData, - !!liveSyncInfo.waitForDebugger - ); + // above. + await this.setupSocketIfNeeded(projectData, false); } } diff --git a/test/services/livesync/ios-device-livesync-service.ts b/test/services/livesync/ios-device-livesync-service.ts index 5ac2b57985..97e03af8f3 100644 --- a/test/services/livesync/ios-device-livesync-service.ts +++ b/test/services/livesync/ios-device-livesync-service.ts @@ -54,9 +54,10 @@ describe("IOSDeviceLiveSyncService", () => { assert.deepStrictEqual(ensureAppStartedValues, [false]); }); - it("starts the app again when the restarted instance waits for a debugger", async () => { - const { ensureAppStartedValues } = await restart(true); + it("keeps an instance that waits for a debugger", async () => { + const { calls, ensureAppStartedValues } = await restart(true); - assert.deepStrictEqual(ensureAppStartedValues, [true]); + assert.deepStrictEqual(calls, ["restart", "socket"]); + assert.deepStrictEqual(ensureAppStartedValues, [false]); }); }); From 43652938bc5a2f86e4fe91fb5a022dd180f8d870 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 23:02:40 +0100 Subject: [PATCH 3/3] fix(ios): keep debug-brk apps alive on devices with a native debugger iOS kills an app that takes about 20 seconds to launch, which is what waiting for or pausing in the JS debugger before UIApplicationMain looks like. The simulator already attaches lldb to prevent that; devices now do too, using the pid ios-device-lib reports. --- .../ios/device/ios-application-manager.ts | 57 ++++++++++- .../mobile/ios-application-manager.ts | 94 +++++++++++++++++++ 2 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 lib/common/test/unit-tests/mobile/ios-application-manager.ts diff --git a/lib/common/mobile/ios/device/ios-application-manager.ts b/lib/common/mobile/ios/device/ios-application-manager.ts index b1199c1d8c..789f64575e 100644 --- a/lib/common/mobile/ios/device/ios-application-manager.ts +++ b/lib/common/mobile/ios/device/ios-application-manager.ts @@ -4,7 +4,9 @@ import { hook } from "../../../helpers"; import { ApplicationManagerBase } from "../../application-manager-base"; import { cache } from "../../../decorators"; import { IOptions } from "../../../../declarations"; +import { ChildProcess } from "child_process"; import { + IChildProcess, IHooksService, IErrors, IiOSNotificationService, @@ -13,6 +15,7 @@ import { export class IOSApplicationManager extends ApplicationManagerBase { private applicationsLiveSyncInfos: Mobile.IApplicationInfo[]; + private lldbProcesses: IDictionary = {}; constructor( protected $logger: ILogger, @@ -22,7 +25,8 @@ export class IOSApplicationManager extends ApplicationManagerBase { private $iOSNotificationService: IiOSNotificationService, private $iosDeviceOperations: IIOSDeviceOperations, private $options: IOptions, - protected $deviceLogProvider: Mobile.IDeviceLogProvider + protected $deviceLogProvider: Mobile.IDeviceLogProvider, + private $childProcess: IChildProcess ) { super($logger, $hooksService, $deviceLogProvider); } @@ -118,6 +122,7 @@ export class IOSApplicationManager extends ApplicationManagerBase { const { appId } = appData; await this.device.destroyDebugSocket(appId); + this.detachNativeDebugger(appId); const action = () => this.$iosDeviceOperations.stop([ @@ -173,15 +178,61 @@ export class IOSApplicationManager extends ApplicationManagerBase { private async runApplicationCore( appData: Mobile.IStartApplicationData ): Promise { + const deviceId = this.device.deviceInfo.identifier; const waitForDebugger = (!!appData.waitForDebugger).toString(); - await this.$iosDeviceOperations.start([ + const result = await this.$iosDeviceOperations.start([ { - deviceId: this.device.deviceInfo.identifier, + deviceId, appId: appData.appId, ddi: this.$options.ddi, waitForDebugger, }, ]); + + const pid: number = (result?.[deviceId]?.[0])?.pid; + if (appData.waitForDebugger && pid > 0) { + this.attachNativeDebugger(appData.appId, pid); + } + } + + // iOS kills an app that takes about 20 seconds to launch, which is what waiting + // for or pausing in the JS debugger looks like, unless a native debugger is + // attached. lldb only knows about devices since Xcode 16; without it the wait + // is just limited to those 20 seconds. + private attachNativeDebugger(appId: string, pid: number): void { + this.detachNativeDebugger(appId); + + const lldb = this.$childProcess.spawn("xcrun", ["lldb"]); + this.lldbProcesses[appId] = lldb; + lldb.on("error", (err: Error) => { + this.$logger.trace("Unable to attach lldb to the application", err); + }); + // Attaching stops the app. It can only be continued once that has happened, + // and until then it can't answer the JS debugger's attach request. + let output = ""; + lldb.stdout?.on("data", (data: Buffer) => { + this.$logger.trace(`${data}`); + if (output !== null) { + output += data; + if (/Process \d+ stopped/.test(output)) { + output = null; + lldb.stdin?.write("process continue\n"); + } + } + }); + lldb.stderr?.on("data", (data: Buffer) => this.$logger.trace(`${data}`)); + lldb.stdin?.write( + `device select ${this.device.deviceInfo.identifier}\ndevice process attach -p ${pid}\n` + ); + } + + private detachNativeDebugger(appId: string): void { + const lldb = this.lldbProcesses[appId]; + if (lldb) { + delete this.lldbProcesses[appId]; + lldb.stdin?.write("process detach\nquit\n"); + lldb.stdin?.end(); + } } @cache() diff --git a/lib/common/test/unit-tests/mobile/ios-application-manager.ts b/lib/common/test/unit-tests/mobile/ios-application-manager.ts new file mode 100644 index 0000000000..bc6d95155a --- /dev/null +++ b/lib/common/test/unit-tests/mobile/ios-application-manager.ts @@ -0,0 +1,94 @@ +import { assert } from "chai"; +import { EventEmitter } from "events"; +import { IOSApplicationManager } from "../../../mobile/ios/device/ios-application-manager"; + +describe("IOSApplicationManager native debugger", () => { + const deviceId = "device"; + const appData = { appId: "org.app", projectName: "app", projectDir: "/app" }; + + const setup = (pid?: number) => { + const lldbInput: string[] = []; + const lldbOutput = new EventEmitter(); + const spawned: string[][] = []; + const manager = new (IOSApplicationManager)( + { trace: () => {}, info: () => {} }, + {}, + { + deviceInfo: { identifier: deviceId }, + destroyDebugSocket: async () => {}, + }, + {}, + {}, + { + start: async () => ({ [deviceId]: [{ deviceId, pid }] }), + stop: async () => ({}), + apps: async () => ({}), + }, + { justlaunch: true }, + new Proxy({}, { get: () => () => {} }), + { + spawn: (command: string, args: string[]) => { + spawned.push([command, ...args]); + return { + on: () => {}, + stdout: lldbOutput, + stdin: { + write: (text: string) => lldbInput.push(text), + end: () => {}, + }, + }; + }, + }, + ); + return { manager, lldbInput, lldbOutput, spawned }; + }; + + it("attaches lldb to an app that waits for the JS debugger", async () => { + const { manager, lldbInput, spawned } = setup(42); + + await manager.restartApplication({ ...appData, waitForDebugger: true }); + + assert.deepEqual(spawned, [["xcrun", "lldb"]]); + assert.include(lldbInput[0], `device select ${deviceId}`); + assert.include(lldbInput[0], "device process attach -p 42"); + }); + + it("continues the app once, after the attach stopped it", async () => { + const { manager, lldbInput, lldbOutput } = setup(42); + + await manager.restartApplication({ ...appData, waitForDebugger: true }); + assert.lengthOf(lldbInput, 1); + + lldbOutput.emit("data", Buffer.from("Process 42 sto")); + assert.lengthOf(lldbInput, 1); + lldbOutput.emit("data", Buffer.from("pped\n")); + lldbOutput.emit("data", Buffer.from("Process 42 stopped\n")); + + assert.deepEqual(lldbInput.slice(1), ["process continue\n"]); + }); + + it("doesn't attach to an app that doesn't wait", async () => { + const { manager, spawned } = setup(42); + + await manager.restartApplication(appData); + + assert.deepEqual(spawned, []); + }); + + it("doesn't attach without a process id", async () => { + const { manager, spawned } = setup(-1); + + await manager.restartApplication({ ...appData, waitForDebugger: true }); + + assert.deepEqual(spawned, []); + }); + + it("detaches when the app stops", async () => { + const { manager, lldbInput } = setup(42); + + await manager.restartApplication({ ...appData, waitForDebugger: true }); + await manager.stopApplication(appData); + + assert.include(lldbInput[lldbInput.length - 1], "process detach"); + }); +});