From 43652938bc5a2f86e4fe91fb5a022dd180f8d870 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 23:02:40 +0100 Subject: [PATCH] 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"); + }); +});