Skip to content

Commit 4b34027

Browse files
committed
fix: prevent stale ios runner loops after timeout
1 parent 330b290 commit 4b34027

5 files changed

Lines changed: 105 additions & 23 deletions

File tree

src/core/dispatch.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,12 @@ export async function dispatchCommand(
184184
doubleTap,
185185
appBundleId: context?.appBundleId,
186186
},
187-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
187+
{
188+
verbose: context?.verbose,
189+
logPath: context?.logPath,
190+
traceLogPath: context?.traceLogPath,
191+
requestId: context?.requestId,
192+
},
188193
);
189194
return { x, y, count, intervalMs, holdMs, jitterPx, doubleTap, timingMode: 'runner-series' };
190195
}
@@ -237,7 +242,12 @@ export async function dispatchCommand(
237242
pattern,
238243
appBundleId: context?.appBundleId,
239244
},
240-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
245+
{
246+
verbose: context?.verbose,
247+
logPath: context?.logPath,
248+
traceLogPath: context?.traceLogPath,
249+
requestId: context?.requestId,
250+
},
241251
);
242252
return {
243253
x1,
@@ -334,7 +344,12 @@ export async function dispatchCommand(
334344
await runIosRunnerCommand(
335345
device,
336346
{ command: 'pinch', scale, x, y, appBundleId: context?.appBundleId },
337-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
347+
{
348+
verbose: context?.verbose,
349+
logPath: context?.logPath,
350+
traceLogPath: context?.traceLogPath,
351+
requestId: context?.requestId,
352+
},
338353
);
339354
return { scale, x, y };
340355
}
@@ -350,7 +365,12 @@ export async function dispatchCommand(
350365
await runIosRunnerCommand(
351366
device,
352367
{ command: 'back', appBundleId: context?.appBundleId },
353-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
368+
{
369+
verbose: context?.verbose,
370+
logPath: context?.logPath,
371+
traceLogPath: context?.traceLogPath,
372+
requestId: context?.requestId,
373+
},
354374
);
355375
return { action: 'back' };
356376
}
@@ -362,7 +382,12 @@ export async function dispatchCommand(
362382
await runIosRunnerCommand(
363383
device,
364384
{ command: 'home', appBundleId: context?.appBundleId },
365-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
385+
{
386+
verbose: context?.verbose,
387+
logPath: context?.logPath,
388+
traceLogPath: context?.traceLogPath,
389+
requestId: context?.requestId,
390+
},
366391
);
367392
return { action: 'home' };
368393
}
@@ -374,7 +399,12 @@ export async function dispatchCommand(
374399
await runIosRunnerCommand(
375400
device,
376401
{ command: 'appSwitcher', appBundleId: context?.appBundleId },
377-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
402+
{
403+
verbose: context?.verbose,
404+
logPath: context?.logPath,
405+
traceLogPath: context?.traceLogPath,
406+
requestId: context?.requestId,
407+
},
378408
);
379409
return { action: 'app-switcher' };
380410
}
@@ -415,7 +445,12 @@ export async function dispatchCommand(
415445
scope: context?.snapshotScope,
416446
raw: context?.snapshotRaw,
417447
},
418-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
448+
{
449+
verbose: context?.verbose,
450+
logPath: context?.logPath,
451+
traceLogPath: context?.traceLogPath,
452+
requestId: context?.requestId,
453+
},
419454
),
420455
{
421456
backend: 'xctest',

src/daemon-client.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
248248
const timeout = setTimeout(() => {
249249
socket.destroy();
250250
const cleanup = cleanupTimedOutIosRunnerBuilds();
251-
resetDaemonAfterTimeout(info);
251+
const daemonReset = resetDaemonAfterTimeout(info);
252252
emitDiagnostic({
253253
level: 'error',
254254
phase: 'daemon_request_timeout',
@@ -259,6 +259,7 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
259259
timedOutRunnerPidsTerminated: cleanup.terminated,
260260
timedOutRunnerCleanupError: cleanup.error,
261261
daemonPidReset: info.pid,
262+
daemonPidForceKilled: daemonReset.forcedKill,
262263
},
263264
});
264265
reject(
@@ -333,15 +334,24 @@ function cleanupTimedOutIosRunnerBuilds(): { terminated: number; error?: string
333334
}
334335
}
335336

336-
function resetDaemonAfterTimeout(info: DaemonInfo): void {
337-
void stopProcessForTakeover(info.pid, {
338-
termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
339-
killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
340-
expectedStartTime: info.processStartTime,
341-
}).finally(() => {
337+
function resetDaemonAfterTimeout(info: DaemonInfo): { forcedKill: boolean } {
338+
let forcedKill = false;
339+
try {
340+
if (isAgentDeviceDaemonProcess(info.pid, info.processStartTime)) {
341+
process.kill(info.pid, 'SIGKILL');
342+
forcedKill = true;
343+
}
344+
} catch {
345+
void stopProcessForTakeover(info.pid, {
346+
termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
347+
killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
348+
expectedStartTime: info.processStartTime,
349+
});
350+
} finally {
342351
removeDaemonInfo();
343352
removeDaemonLock();
344-
});
353+
}
354+
return { forcedKill };
345355
}
346356

347357
export function resolveDaemonRequestTimeoutMs(raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS): number {

src/daemon/handlers/snapshot.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,12 @@ export async function handleSnapshotCommands(params: {
234234
const result = (await runIosRunnerCommand(
235235
device,
236236
{ command: 'findText', text, appBundleId: session?.appBundleId },
237-
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
237+
{
238+
verbose: req.flags?.verbose,
239+
logPath,
240+
traceLogPath: session?.trace?.outPath,
241+
requestId: req.meta?.requestId,
242+
},
238243
)) as { found?: boolean };
239244
if (result?.found) {
240245
recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start });
@@ -277,7 +282,12 @@ export async function handleSnapshotCommands(params: {
277282
const data = await runIosRunnerCommand(
278283
device,
279284
{ command: 'alert', action: 'get', appBundleId: session?.appBundleId },
280-
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
285+
{
286+
verbose: req.flags?.verbose,
287+
logPath,
288+
traceLogPath: session?.trace?.outPath,
289+
requestId: req.meta?.requestId,
290+
},
281291
);
282292
recordIfSession(sessionStore, session, req, data as Record<string, unknown>);
283293
return { ok: true, data };
@@ -296,7 +306,12 @@ export async function handleSnapshotCommands(params: {
296306
action === 'accept' || action === 'dismiss' ? (action as 'accept' | 'dismiss') : 'get',
297307
appBundleId: session?.appBundleId,
298308
},
299-
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
309+
{
310+
verbose: req.flags?.verbose,
311+
logPath,
312+
traceLogPath: session?.trace?.outPath,
313+
requestId: req.meta?.requestId,
314+
},
300315
);
301316
recordIfSession(sessionStore, session, req, data as Record<string, unknown>);
302317
return { ok: true, data };

src/platforms/ios/runner-client.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { isProcessAlive } from '../../utils/process-identity.ts';
1111
import net from 'node:net';
1212
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
1313
import { resolveTimeoutMs, resolveTimeoutSeconds } from '../../utils/timeouts.ts';
14+
import { isRequestCanceled } from '../../daemon/request-cancel.ts';
1415

1516
export type RunnerCommand = {
1617
command:
@@ -126,13 +127,22 @@ export type RunnerSnapshotNode = {
126127
export async function runIosRunnerCommand(
127128
device: DeviceInfo,
128129
command: RunnerCommand,
129-
options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
130+
options: { verbose?: boolean; logPath?: string; traceLogPath?: string; requestId?: string } = {},
130131
): Promise<Record<string, unknown>> {
131132
validateRunnerDevice(device);
133+
assertRunnerRequestActive(options.requestId);
132134
if (isReadOnlyRunnerCommand(command.command)) {
133135
return withRetry(
134-
() => executeRunnerCommand(device, command, options),
135-
{ shouldRetry: isRetryableRunnerError },
136+
() => {
137+
assertRunnerRequestActive(options.requestId);
138+
return executeRunnerCommand(device, command, options);
139+
},
140+
{
141+
shouldRetry: (error) => {
142+
assertRunnerRequestActive(options.requestId);
143+
return isRetryableRunnerError(error);
144+
},
145+
},
136146
);
137147
}
138148
return executeRunnerCommand(device, command, options);
@@ -145,8 +155,9 @@ function withRunnerSessionLock<T>(deviceId: string, task: () => Promise<T>): Pro
145155
async function executeRunnerCommand(
146156
device: DeviceInfo,
147157
command: RunnerCommand,
148-
options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
158+
options: { verbose?: boolean; logPath?: string; traceLogPath?: string; requestId?: string } = {},
149159
): Promise<Record<string, unknown>> {
160+
assertRunnerRequestActive(options.requestId);
150161
let session: RunnerSession | undefined;
151162
try {
152163
session = await ensureRunnerSession(device, options);
@@ -167,6 +178,7 @@ async function executeRunnerCommand(
167178
shouldRetryRunnerConnectError(appErr) &&
168179
session?.ready
169180
) {
181+
assertRunnerRequestActive(options.requestId);
170182
if (session) {
171183
await stopRunnerSession(session);
172184
} else {
@@ -649,6 +661,11 @@ function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
649661
return command === 'snapshot' || command === 'findText' || command === 'listTappables' || command === 'alert';
650662
}
651663

664+
function assertRunnerRequestActive(requestId: string | undefined): void {
665+
if (!isRequestCanceled(requestId)) return;
666+
throw new AppError('COMMAND_FAILED', 'request canceled');
667+
}
668+
652669
function shouldCleanDerived(): boolean {
653670
return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED);
654671
}

src/utils/interactors.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,12 @@ type IoRunnerOverrides = Pick<
8787
>;
8888

8989
function iosRunnerOverrides(device: DeviceInfo, ctx: RunnerContext): IoRunnerOverrides {
90-
const runnerOpts = { verbose: ctx.verbose, logPath: ctx.logPath, traceLogPath: ctx.traceLogPath };
90+
const runnerOpts = {
91+
verbose: ctx.verbose,
92+
logPath: ctx.logPath,
93+
traceLogPath: ctx.traceLogPath,
94+
requestId: ctx.requestId,
95+
};
9196
const throwIfCanceled = () => {
9297
if (!isRequestCanceled(ctx.requestId)) return;
9398
throw new AppError('COMMAND_FAILED', 'request canceled');

0 commit comments

Comments
 (0)