Skip to content

Commit 4365293

Browse files
committed
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.
1 parent aa94547 commit 4365293

2 files changed

Lines changed: 148 additions & 3 deletions

File tree

‎lib/common/mobile/ios/device/ios-application-manager.ts‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { hook } from "../../../helpers";
44
import { ApplicationManagerBase } from "../../application-manager-base";
55
import { cache } from "../../../decorators";
66
import { IOptions } from "../../../../declarations";
7+
import { ChildProcess } from "child_process";
78
import {
9+
IChildProcess,
810
IHooksService,
911
IErrors,
1012
IiOSNotificationService,
@@ -13,6 +15,7 @@ import {
1315

1416
export class IOSApplicationManager extends ApplicationManagerBase {
1517
private applicationsLiveSyncInfos: Mobile.IApplicationInfo[];
18+
private lldbProcesses: IDictionary<ChildProcess> = {};
1619

1720
constructor(
1821
protected $logger: ILogger,
@@ -22,7 +25,8 @@ export class IOSApplicationManager extends ApplicationManagerBase {
2225
private $iOSNotificationService: IiOSNotificationService,
2326
private $iosDeviceOperations: IIOSDeviceOperations,
2427
private $options: IOptions,
25-
protected $deviceLogProvider: Mobile.IDeviceLogProvider
28+
protected $deviceLogProvider: Mobile.IDeviceLogProvider,
29+
private $childProcess: IChildProcess
2630
) {
2731
super($logger, $hooksService, $deviceLogProvider);
2832
}
@@ -118,6 +122,7 @@ export class IOSApplicationManager extends ApplicationManagerBase {
118122
const { appId } = appData;
119123

120124
await this.device.destroyDebugSocket(appId);
125+
this.detachNativeDebugger(appId);
121126

122127
const action = () =>
123128
this.$iosDeviceOperations.stop([
@@ -173,15 +178,61 @@ export class IOSApplicationManager extends ApplicationManagerBase {
173178
private async runApplicationCore(
174179
appData: Mobile.IStartApplicationData
175180
): Promise<void> {
181+
const deviceId = this.device.deviceInfo.identifier;
176182
const waitForDebugger = (!!appData.waitForDebugger).toString();
177-
await this.$iosDeviceOperations.start([
183+
const result = await this.$iosDeviceOperations.start([
178184
{
179-
deviceId: this.device.deviceInfo.identifier,
185+
deviceId,
180186
appId: appData.appId,
181187
ddi: this.$options.ddi,
182188
waitForDebugger,
183189
},
184190
]);
191+
192+
const pid: number = (<any>result?.[deviceId]?.[0])?.pid;
193+
if (appData.waitForDebugger && pid > 0) {
194+
this.attachNativeDebugger(appData.appId, pid);
195+
}
196+
}
197+
198+
// iOS kills an app that takes about 20 seconds to launch, which is what waiting
199+
// for or pausing in the JS debugger looks like, unless a native debugger is
200+
// attached. lldb only knows about devices since Xcode 16; without it the wait
201+
// is just limited to those 20 seconds.
202+
private attachNativeDebugger(appId: string, pid: number): void {
203+
this.detachNativeDebugger(appId);
204+
205+
const lldb = this.$childProcess.spawn("xcrun", ["lldb"]);
206+
this.lldbProcesses[appId] = lldb;
207+
lldb.on("error", (err: Error) => {
208+
this.$logger.trace("Unable to attach lldb to the application", err);
209+
});
210+
// Attaching stops the app. It can only be continued once that has happened,
211+
// and until then it can't answer the JS debugger's attach request.
212+
let output = "";
213+
lldb.stdout?.on("data", (data: Buffer) => {
214+
this.$logger.trace(`${data}`);
215+
if (output !== null) {
216+
output += data;
217+
if (/Process \d+ stopped/.test(output)) {
218+
output = null;
219+
lldb.stdin?.write("process continue\n");
220+
}
221+
}
222+
});
223+
lldb.stderr?.on("data", (data: Buffer) => this.$logger.trace(`${data}`));
224+
lldb.stdin?.write(
225+
`device select ${this.device.deviceInfo.identifier}\ndevice process attach -p ${pid}\n`
226+
);
227+
}
228+
229+
private detachNativeDebugger(appId: string): void {
230+
const lldb = this.lldbProcesses[appId];
231+
if (lldb) {
232+
delete this.lldbProcesses[appId];
233+
lldb.stdin?.write("process detach\nquit\n");
234+
lldb.stdin?.end();
235+
}
185236
}
186237

187238
@cache()
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { assert } from "chai";
2+
import { EventEmitter } from "events";
3+
import { IOSApplicationManager } from "../../../mobile/ios/device/ios-application-manager";
4+
5+
describe("IOSApplicationManager native debugger", () => {
6+
const deviceId = "device";
7+
const appData = { appId: "org.app", projectName: "app", projectDir: "/app" };
8+
9+
const setup = (pid?: number) => {
10+
const lldbInput: string[] = [];
11+
const lldbOutput = new EventEmitter();
12+
const spawned: string[][] = [];
13+
const manager = new (<any>IOSApplicationManager)(
14+
{ trace: () => {}, info: () => {} },
15+
{},
16+
{
17+
deviceInfo: { identifier: deviceId },
18+
destroyDebugSocket: async () => {},
19+
},
20+
{},
21+
{},
22+
{
23+
start: async () => ({ [deviceId]: [{ deviceId, pid }] }),
24+
stop: async () => ({}),
25+
apps: async () => ({}),
26+
},
27+
{ justlaunch: true },
28+
new Proxy({}, { get: () => () => {} }),
29+
{
30+
spawn: (command: string, args: string[]) => {
31+
spawned.push([command, ...args]);
32+
return {
33+
on: () => {},
34+
stdout: lldbOutput,
35+
stdin: {
36+
write: (text: string) => lldbInput.push(text),
37+
end: () => {},
38+
},
39+
};
40+
},
41+
},
42+
);
43+
return { manager, lldbInput, lldbOutput, spawned };
44+
};
45+
46+
it("attaches lldb to an app that waits for the JS debugger", async () => {
47+
const { manager, lldbInput, spawned } = setup(42);
48+
49+
await manager.restartApplication({ ...appData, waitForDebugger: true });
50+
51+
assert.deepEqual(spawned, [["xcrun", "lldb"]]);
52+
assert.include(lldbInput[0], `device select ${deviceId}`);
53+
assert.include(lldbInput[0], "device process attach -p 42");
54+
});
55+
56+
it("continues the app once, after the attach stopped it", async () => {
57+
const { manager, lldbInput, lldbOutput } = setup(42);
58+
59+
await manager.restartApplication({ ...appData, waitForDebugger: true });
60+
assert.lengthOf(lldbInput, 1);
61+
62+
lldbOutput.emit("data", Buffer.from("Process 42 sto"));
63+
assert.lengthOf(lldbInput, 1);
64+
lldbOutput.emit("data", Buffer.from("pped\n"));
65+
lldbOutput.emit("data", Buffer.from("Process 42 stopped\n"));
66+
67+
assert.deepEqual(lldbInput.slice(1), ["process continue\n"]);
68+
});
69+
70+
it("doesn't attach to an app that doesn't wait", async () => {
71+
const { manager, spawned } = setup(42);
72+
73+
await manager.restartApplication(appData);
74+
75+
assert.deepEqual(spawned, []);
76+
});
77+
78+
it("doesn't attach without a process id", async () => {
79+
const { manager, spawned } = setup(-1);
80+
81+
await manager.restartApplication({ ...appData, waitForDebugger: true });
82+
83+
assert.deepEqual(spawned, []);
84+
});
85+
86+
it("detaches when the app stops", async () => {
87+
const { manager, lldbInput } = setup(42);
88+
89+
await manager.restartApplication({ ...appData, waitForDebugger: true });
90+
await manager.stopApplication(appData);
91+
92+
assert.include(lldbInput[lldbInput.length - 1], "process detach");
93+
});
94+
});

0 commit comments

Comments
 (0)