Skip to content

Commit d67e988

Browse files
fix: ios press idle timeout (#543)
* fix: ios press idle timeout * fix: preserve ios mutating command recovery --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
1 parent 7e14dec commit d67e988

6 files changed

Lines changed: 232 additions & 38 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,53 @@ extension RunnerTests {
109109
operation: () -> Void
110110
) {
111111
let setter = NSSelectorFromString("setWaitForIdleTimeout:")
112-
guard target.responds(to: setter) else {
113-
operation()
114-
return
112+
let supportsWaitForIdleTimeout = target.responds(to: setter)
113+
let previous = supportsWaitForIdleTimeout
114+
? (target.value(forKey: "waitForIdleTimeout") as? NSNumber)
115+
: nil
116+
if supportsWaitForIdleTimeout {
117+
target.setValue(resolveScrollInteractionIdleTimeout(), forKey: "waitForIdleTimeout")
115118
}
116-
let previous = target.value(forKey: "waitForIdleTimeout") as? NSNumber
117-
target.setValue(resolveScrollInteractionIdleTimeout(), forKey: "waitForIdleTimeout")
118119
defer {
119120
if let previous {
120121
target.setValue(previous.doubleValue, forKey: "waitForIdleTimeout")
121122
}
122123
}
123-
operation()
124+
performWithQuiescenceSkippedIfSupported(target, operation: operation)
125+
}
126+
127+
// Some apps never report post-gesture quiescence, even after XCTest has synthesized the event.
128+
private func performWithQuiescenceSkippedIfSupported(
129+
_ target: XCUIApplication,
130+
operation: () -> Void
131+
) {
132+
let selector = NSSelectorFromString("_performWithInteractionOptions:block:")
133+
guard target.responds(to: selector) else {
134+
operation()
135+
return
136+
}
137+
typealias PerformWithInteractionOptions = @convention(c) (
138+
NSObject,
139+
Selector,
140+
UInt,
141+
@convention(block) () -> Void
142+
) -> Void
143+
let implementation = target.method(for: selector)
144+
let performWithOptions = unsafeBitCast(
145+
implementation,
146+
to: PerformWithInteractionOptions.self
147+
)
148+
let skipPreEventQuiescence = UInt(1)
149+
let skipPostEventQuiescence = UInt(2)
150+
withoutActuallyEscaping(operation) { escapableOperation in
151+
let block: @convention(block) () -> Void = escapableOperation
152+
performWithOptions(
153+
target,
154+
selector,
155+
skipPreEventQuiescence | skipPostEventQuiescence,
156+
block
157+
)
158+
}
124159
}
125160

126161
private func resolveScrollInteractionIdleTimeout() -> TimeInterval {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { beforeEach, test, vi } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
4+
import { AppError } from '../../../utils/errors.ts';
5+
import type { RunnerSession } from '../runner-session-types.ts';
6+
7+
const { mockEnsureRunnerSession, mockExecuteRunnerCommandWithSession, mockStopRunnerSession } =
8+
vi.hoisted(() => ({
9+
mockEnsureRunnerSession: vi.fn(),
10+
mockExecuteRunnerCommandWithSession: vi.fn(),
11+
mockStopRunnerSession: vi.fn(),
12+
}));
13+
14+
vi.mock('../runner-session.ts', async () => {
15+
const actual =
16+
await vi.importActual<typeof import('../runner-session.ts')>('../runner-session.ts');
17+
return {
18+
...actual,
19+
ensureRunnerSession: mockEnsureRunnerSession,
20+
executeRunnerCommandWithSession: mockExecuteRunnerCommandWithSession,
21+
stopRunnerSession: mockStopRunnerSession,
22+
};
23+
});
24+
25+
import { runIosRunnerCommand } from '../runner-client.ts';
26+
27+
beforeEach(() => {
28+
vi.resetAllMocks();
29+
});
30+
31+
test('mutating commands restart stale ready sessions when the preflight probe never reaches the runner', async () => {
32+
const staleSession = makeRunnerSession({ port: 8100, ready: true });
33+
const freshSession = makeRunnerSession({ port: 8101, ready: false });
34+
35+
mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession);
36+
mockExecuteRunnerCommandWithSession
37+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
38+
.mockResolvedValueOnce({ message: 'tapped' });
39+
40+
const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 });
41+
42+
assert.deepEqual(result, { message: 'tapped' });
43+
assert.equal(mockEnsureRunnerSession.mock.calls.length, 2);
44+
assert.equal(mockStopRunnerSession.mock.calls.length, 1);
45+
assert.equal(mockStopRunnerSession.mock.calls[0]?.[0], staleSession);
46+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
47+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2].command, 'tap');
48+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], freshSession);
49+
});
50+
51+
test('mutating commands do not restart or replay after command send failure', async () => {
52+
const session = makeRunnerSession({ port: 8100, ready: true });
53+
54+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
55+
mockExecuteRunnerCommandWithSession.mockRejectedValueOnce(
56+
new Error('request timed out after reaching runner'),
57+
);
58+
59+
await assert.rejects(() =>
60+
runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
61+
);
62+
63+
assert.equal(mockEnsureRunnerSession.mock.calls.length, 1);
64+
assert.equal(mockStopRunnerSession.mock.calls.length, 0);
65+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 1);
66+
});
67+
68+
function makeRunnerSession(overrides: Partial<RunnerSession> = {}): RunnerSession {
69+
return {
70+
sessionId: `session-${overrides.port ?? 8100}`,
71+
device: IOS_SIMULATOR,
72+
deviceId: IOS_SIMULATOR.id,
73+
port: 8100,
74+
xctestrunPath: '/tmp/runner.xctestrun',
75+
jsonPath: '/tmp/runner.json',
76+
testPromise: Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }),
77+
child: { pid: 1234, exitCode: null },
78+
ready: true,
79+
...overrides,
80+
} as RunnerSession;
81+
}

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ vi.mock('../../../utils/exec.ts', async () => {
1717
};
1818
});
1919

20-
import { clearDeviceTunnelIpCache, waitForRunner } from '../runner-transport.ts';
20+
import {
21+
clearDeviceTunnelIpCache,
22+
sendRunnerCommandOnce,
23+
waitForRunner,
24+
} from '../runner-transport.ts';
2125

2226
const iosSimulator: DeviceInfo = {
2327
platform: 'ios',
@@ -144,6 +148,22 @@ test('waitForRunner invalidates cached tunnel IP when localhost fallback succeed
144148
]);
145149
});
146150

151+
test('sendRunnerCommandOnce does not retry or simulator fallback after request failure', async () => {
152+
vi.stubGlobal(
153+
'fetch',
154+
vi.fn(async () => {
155+
throw new Error('request timed out after reaching runner');
156+
}),
157+
);
158+
159+
await assert.rejects(() =>
160+
sendRunnerCommandOnce(iosSimulator, 8100, { command: 'tap', x: 120, y: 240 }, 5_000),
161+
);
162+
163+
assert.equal(vi.mocked(fetch).mock.calls.length, 1);
164+
assert.equal(mockRunCmd.mock.calls.length, 0);
165+
});
166+
147167
function stubSuccessfulFetch(): void {
148168
vi.stubGlobal(
149169
'fetch',

src/platforms/ios/runner-client.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,14 @@ import { AppError } from '../../utils/errors.ts';
22
import { withRetry } from '../../utils/retry.ts';
33
import type { DeviceInfo } from '../../utils/device.ts';
44
import { getRequestSignal } from '../../daemon/request-cancel.ts';
5-
import {
6-
waitForRunner,
7-
RUNNER_COMMAND_TIMEOUT_MS,
8-
RUNNER_STARTUP_TIMEOUT_MS,
9-
} from './runner-transport.ts';
5+
import { RUNNER_COMMAND_TIMEOUT_MS, RUNNER_STARTUP_TIMEOUT_MS } from './runner-transport.ts';
106
import {
117
type RunnerSession,
128
ensureRunnerSession,
139
stopRunnerSession,
1410
stopIosRunnerSession,
1511
validateRunnerDevice,
1612
executeRunnerCommandWithSession,
17-
parseRunnerResponse,
1813
} from './runner-session.ts';
1914
import {
2015
assertRunnerRequestActive,
@@ -95,16 +90,14 @@ async function executeRunnerCommand(
9590
await stopIosRunnerSession(device.id);
9691
}
9792
session = await ensureRunnerSession(device, options);
98-
const response = await waitForRunner(
99-
session.device,
100-
session.port,
93+
return await executeRunnerCommandWithSession(
94+
device,
95+
session,
10196
command,
10297
options.logPath,
10398
RUNNER_STARTUP_TIMEOUT_MS,
104-
undefined,
10599
signal,
106100
);
107-
return await parseRunnerResponse(response, session, options.logPath);
108101
}
109102
throw err;
110103
}

src/platforms/ios/runner-session.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import {
66
type ExecBackgroundResult,
77
} from '../../utils/exec.ts';
88
import { withKeyedLock } from '../../utils/keyed-lock.ts';
9+
import { Deadline } from '../../utils/retry.ts';
910
import { isProcessAlive, isProcessGroupAlive } from '../../utils/process-identity.ts';
1011
import type { DeviceInfo } from '../../utils/device.ts';
1112
import { buildSimctlArgsForDevice } from './simctl.ts';
1213
import {
1314
waitForRunner,
15+
sendRunnerCommandOnce,
1416
getFreePort,
1517
logChunk,
1618
cleanupTempFile,
@@ -26,7 +28,7 @@ import {
2628
resolveRunnerMaxConcurrentDestinationsFlag,
2729
runnerPrepProcesses,
2830
} from './runner-xctestrun.ts';
29-
import type { RunnerCommand } from './runner-contract.ts';
31+
import { isReadOnlyRunnerCommand, type RunnerCommand } from './runner-contract.ts';
3032
import type { RunnerSession } from './runner-session-types.ts';
3133

3234
export type { RunnerSession } from './runner-session-types.ts';
@@ -334,15 +336,36 @@ export async function executeRunnerCommandWithSession(
334336
timeoutMs: number,
335337
signal?: AbortSignal,
336338
): Promise<Record<string, unknown>> {
337-
const response = await waitForRunner(
339+
const readOnlyCommand = isReadOnlyRunnerCommand(command.command);
340+
if (readOnlyCommand) {
341+
const response = await waitForRunner(
342+
device,
343+
session.port,
344+
command,
345+
logPath,
346+
timeoutMs,
347+
session,
348+
signal,
349+
);
350+
return await parseRunnerResponse(response, session, logPath);
351+
}
352+
353+
const deadline = Deadline.fromTimeoutMs(timeoutMs);
354+
const readinessResponse = await waitForRunner(
338355
device,
339356
session.port,
340-
command,
357+
{ command: 'uptime' },
341358
logPath,
342-
timeoutMs,
359+
Math.min(RUNNER_STARTUP_TIMEOUT_MS, deadline.remainingMs()),
343360
session,
344361
signal,
345362
);
363+
await parseRunnerResponse(readinessResponse, session, logPath);
364+
const remainingMs = deadline.remainingMs();
365+
if (remainingMs <= 0) {
366+
throw new AppError('COMMAND_FAILED', 'Runner command deadline exceeded', { timeoutMs });
367+
}
368+
const response = await sendRunnerCommandOnce(device, session.port, command, remainingMs, signal);
346369
return await parseRunnerResponse(response, session, logPath);
347370
}
348371

src/platforms/ios/runner-transport.ts

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -77,22 +77,7 @@ export async function waitForRunner(
7777
signal?: AbortSignal,
7878
): Promise<Response> {
7979
const deadline = Deadline.fromTimeoutMs(timeoutMs);
80-
let requestTunnelIp: string | null | undefined;
81-
const getEndpoints = async (timeoutBudgetMs?: number, forceRefresh = false) => {
82-
const tunnelIp = await getDeviceTunnelIpForRequest({
83-
device,
84-
timeoutBudgetMs,
85-
forceRefresh,
86-
requestTunnelIp,
87-
setRequestTunnelIp: (ip) => {
88-
requestTunnelIp = ip;
89-
},
90-
});
91-
return {
92-
endpoints: resolveRunnerCommandEndpoints(device, port, tunnelIp.ip),
93-
cached: tunnelIp.sharedCacheHit,
94-
};
95-
};
80+
const { getEndpoints } = createRunnerEndpointResolver(device, port);
9681
let { endpoints } = await getEndpoints(deadline.remainingMs());
9782
let lastError: unknown = null;
9883
const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS));
@@ -188,6 +173,63 @@ export async function waitForRunner(
188173
throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
189174
}
190175

176+
export async function sendRunnerCommandOnce(
177+
device: DeviceInfo,
178+
port: number,
179+
command: RunnerCommand,
180+
timeoutMs: number = RUNNER_COMMAND_TIMEOUT_MS,
181+
signal?: AbortSignal,
182+
): Promise<Response> {
183+
if (signal?.aborted) {
184+
throw createRequestCanceledError();
185+
}
186+
const deadline = Deadline.fromTimeoutMs(timeoutMs);
187+
const { getEndpoints } = createRunnerEndpointResolver(device, port);
188+
const { endpoints } = await getEndpoints(deadline.remainingMs());
189+
const endpoint = endpoints[0];
190+
if (!endpoint) {
191+
throw new AppError('COMMAND_FAILED', 'Runner command endpoint not available', {
192+
port,
193+
endpoints,
194+
});
195+
}
196+
const remainingMs = deadline.remainingMs();
197+
if (remainingMs <= 0) {
198+
throw new AppError('COMMAND_FAILED', 'Runner command deadline exceeded', { timeoutMs });
199+
}
200+
return await fetchWithTimeout(
201+
endpoint,
202+
{
203+
method: 'POST',
204+
headers: { 'Content-Type': 'application/json' },
205+
body: JSON.stringify(command),
206+
},
207+
remainingMs,
208+
signal,
209+
);
210+
}
211+
212+
function createRunnerEndpointResolver(device: DeviceInfo, port: number) {
213+
let requestTunnelIp: string | null | undefined;
214+
return {
215+
getEndpoints: async (timeoutBudgetMs?: number, forceRefresh = false) => {
216+
const tunnelIp = await getDeviceTunnelIpForRequest({
217+
device,
218+
timeoutBudgetMs,
219+
forceRefresh,
220+
requestTunnelIp,
221+
setRequestTunnelIp: (ip) => {
222+
requestTunnelIp = ip;
223+
},
224+
});
225+
return {
226+
endpoints: resolveRunnerCommandEndpoints(device, port, tunnelIp.ip),
227+
cached: tunnelIp.sharedCacheHit,
228+
};
229+
},
230+
};
231+
}
232+
191233
async function tryRunnerEndpoints(
192234
endpoints: string[],
193235
params: {

0 commit comments

Comments
 (0)