Skip to content

Commit db339c9

Browse files
authored
fix: harden startup/teardown behavior on iOS (#32)
1 parent 4aed3fb commit db339c9

1 file changed

Lines changed: 115 additions & 55 deletions

File tree

src/platforms/ios/runner-client.ts

Lines changed: 115 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import os from 'node:os';
33
import path from 'node:path';
44
import { fileURLToPath } from 'node:url';
55
import { AppError } from '../../utils/errors.ts';
6-
import { runCmd, runCmdStreaming, type ExecResult } from '../../utils/exec.ts';
6+
import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts';
77
import { withRetry } from '../../utils/retry.ts';
88
import type { DeviceInfo } from '../../utils/device.ts';
99
import net from 'node:net';
@@ -46,9 +46,30 @@ export type RunnerSession = {
4646
xctestrunPath: string;
4747
jsonPath: string;
4848
testPromise: Promise<ExecResult>;
49+
child: ExecBackgroundResult['child'];
50+
ready: boolean;
4951
};
5052

5153
const runnerSessions = new Map<string, RunnerSession>();
54+
const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs(
55+
process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS,
56+
120_000,
57+
5_000,
58+
);
59+
const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
60+
process.env.AGENT_DEVICE_RUNNER_COMMAND_TIMEOUT_MS,
61+
15_000,
62+
1_000,
63+
);
64+
const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
65+
const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
66+
67+
function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
68+
if (!raw) return fallback;
69+
const parsed = Number(raw);
70+
if (!Number.isFinite(parsed)) return fallback;
71+
return Math.max(min, Math.floor(parsed));
72+
}
5273

5374
export type RunnerSnapshotNode = {
5475
index: number;
@@ -87,29 +108,14 @@ async function executeRunnerCommand(
87108

88109
try {
89110
const session = await ensureRunnerSession(device, options);
90-
const response = await waitForRunner(device, session.port, command, options.logPath);
91-
const text = await response.text();
92-
93-
let json: any = {};
94-
try {
95-
json = JSON.parse(text);
96-
} catch {
97-
throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text });
98-
}
99-
100-
if (!json.ok) {
101-
throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
102-
runner: json,
103-
xcodebuild: {
104-
exitCode: 1,
105-
stdout: '',
106-
stderr: '',
107-
},
108-
logPath: options.logPath,
109-
});
110-
}
111-
112-
return json.data ?? {};
111+
const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS;
112+
return await executeRunnerCommandWithSession(
113+
device,
114+
session,
115+
command,
116+
options.logPath,
117+
timeoutMs,
118+
);
113119
} catch (err) {
114120
const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
115121
if (
@@ -119,46 +125,79 @@ async function executeRunnerCommand(
119125
) {
120126
await stopIosRunnerSession(device.id);
121127
const session = await ensureRunnerSession(device, options);
122-
const response = await waitForRunner(device, session.port, command, options.logPath);
123-
const text = await response.text();
124-
let json: any = {};
125-
try {
126-
json = JSON.parse(text);
127-
} catch {
128-
throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text });
129-
}
130-
if (!json.ok) {
131-
throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
132-
runner: json,
133-
xcodebuild: {
134-
exitCode: 1,
135-
stdout: '',
136-
stderr: '',
137-
},
138-
logPath: options.logPath,
139-
});
140-
}
141-
return json.data ?? {};
128+
const response = await waitForRunner(
129+
session.device,
130+
session.port,
131+
command,
132+
options.logPath,
133+
RUNNER_STARTUP_TIMEOUT_MS,
134+
);
135+
return await parseRunnerResponse(response, session, options.logPath);
142136
}
143137
throw err;
144138
}
145139
}
146140

141+
async function executeRunnerCommandWithSession(
142+
device: DeviceInfo,
143+
session: RunnerSession,
144+
command: RunnerCommand,
145+
logPath: string | undefined,
146+
timeoutMs: number,
147+
): Promise<Record<string, unknown>> {
148+
const response = await waitForRunner(device, session.port, command, logPath, timeoutMs);
149+
return await parseRunnerResponse(response, session, logPath);
150+
}
151+
152+
async function parseRunnerResponse(
153+
response: Response,
154+
session: RunnerSession,
155+
logPath?: string,
156+
): Promise<Record<string, unknown>> {
157+
const text = await response.text();
158+
let json: any = {};
159+
try {
160+
json = JSON.parse(text);
161+
} catch {
162+
throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text });
163+
}
164+
if (!json.ok) {
165+
throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
166+
runner: json,
167+
xcodebuild: {
168+
exitCode: 1,
169+
stdout: '',
170+
stderr: '',
171+
},
172+
logPath,
173+
});
174+
}
175+
session.ready = true;
176+
return json.data ?? {};
177+
}
178+
147179
export async function stopIosRunnerSession(deviceId: string): Promise<void> {
148180
const session = runnerSessions.get(deviceId);
149181
if (!session) return;
150182
try {
151183
await waitForRunner(session.device, session.port, {
152184
command: 'shutdown',
153-
} as RunnerCommand);
185+
} as RunnerCommand, undefined, RUNNER_SHUTDOWN_TIMEOUT_MS);
154186
} catch {
155-
// ignore
187+
// Runner not responsive — send SIGTERM so we don't hang on testPromise
188+
await killRunnerProcessTree(session.child.pid, 'SIGTERM');
156189
}
157190
try {
158-
await session.testPromise;
191+
// Bound the wait so we never hang if xcodebuild refuses to exit
192+
await Promise.race([
193+
session.testPromise,
194+
new Promise<void>((resolve) => setTimeout(resolve, RUNNER_STOP_WAIT_TIMEOUT_MS)),
195+
]);
159196
} catch {
160197
// ignore
161198
}
199+
// Force-kill if still alive (harmless if already exited)
200+
await killRunnerProcessTree(session.child.pid, 'SIGKILL');
162201
cleanupTempFile(session.xctestrunPath);
163202
cleanupTempFile(session.jsonPath);
164203
runnerSessions.delete(deviceId);
@@ -183,7 +222,7 @@ async function ensureRunnerSession(
183222
{ AGENT_DEVICE_RUNNER_PORT: String(port) },
184223
`session-${device.id}-${port}`,
185224
);
186-
const testPromise = runCmdStreaming(
225+
const { child, wait: testPromise } = runCmdBackground(
187226
'xcodebuild',
188227
[
189228
'test-without-building',
@@ -201,16 +240,16 @@ async function ensureRunnerSession(
201240
`platform=iOS Simulator,id=${device.id}`,
202241
],
203242
{
204-
onStdoutChunk: (chunk) => {
205-
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
206-
},
207-
onStderrChunk: (chunk) => {
208-
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
209-
},
210243
allowFailure: true,
211244
env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
212245
},
213246
);
247+
child.stdout?.on('data', (chunk: string) => {
248+
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
249+
});
250+
child.stderr?.on('data', (chunk: string) => {
251+
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
252+
});
214253

215254
const session: RunnerSession = {
216255
device,
@@ -219,11 +258,31 @@ async function ensureRunnerSession(
219258
xctestrunPath,
220259
jsonPath,
221260
testPromise,
261+
child,
262+
ready: false,
222263
};
223264
runnerSessions.set(device.id, session);
224265
return session;
225266
}
226267

268+
async function killRunnerProcessTree(
269+
pid: number | undefined,
270+
signal: 'SIGTERM' | 'SIGKILL',
271+
): Promise<void> {
272+
if (!pid || pid <= 0) return;
273+
try {
274+
process.kill(pid, signal);
275+
} catch {
276+
// ignore
277+
}
278+
const pkillSignal = signal === 'SIGTERM' ? 'TERM' : 'KILL';
279+
try {
280+
await runCmd('pkill', [`-${pkillSignal}`, '-P', String(pid)], { allowFailure: true });
281+
} catch {
282+
// ignore
283+
}
284+
}
285+
227286

228287
async function ensureXctestrun(
229288
udid: string,
@@ -364,10 +423,11 @@ async function waitForRunner(
364423
port: number,
365424
command: RunnerCommand,
366425
logPath?: string,
426+
timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
367427
): Promise<Response> {
368428
const start = Date.now();
369429
let lastError: unknown = null;
370-
while (Date.now() - start < 15000) {
430+
while (Date.now() - start < timeoutMs) {
371431
try {
372432
const response = await fetch(`http://127.0.0.1:${port}/command`, {
373433
method: 'POST',

0 commit comments

Comments
 (0)