Skip to content

Commit a9a34d9

Browse files
committed
fix: keep iOS runner hot across app closes
1 parent f2424f9 commit a9a34d9

5 files changed

Lines changed: 143 additions & 5 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ final class RunnerTests: XCTestCase {
4848
let firstInteractionAfterActivateDelay: TimeInterval = 0.25
4949
let scrollInteractionIdleTimeoutDefault: TimeInterval = 1.0
5050
let tvRemoteDoublePressDelayDefault: TimeInterval = 0.0
51+
let xctestIdleKeepaliveInterval: TimeInterval = 5.0
5152
let minRecordingFps = 1
5253
let maxRecordingFps = 120
5354
let minRecordingQuality = 5
@@ -119,6 +120,18 @@ final class RunnerTests: XCTestCase {
119120
self.handle(connection: conn)
120121
}
121122
listener?.start(queue: transportQueue)
123+
let idleKeepaliveTimer = DispatchSource.makeTimerSource(queue: transportQueue)
124+
idleKeepaliveTimer.schedule(
125+
deadline: .now() + xctestIdleKeepaliveInterval,
126+
repeating: xctestIdleKeepaliveInterval
127+
)
128+
idleKeepaliveTimer.setEventHandler {
129+
NSLog("AGENT_DEVICE_RUNNER_IDLE_KEEPALIVE")
130+
}
131+
idleKeepaliveTimer.resume()
132+
defer {
133+
idleKeepaliveTimer.cancel()
134+
}
122135

123136
guard let expectation = doneExpectation else {
124137
XCTFail("runner expectation was not initialized")

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3013,6 +3013,48 @@ test('close <app> on iOS stops runner before app close dispatch and performs fin
30133013
expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'stop-runner']);
30143014
});
30153015

3016+
test('close <app> on iOS simulator retains runner while terminating app', async () => {
3017+
const sessionStore = makeSessionStore();
3018+
const sessionName = 'ios-simulator-close-session';
3019+
sessionStore.set(sessionName, {
3020+
...makeSession(sessionName, {
3021+
platform: 'ios',
3022+
id: 'sim-1',
3023+
name: 'iPhone 17 Pro',
3024+
kind: 'simulator',
3025+
booted: true,
3026+
}),
3027+
appName: 'com.example.app',
3028+
});
3029+
3030+
const calls: string[] = [];
3031+
mockStopIosRunner.mockImplementation(async () => {
3032+
calls.push('stop-runner');
3033+
});
3034+
mockDispatch.mockImplementation(async (_device, command, positionals) => {
3035+
calls.push(`${command}:${positionals.join(' ')}`);
3036+
return {};
3037+
});
3038+
3039+
const response = await handleSessionCommands({
3040+
req: {
3041+
token: 't',
3042+
session: sessionName,
3043+
command: 'close',
3044+
positionals: ['com.example.app'],
3045+
flags: {},
3046+
},
3047+
sessionName,
3048+
logPath: path.join(os.tmpdir(), 'daemon.log'),
3049+
sessionStore,
3050+
invoke: noopInvoke,
3051+
});
3052+
3053+
expect(response).toBeTruthy();
3054+
expect(response?.ok).toBe(true);
3055+
expect(calls).toEqual(['close:com.example.app']);
3056+
});
3057+
30163058
test('close <app> on macOS stops runner before app close dispatch and dismisses automation alert', async () => {
30173059
const sessionStore = makeSessionStore();
30183060
const sessionName = 'macos-close-session';

src/daemon/handlers/session-close.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,11 @@ async function stopAppleRunnerForClose(session: SessionState): Promise<void> {
9696
}
9797

9898
function shouldRetainAppleRunnerAfterClose(req: DaemonRequest, session: SessionState): boolean {
99-
const hasCloseTarget = (req.positionals?.length ?? 0) > 0;
100-
return (
101-
isIosSimulator(session.device) && !hasCloseTarget && !req.flags?.shutdown && !session.recording
102-
);
99+
return isIosSimulator(session.device) && !req.flags?.shutdown && !session.recording;
100+
}
101+
102+
function shouldStopAppleRunnerBeforeTargetedClose(session: SessionState): boolean {
103+
return isApplePlatform(session.device.platform) && !isIosSimulator(session.device);
103104
}
104105

105106
export async function teardownSessionResources(
@@ -130,7 +131,7 @@ export async function handleCloseCommand(params: {
130131
await stopAppLog(session.appLog);
131132
}
132133
if (req.positionals && req.positionals.length > 0) {
133-
if (isApplePlatform(session.device.platform)) {
134+
if (shouldStopAppleRunnerBeforeTargetedClose(session)) {
134135
await stopAppleRunnerForClose(session);
135136
}
136137
await dispatchCommand(session.device, 'close', req.positionals, req.flags?.out, {

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import fs from 'node:fs';
22
import { afterEach, beforeEach, test, vi } from 'vitest';
33
import assert from 'node:assert/strict';
44
import type { DeviceInfo } from '../../../utils/device.ts';
5+
import type { ExecBackgroundResult } from '../../../utils/exec.ts';
56
import { AppError } from '../../../utils/errors.ts';
7+
import type { RunnerSession } from '../runner-session-types.ts';
68

79
const { mockRunCmd } = vi.hoisted(() => ({
810
mockRunCmd: vi.fn(),
@@ -106,6 +108,37 @@ test('waitForRunner keeps tunnel IP lookup request-local when no tunnel IP is av
106108
assert.equal(vi.mocked(fetch).mock.calls[0]?.[0], 'http://127.0.0.1:8100/command');
107109
});
108110

111+
test('waitForRunner uses simulator fallback within the attempt for ready sessions', async () => {
112+
vi.stubGlobal(
113+
'fetch',
114+
vi.fn(async () => {
115+
throw new Error('ECONNREFUSED');
116+
}),
117+
);
118+
mockRunCmd.mockResolvedValue({ exitCode: 0, stdout: '{"ok":true}', stderr: '' });
119+
120+
const response = await waitForRunner(
121+
iosSimulator,
122+
8100,
123+
{ command: 'uptime' },
124+
undefined,
125+
5_000,
126+
makeReadyRunnerSession(),
127+
);
128+
129+
assert.equal(await response.text(), '{"ok":true}');
130+
assert.equal(vi.mocked(fetch).mock.calls.length, 1);
131+
assert.equal(mockRunCmd.mock.calls.length, 1);
132+
assert.equal(mockRunCmd.mock.calls[0]?.[0], 'xcrun');
133+
assert.deepEqual(mockRunCmd.mock.calls[0]?.[1]?.slice(0, 5), [
134+
'simctl',
135+
'spawn',
136+
'sim-1',
137+
'/usr/bin/curl',
138+
'-s',
139+
]);
140+
});
141+
109142
test('waitForRunner invalidates cached tunnel IP when localhost fallback succeeds', async () => {
110143
const tunnelIps = ['fd00::123', 'fd00::456'];
111144
mockRunCmd.mockImplementation(async (_cmd: string, args: string[]) => {
@@ -170,3 +203,17 @@ function stubSuccessfulFetch(): void {
170203
vi.fn(async () => new Response('{}')),
171204
);
172205
}
206+
207+
function makeReadyRunnerSession(): RunnerSession {
208+
return {
209+
sessionId: 'ready-session',
210+
device: iosSimulator,
211+
deviceId: iosSimulator.id,
212+
port: 8100,
213+
xctestrunPath: '/tmp/runner.xctestrun',
214+
jsonPath: '/tmp/runner.json',
215+
testPromise: Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }),
216+
child: { pid: 1234, exitCode: null } as ExecBackgroundResult['child'],
217+
ready: true,
218+
};
219+
}

src/platforms/ios/runner-transport.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ export async function waitForRunner(
8181
},
8282
});
8383
if (response) return response;
84+
if (device.kind === 'simulator' && session?.ready) {
85+
const simulatorResponse = await tryRunnerSimulatorEndpoint(device, port, command, {
86+
signal,
87+
attemptDeadline,
88+
onError: (err) => {
89+
lastError = err;
90+
},
91+
});
92+
if (simulatorResponse) return simulatorResponse;
93+
}
8494
if (device.kind === 'device' && usedCachedTunnelIp) {
8595
invalidateDeviceTunnelIpCache(device.id);
8696
const refreshed = await getEndpoints(attemptDeadline?.remainingMs(), true);
@@ -238,6 +248,31 @@ async function tryRunnerEndpoints(
238248
return null;
239249
}
240250

251+
async function tryRunnerSimulatorEndpoint(
252+
device: DeviceInfo,
253+
port: number,
254+
command: RunnerCommand,
255+
params: {
256+
signal?: AbortSignal;
257+
attemptDeadline?: Deadline;
258+
onError: (error: unknown) => void;
259+
},
260+
): Promise<Response | null> {
261+
const { signal, attemptDeadline, onError } = params;
262+
const remainingMs = attemptDeadline?.remainingMs() ?? RUNNER_COMMAND_TIMEOUT_MS;
263+
if (remainingMs <= 0) return null;
264+
try {
265+
const simResponse = await postCommandViaSimulator(device, port, command, remainingMs, signal);
266+
return new Response(simResponse.body, { status: simResponse.status });
267+
} catch (err) {
268+
if (signal?.aborted || isRequestCanceledError(err)) {
269+
throw createRequestCanceledError();
270+
}
271+
onError(err);
272+
return null;
273+
}
274+
}
275+
241276
async function getDeviceTunnelIpForRequest(params: {
242277
device: DeviceInfo;
243278
timeoutBudgetMs?: number;

0 commit comments

Comments
 (0)