Skip to content

Commit c6a55b3

Browse files
committed
fix: resolve simulator app PID via idempotent simctl launch
The --console-pty flag causes PTY buffering that prevents the PID banner from flushing to the log file reliably (0/5 in testing). Replace file-based PID polling with an idempotent simctl launch call via CommandExecutor. Calling simctl launch on an already-running app returns the existing PID without relaunching (verified 10/10). Algorithm: 1. Spawn --console-pty with fd-to-file (captures print/NSLog) 2. Wait for app startup 3. Plain simctl launch returns existing app PID 4. Start oslog stream separately for Logger messages
1 parent a1c1cd2 commit c6a55b3

6 files changed

Lines changed: 92 additions & 69 deletions

File tree

src/mcp/tools/simulator/__tests__/build_run_sim.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { LaunchWithLoggingResult } from '../../../../utils/simulator-steps.
1515
const mockLauncher: SimulatorLauncher = async (
1616
_uuid,
1717
_bundleId,
18+
_executor,
1819
_opts?,
1920
): Promise<LaunchWithLoggingResult> => ({
2021
success: true,

src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { LaunchWithLoggingResult } from '../../../../utils/simulator-steps.
77
import { runLogic } from '../../../../test-utils/test-helpers.ts';
88

99
function createMockLauncher(overrides?: Partial<LaunchWithLoggingResult>): SimulatorLauncher {
10-
return async (_uuid, _bundleId, _opts?) => ({
10+
return async (_uuid, _bundleId, _executor, _opts?) => ({
1111
success: true,
1212
processId: 12345,
1313
logFilePath: '/tmp/mock-logs/test.log',
@@ -112,7 +112,7 @@ describe('launch_app_sim tool', () => {
112112
it('should pass args and env through to launcher', async () => {
113113
let capturedArgs: string[] | undefined;
114114
let capturedEnv: Record<string, string> | undefined;
115-
const trackingLauncher: SimulatorLauncher = async (_uuid, _bundleId, opts?) => {
115+
const trackingLauncher: SimulatorLauncher = async (_uuid, _bundleId, _executor, opts?) => {
116116
capturedArgs = opts?.args;
117117
capturedEnv = opts?.env;
118118
return { success: true, processId: 12345, logFilePath: '/tmp/test.log' };
@@ -262,7 +262,7 @@ describe('launch_app_sim tool', () => {
262262

263263
it('should not pass env when env is undefined', async () => {
264264
let capturedEnv: Record<string, string> | undefined;
265-
const trackingLauncher: SimulatorLauncher = async (_uuid, _bundleId, opts?) => {
265+
const trackingLauncher: SimulatorLauncher = async (_uuid, _bundleId, _executor, opts?) => {
266266
capturedEnv = opts?.env;
267267
return { success: true, processId: 12345, logFilePath: '/tmp/test.log' };
268268
};

src/mcp/tools/simulator/build_run_sim.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,11 @@ export async function build_run_simLogic(
452452
data: { step: 'launch-app', status: 'started', appPath: appBundlePath },
453453
});
454454

455-
const launchResult: LaunchWithLoggingResult = await launcher(simulatorId, bundleId);
455+
const launchResult: LaunchWithLoggingResult = await launcher(
456+
simulatorId,
457+
bundleId,
458+
executor,
459+
);
456460
if (!launchResult.success) {
457461
const errorMessage = launchResult.error ?? 'Failed to launch app';
458462
log('error', `Failed to launch app: ${errorMessage}`);

src/mcp/tools/simulator/launch_app_sim.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,15 @@ export async function launch_app_simLogic(
103103
return withErrorHandling(
104104
ctx,
105105
async () => {
106-
const launchResult: LaunchWithLoggingResult = await launcher(simulatorId, params.bundleId, {
107-
args: params.args,
108-
env: params.env,
109-
});
106+
const launchResult: LaunchWithLoggingResult = await launcher(
107+
simulatorId,
108+
params.bundleId,
109+
executor,
110+
{
111+
args: params.args,
112+
env: params.env,
113+
},
114+
);
110115

111116
if (!launchResult.success) {
112117
ctx.emit(headerEvent);
Lines changed: 43 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { describe, it, expect, vi, afterEach } from 'vitest';
2-
import * as fs from 'node:fs';
32
import type { ChildProcess, SpawnOptions } from 'node:child_process';
43
import { EventEmitter } from 'node:events';
54
import { launchSimulatorAppWithLogging } from '../simulator-steps.ts';
5+
import type { CommandExecutor } from '../CommandExecutor.ts';
66

77
function createMockChild(exitCode: number | null = null): ChildProcess {
88
const emitter = new EventEmitter();
@@ -13,38 +13,34 @@ function createMockChild(exitCode: number | null = null): ChildProcess {
1313
return child;
1414
}
1515

16-
function createFileWritingSpawner(content: string, delayMs: number = 0) {
17-
return (command: string, args: string[], options: SpawnOptions): ChildProcess => {
18-
const child = createMockChild(null);
19-
const stdio = options.stdio as [unknown, number, number];
20-
const fd = stdio[1];
21-
if (typeof fd === 'number') {
22-
if (delayMs > 0) {
23-
setTimeout(() => {
24-
try {
25-
fs.writeSync(fd, content);
26-
} catch {
27-
// fd may already be closed by the caller
28-
}
29-
}, delayMs);
30-
} else {
31-
fs.writeSync(fd, content);
32-
}
33-
}
34-
return child;
16+
function createMockSpawner() {
17+
return (_command: string, _args: string[], _options: SpawnOptions): ChildProcess => {
18+
return createMockChild(null);
3519
};
3620
}
3721

38-
describe('launchSimulatorAppWithLogging PID parsing', () => {
22+
function createMockExecutor(pid?: number): CommandExecutor {
23+
return async () => ({
24+
success: true,
25+
output: pid !== undefined ? `com.example.app: ${pid}` : '',
26+
process: { pid: 1 } as never,
27+
exitCode: 0,
28+
});
29+
}
30+
31+
describe('launchSimulatorAppWithLogging PID resolution', () => {
3932
afterEach(() => {
4033
vi.restoreAllMocks();
4134
});
4235

43-
it('extracts PID from standard simctl colon format (bundleId: PID)', async () => {
44-
const spawner = createFileWritingSpawner('com.example.app: 42567\n');
36+
it('resolves PID via idempotent simctl launch', async () => {
37+
const spawner = createMockSpawner();
38+
const executor = createMockExecutor(42567);
39+
4540
const result = await launchSimulatorAppWithLogging(
4641
'test-sim-uuid',
4742
'com.example.app',
43+
executor,
4844
undefined,
4945
{ spawner },
5046
);
@@ -53,48 +49,58 @@ describe('launchSimulatorAppWithLogging PID parsing', () => {
5349
expect(result.processId).toBe(42567);
5450
});
5551

56-
it('extracts PID from first line even when app output has bracketed numbers', async () => {
57-
const spawner = createFileWritingSpawner(
58-
'com.example.app: 42567\n[404] Not Found\nHTTP [200] OK\n',
59-
);
52+
it('returns undefined processId when executor returns no PID', async () => {
53+
const spawner = createMockSpawner();
54+
const executor = createMockExecutor();
55+
6056
const result = await launchSimulatorAppWithLogging(
6157
'test-sim-uuid',
6258
'com.example.app',
59+
executor,
6360
undefined,
6461
{ spawner },
6562
);
6663

6764
expect(result.success).toBe(true);
68-
expect(result.processId).toBe(42567);
65+
expect(result.processId).toBeUndefined();
6966
});
7067

71-
it('ignores non-PID first lines and returns undefined', async () => {
72-
const spawner = createFileWritingSpawner('Loading resources...\n[404] Not Found\n');
68+
it('returns undefined processId when executor fails', async () => {
69+
const spawner = createMockSpawner();
70+
const executor: CommandExecutor = async () => ({
71+
success: false,
72+
output: 'Unable to launch',
73+
error: 'App not installed',
74+
process: { pid: 1 } as never,
75+
exitCode: 1,
76+
});
77+
7378
const result = await launchSimulatorAppWithLogging(
7479
'test-sim-uuid',
7580
'com.example.app',
81+
executor,
7682
undefined,
7783
{ spawner },
7884
);
7985

8086
expect(result.success).toBe(true);
81-
// First line has no colon PID pattern, bracketed numbers are not matched
8287
expect(result.processId).toBeUndefined();
8388
});
8489

85-
it('returns undefined when no PID is found within timeout', async () => {
86-
// Write content with no PID pattern at all
87-
const spawner = createFileWritingSpawner('Starting application...\nLoading resources...\n');
90+
it('reports failure when spawn exits immediately with error', async () => {
91+
const spawner = (_command: string, _args: string[], _options: SpawnOptions): ChildProcess => {
92+
return createMockChild(1);
93+
};
94+
const executor = createMockExecutor(42567);
8895

89-
// Use a short timeout to not slow down tests
9096
const result = await launchSimulatorAppWithLogging(
9197
'test-sim-uuid',
9298
'com.example.app',
99+
executor,
93100
undefined,
94101
{ spawner },
95102
);
96103

97-
expect(result.success).toBe(true);
98-
expect(result.processId).toBeUndefined();
104+
expect(result.success).toBe(false);
99105
});
100106
});

src/utils/simulator-steps.ts

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,6 @@ export async function launchSimulatorApp(
112112
return { success: true, processId };
113113
}
114114

115-
const PID_POLL_TIMEOUT_MS = 5000;
116-
const PID_POLL_INTERVAL_MS = 100;
117-
118115
export type ProcessSpawner = (
119116
command: string,
120117
args: string[],
@@ -131,13 +128,20 @@ export interface LaunchWithLoggingResult {
131128

132129
/**
133130
* Launch an app on a simulator with implicit runtime logging.
134-
* Uses `simctl launch --console-pty` to both launch the app and stream its
135-
* stdout/stderr directly to a log file via OS-level fd inheritance.
136-
* The process is fully detached — no Node.js streams or lifecycle management.
131+
*
132+
* Uses a two-phase approach:
133+
* 1. `simctl launch --console-pty` captures the app's stdout/stderr (print/NSLog)
134+
* to a log file via detached fd inheritance. PTY buffering prevents reading
135+
* the PID banner from this file reliably.
136+
* 2. A follow-up idempotent `simctl launch` (without --terminate) returns the
137+
* already-running app's PID without relaunching it.
138+
*
139+
* OSLog (Logger) messages are captured separately via `simctl spawn log stream`.
137140
*/
138141
export async function launchSimulatorAppWithLogging(
139142
simulatorUuid: string,
140143
bundleId: string,
144+
executor: CommandExecutor,
141145
options?: {
142146
args?: string[];
143147
env?: Record<string, string>;
@@ -183,8 +187,8 @@ export async function launchSimulatorAppWithLogging(
183187
fs.closeSync(fd);
184188
fd = undefined;
185189

186-
// Brief wait then check for immediate crash
187-
await new Promise((resolve) => setTimeout(resolve, 300));
190+
// Wait for app startup then check for immediate crash
191+
await new Promise((resolve) => setTimeout(resolve, 500));
188192
if (child.exitCode !== null && child.exitCode !== 0) {
189193
const logContent = readLogFileSafe(logFilePath);
190194
return {
@@ -194,7 +198,8 @@ export async function launchSimulatorAppWithLogging(
194198
};
195199
}
196200

197-
const processId = await resolveAppPid(logFilePath);
201+
// Resolve PID via idempotent simctl launch (returns existing app's PID)
202+
const processId = await resolveAppPidViaLaunch(simulatorUuid, bundleId, executor);
198203

199204
// Start OSLog stream as a separate detached process writing to its own file
200205
const osLogPath = startOsLogStream(simulatorUuid, bundleId, logsDir, spawner);
@@ -215,22 +220,24 @@ export async function launchSimulatorAppWithLogging(
215220
}
216221
}
217222

218-
async function resolveAppPid(logFilePath: string): Promise<number | undefined> {
219-
const start = Date.now();
220-
while (Date.now() - start < PID_POLL_TIMEOUT_MS) {
221-
const content = readLogFileSafe(logFilePath);
222-
if (content) {
223-
const firstLine = content.split('\n').find((l) => l.trim().length > 0);
224-
if (firstLine) {
225-
const colonMatch = firstLine.match(/:\s*(\d+)\s*$/);
226-
if (colonMatch) {
227-
return parseInt(colonMatch[1], 10);
228-
}
229-
}
230-
}
231-
await new Promise((resolve) => setTimeout(resolve, PID_POLL_INTERVAL_MS));
223+
async function resolveAppPidViaLaunch(
224+
simulatorUuid: string,
225+
bundleId: string,
226+
executor: CommandExecutor,
227+
): Promise<number | undefined> {
228+
// simctl launch is idempotent: calling it on an already-running app
229+
// returns the existing PID without relaunching.
230+
const result = await executor(
231+
['xcrun', 'simctl', 'launch', simulatorUuid, bundleId],
232+
'Resolve App PID',
233+
false,
234+
);
235+
if (!result.success) {
236+
log('warn', `Failed to resolve app PID: ${result.error ?? result.output}`);
237+
return undefined;
232238
}
233-
return undefined;
239+
const pidMatch = result.output?.match(/:\s*(\d+)\s*$/);
240+
return pidMatch ? parseInt(pidMatch[1], 10) : undefined;
234241
}
235242

236243
function readLogFileSafe(filePath: string): string {

0 commit comments

Comments
 (0)