Skip to content

Commit 3462c29

Browse files
committed
Harden iOS runner timeouts and cleanup safety
1 parent 284633e commit 3462c29

3 files changed

Lines changed: 146 additions & 32 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ Environment selectors:
273273
- `AGENT_DEVICE_IOS_SIGNING_IDENTITY=<identity>` optional signing identity override.
274274
- `AGENT_DEVICE_IOS_PROVISIONING_PROFILE=<profile>` optional provisioning profile specifier for iOS device runner signing.
275275
- `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH=<path>` optional override for iOS runner derived data root. By default, agent-device separates caches by target kind (`.../derived/simulator` and `.../derived/device`). If you set this override, use separate paths per kind to avoid simulator/device artifact collisions.
276+
- `AGENT_DEVICE_IOS_CLEAN_DERIVED=1` rebuild iOS runner artifacts from scratch. When `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is set, cleanup is blocked by default; set `AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1` only for trusted custom paths.
276277

277278
Test screenshots are written to:
278279
- `test/screenshots/android-settings.png`

src/platforms/ios/__tests__/runner-client.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import test from 'node:test';
22
import assert from 'node:assert/strict';
33
import type { DeviceInfo } from '../../../utils/device.ts';
44
import {
5+
assertSafeDerivedCleanup,
56
resolveRunnerBuildDestination,
67
resolveRunnerDestination,
78
resolveRunnerMaxConcurrentDestinationsFlag,
@@ -84,3 +85,29 @@ test('resolveRunnerSigningBuildSettings applies optional overrides when provided
8485
'PROVISIONING_PROFILE_SPECIFIER=My Profile',
8586
]);
8687
});
88+
89+
test('assertSafeDerivedCleanup allows cleaning when no override is set', () => {
90+
assert.doesNotThrow(() => {
91+
assertSafeDerivedCleanup('/tmp/derived', {});
92+
});
93+
});
94+
95+
test('assertSafeDerivedCleanup rejects cleaning override path by default', () => {
96+
assert.throws(
97+
() => {
98+
assertSafeDerivedCleanup('/tmp/custom', {
99+
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: '/tmp/custom',
100+
});
101+
},
102+
/Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically/,
103+
);
104+
});
105+
106+
test('assertSafeDerivedCleanup allows cleaning override path with explicit opt-in', () => {
107+
assert.doesNotThrow(() => {
108+
assertSafeDerivedCleanup('/tmp/custom', {
109+
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: '/tmp/custom',
110+
AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN: '1',
111+
});
112+
});
113+
});

src/platforms/ios/runner-client.ts

Lines changed: 118 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from 'node:path';
44
import { fileURLToPath } from 'node:url';
55
import { AppError } from '../../utils/errors.ts';
66
import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts';
7-
import { Deadline, retryWithPolicy, withRetry } from '../../utils/retry.ts';
7+
import { Deadline, isEnvTruthy, retryWithPolicy, withRetry } from '../../utils/retry.ts';
88
import type { DeviceInfo } from '../../utils/device.ts';
99
import net from 'node:net';
1010
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
@@ -65,8 +65,34 @@ const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
6565
15_000,
6666
1_000,
6767
);
68+
const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = resolveTimeoutMs(
69+
process.env.AGENT_DEVICE_RUNNER_CONNECT_ATTEMPT_INTERVAL_MS,
70+
250,
71+
50,
72+
);
73+
const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = resolveTimeoutMs(
74+
process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
75+
100,
76+
10,
77+
);
78+
const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = resolveTimeoutMs(
79+
process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
80+
500,
81+
10,
82+
);
83+
const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
84+
process.env.AGENT_DEVICE_RUNNER_CONNECT_REQUEST_TIMEOUT_MS,
85+
1_000,
86+
50,
87+
);
88+
const RUNNER_DEVICE_INFO_TIMEOUT_MS = resolveTimeoutMs(
89+
process.env.AGENT_DEVICE_IOS_DEVICE_INFO_TIMEOUT_MS,
90+
10_000,
91+
500,
92+
);
6893
const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
6994
const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
95+
const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner');
7096

7197
function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
7298
if (!raw) return fallback;
@@ -294,6 +320,7 @@ async function ensureXctestrun(
294320
): Promise<string> {
295321
const derived = resolveRunnerDerivedPath(device.kind);
296322
if (shouldCleanDerived()) {
323+
assertSafeDerivedCleanup(derived);
297324
try {
298325
fs.rmSync(derived, { recursive: true, force: true });
299326
} catch {
@@ -364,8 +391,7 @@ function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
364391
if (override) {
365392
return path.resolve(override);
366393
}
367-
const base = path.join(os.homedir(), '.agent-device', 'ios-runner');
368-
return path.join(base, 'derived', kind);
394+
return path.join(RUNNER_DERIVED_ROOT, 'derived', kind);
369395
}
370396

371397
export function resolveRunnerDestination(device: DeviceInfo): string {
@@ -509,9 +535,54 @@ function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
509535
}
510536

511537
function shouldCleanDerived(): boolean {
512-
const value = process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED;
513-
if (!value) return false;
514-
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
538+
return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED);
539+
}
540+
541+
export function assertSafeDerivedCleanup(
542+
derivedPath: string,
543+
env: NodeJS.ProcessEnv = process.env,
544+
): void {
545+
const override = env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
546+
if (!override) {
547+
return;
548+
}
549+
if (isCleanupOverrideAllowed(env)) {
550+
return;
551+
}
552+
throw new AppError(
553+
'COMMAND_FAILED',
554+
'Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically',
555+
{
556+
derivedPath,
557+
hint: 'Unset AGENT_DEVICE_IOS_CLEAN_DERIVED, or set AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1 if you trust this path.',
558+
},
559+
);
560+
}
561+
562+
function isCleanupOverrideAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
563+
return isEnvTruthy(env.AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN);
564+
}
565+
566+
function buildRunnerConnectError(params: {
567+
port: number;
568+
endpoints: string[];
569+
logPath?: string;
570+
lastError: unknown;
571+
}): AppError {
572+
const { port, endpoints, logPath, lastError } = params;
573+
const message = 'Runner did not accept connection';
574+
return new AppError('COMMAND_FAILED', message, {
575+
port,
576+
endpoints,
577+
logPath,
578+
lastError: lastError ? String(lastError) : undefined,
579+
reason: classifyBootFailure({
580+
error: lastError,
581+
message,
582+
context: { platform: 'ios', phase: 'connect' },
583+
}),
584+
hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
585+
});
515586
}
516587

517588
async function waitForRunner(
@@ -521,26 +592,39 @@ async function waitForRunner(
521592
logPath?: string,
522593
timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
523594
): Promise<Response> {
524-
let endpoints = await resolveRunnerCommandEndpoints(device, port);
525-
let lastError: unknown = null;
526595
const deadline = Deadline.fromTimeoutMs(timeoutMs);
527-
const maxAttempts = Math.max(1, Math.ceil(timeoutMs / 250));
596+
let endpoints = await resolveRunnerCommandEndpoints(device, port, deadline.remainingMs());
597+
let lastError: unknown = null;
598+
const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS));
528599
try {
529600
return await retryWithPolicy(
530-
async () => {
601+
async ({ deadline: attemptDeadline }) => {
602+
if (attemptDeadline?.isExpired()) {
603+
throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
604+
port,
605+
timeoutMs,
606+
});
607+
}
531608
if (device.kind === 'device') {
532-
endpoints = await resolveRunnerCommandEndpoints(device, port);
609+
endpoints = await resolveRunnerCommandEndpoints(device, port, attemptDeadline?.remainingMs());
533610
}
534611
for (const endpoint of endpoints) {
535612
try {
613+
const remainingMs = attemptDeadline?.remainingMs() ?? timeoutMs;
614+
if (remainingMs <= 0) {
615+
throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
616+
port,
617+
timeoutMs,
618+
});
619+
}
536620
const response = await fetchWithTimeout(
537621
endpoint,
538622
{
539623
method: 'POST',
540624
headers: { 'Content-Type': 'application/json' },
541625
body: JSON.stringify(command),
542626
},
543-
1_000,
627+
Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs),
544628
);
545629
return response;
546630
} catch (err) {
@@ -555,8 +639,8 @@ async function waitForRunner(
555639
},
556640
{
557641
maxAttempts,
558-
baseDelayMs: 100,
559-
maxDelayMs: 500,
642+
baseDelayMs: RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
643+
maxDelayMs: RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
560644
jitter: 0.2,
561645
shouldRetry: () => true,
562646
},
@@ -569,33 +653,27 @@ async function waitForRunner(
569653
}
570654

571655
if (device.kind === 'simulator') {
572-
const simResponse = await postCommandViaSimulator(device.id, port, command);
656+
const remainingMs = deadline.remainingMs();
657+
if (remainingMs <= 0) {
658+
throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
659+
}
660+
const simResponse = await postCommandViaSimulator(device.id, port, command, remainingMs);
573661
return new Response(simResponse.body, { status: simResponse.status });
574662
}
575663

576-
throw new AppError('COMMAND_FAILED', 'Runner did not accept connection', {
577-
port,
578-
endpoints,
579-
logPath,
580-
lastError: lastError ? String(lastError) : undefined,
581-
reason: classifyBootFailure({
582-
error: lastError,
583-
message: 'Runner did not accept connection',
584-
context: { platform: 'ios', phase: 'connect' },
585-
}),
586-
hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
587-
});
664+
throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
588665
}
589666

590667
async function resolveRunnerCommandEndpoints(
591668
device: DeviceInfo,
592669
port: number,
670+
timeoutBudgetMs?: number,
593671
): Promise<string[]> {
594672
const endpoints = [`http://127.0.0.1:${port}/command`];
595673
if (device.kind !== 'device') {
596674
return endpoints;
597675
}
598-
const tunnelIp = await resolveDeviceTunnelIp(device.id);
676+
const tunnelIp = await resolveDeviceTunnelIp(device.id, timeoutBudgetMs);
599677
if (tunnelIp) {
600678
endpoints.unshift(`http://[${tunnelIp}]:${port}/command`);
601679
}
@@ -616,12 +694,19 @@ async function fetchWithTimeout(
616694
}
617695
}
618696

619-
async function resolveDeviceTunnelIp(deviceId: string): Promise<string | null> {
697+
async function resolveDeviceTunnelIp(deviceId: string, timeoutBudgetMs?: number): Promise<string | null> {
698+
if (typeof timeoutBudgetMs === 'number' && timeoutBudgetMs <= 0) {
699+
return null;
700+
}
701+
const timeoutMs = typeof timeoutBudgetMs === 'number'
702+
? Math.max(1, Math.min(RUNNER_DEVICE_INFO_TIMEOUT_MS, timeoutBudgetMs))
703+
: RUNNER_DEVICE_INFO_TIMEOUT_MS;
620704
const jsonPath = path.join(
621705
os.tmpdir(),
622706
`agent-device-devicectl-info-${process.pid}-${Date.now()}.json`,
623707
);
624708
try {
709+
const devicectlTimeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
625710
const result = await runCmd(
626711
'xcrun',
627712
[
@@ -634,9 +719,9 @@ async function resolveDeviceTunnelIp(deviceId: string): Promise<string | null> {
634719
'--json-output',
635720
jsonPath,
636721
'--timeout',
637-
'10',
722+
String(devicectlTimeoutSeconds),
638723
],
639-
{ allowFailure: true },
724+
{ allowFailure: true, timeoutMs },
640725
);
641726
if (result.exitCode !== 0 || !fs.existsSync(jsonPath)) {
642727
return null;
@@ -667,6 +752,7 @@ async function postCommandViaSimulator(
667752
udid: string,
668753
port: number,
669754
command: RunnerCommand,
755+
timeoutMs: number,
670756
): Promise<{ status: number; body: string }> {
671757
const payload = JSON.stringify(command);
672758
const result = await runCmd(
@@ -685,7 +771,7 @@ async function postCommandViaSimulator(
685771
payload,
686772
`http://127.0.0.1:${port}/command`,
687773
],
688-
{ allowFailure: true },
774+
{ allowFailure: true, timeoutMs },
689775
);
690776
const body = result.stdout as string;
691777
if (result.exitCode !== 0) {

0 commit comments

Comments
 (0)