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"); + }); +});