fix: harden ios runner resilience and cancellation (#85)

This commit is contained in:
Michał Pierzchała
2026-02-20 17:18:37 +01:00
committed by GitHub
parent 40283f5c27
commit 345b7eee7d
10 changed files with 491 additions and 73 deletions
@@ -33,6 +33,7 @@ final class RunnerTests: XCTestCase {
private let maxSnapshotElements = 600
private let fastSnapshotLimit = 300
private let mainThreadExecutionTimeout: TimeInterval = 30
private let appExistenceTimeout: TimeInterval = 30
private let retryCooldown: TimeInterval = 0.2
private let postSnapshotInteractionDelay: TimeInterval = 0.2
private let firstInteractionAfterActivateDelay: TimeInterval = 0.25
@@ -261,7 +262,11 @@ final class RunnerTests: XCTestCase {
if let exceptionMessage {
currentApp = nil
currentBundleId = nil
if !hasRetried, shouldRetryCommand(command.command) {
if !hasRetried, shouldRetryException(command, message: exceptionMessage) {
NSLog(
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=objc_exception",
command.command.rawValue
)
hasRetried = true
sleepFor(retryCooldown)
continue
@@ -282,7 +287,11 @@ final class RunnerTests: XCTestCase {
userInfo: [NSLocalizedDescriptionKey: "command returned no response"]
)
}
if !hasRetried, shouldRetryCommand(command.command), shouldRetryResponse(response) {
if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
NSLog(
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
command.command.rawValue
)
hasRetried = true
currentApp = nil
currentBundleId = nil
@@ -319,10 +328,10 @@ final class RunnerTests: XCTestCase {
activeApp = app
}
if !activeApp.waitForExistence(timeout: 5) {
if !activeApp.waitForExistence(timeout: appExistenceTimeout) {
if let bundleId = requestedBundleId {
activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
guard activeApp.waitForExistence(timeout: 5) else {
guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
}
} else {
@@ -532,10 +541,35 @@ final class RunnerTests: XCTestCase {
return target
}
private func shouldRetryCommand(_ command: CommandType) -> Bool {
switch command {
case .tap, .longPress, .drag:
private func shouldRetryCommand(_ command: Command) -> Bool {
if isEnvTruthy("AGENT_DEVICE_RUNNER_DISABLE_READONLY_RETRY") {
return false
}
return isReadOnlyCommand(command)
}
private func shouldRetryException(_ command: Command, message: String) -> Bool {
guard shouldRetryCommand(command) else { return false }
let normalized = message.lowercased()
if normalized.contains("kaxerrorservernotfound") {
return true
}
if normalized.contains("main thread execution timed out") {
return true
}
if normalized.contains("timed out") && command.command == .snapshot {
return true
}
return false
}
private func isReadOnlyCommand(_ command: Command) -> Bool {
switch command.command {
case .findText, .listTappables, .snapshot:
return true
case .alert:
let action = (command.action ?? "get").lowercased()
return action == "get"
default:
return false
}
@@ -977,43 +1011,58 @@ final class RunnerTests: XCTestCase {
}
let title = preferredSystemModalTitle(modal)
var nodes: [SnapshotNode] = [
makeSnapshotNode(
element: modal,
index: 0,
type: "Alert",
labelOverride: title,
identifierOverride: modal.identifier,
depth: 0,
hittableOverride: true
)
]
guard let modalNode = safeMakeSnapshotNode(
element: modal,
index: 0,
type: "Alert",
labelOverride: title,
identifierOverride: modal.identifier,
depth: 0,
hittableOverride: true
) else {
return nil
}
var nodes: [SnapshotNode] = [modalNode]
for action in actions {
nodes.append(
makeSnapshotNode(
element: action,
index: nodes.count,
type: elementTypeName(action.elementType),
depth: 1,
hittableOverride: true
)
)
guard let actionNode = safeMakeSnapshotNode(
element: action,
index: nodes.count,
type: elementTypeName(action.elementType),
depth: 1,
hittableOverride: true
) else {
continue
}
nodes.append(actionNode)
}
return DataPayload(nodes: nodes, truncated: false)
}
private func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? {
for alert in springboard.alerts.allElementsBoundByIndex {
if isBlockingSystemModal(alert, in: springboard) {
let disableSafeProbe = isEnvTruthy("AGENT_DEVICE_RUNNER_DISABLE_SAFE_MODAL_PROBE")
let queryElements: (() -> [XCUIElement]) -> [XCUIElement] = { fetch in
if disableSafeProbe {
return fetch()
}
return self.safeElementsQuery(fetch)
}
let alerts = queryElements {
springboard.alerts.allElementsBoundByIndex
}
for alert in alerts {
if safeIsBlockingSystemModal(alert, in: springboard) {
return alert
}
}
for sheet in springboard.sheets.allElementsBoundByIndex {
if isBlockingSystemModal(sheet, in: springboard) {
let sheets = queryElements {
springboard.sheets.allElementsBoundByIndex
}
for sheet in sheets {
if safeIsBlockingSystemModal(sheet, in: springboard) {
return sheet
}
}
@@ -1021,6 +1070,36 @@ final class RunnerTests: XCTestCase {
return nil
}
private func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
var elements: [XCUIElement] = []
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
elements = fetch()
})
if let exceptionMessage {
NSLog(
"AGENT_DEVICE_RUNNER_MODAL_QUERY_IGNORED_EXCEPTION=%@",
exceptionMessage
)
return []
}
return elements
}
private func safeIsBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
var isBlocking = false
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
isBlocking = isBlockingSystemModal(element, in: springboard)
})
if let exceptionMessage {
NSLog(
"AGENT_DEVICE_RUNNER_MODAL_CHECK_IGNORED_EXCEPTION=%@",
exceptionMessage
)
return false
}
return isBlocking
}
private func isBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
guard element.exists else { return false }
let frame = element.frame
@@ -1038,20 +1117,38 @@ final class RunnerTests: XCTestCase {
private func actionableElements(in element: XCUIElement) -> [XCUIElement] {
var seen = Set<String>()
var actions: [XCUIElement] = []
let descendants = element.descendants(matching: .any).allElementsBoundByIndex
let descendants = safeElementsQuery {
element.descendants(matching: .any).allElementsBoundByIndex
}
for candidate in descendants {
if !candidate.exists || !candidate.isHittable { continue }
if !actionableTypes.contains(candidate.elementType) { continue }
let frame = candidate.frame
if frame.isNull || frame.isEmpty { continue }
let key = "\(candidate.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(candidate.label)"
if seen.contains(key) { continue }
seen.insert(key)
if !safeIsActionableCandidate(candidate, seen: &seen) { continue }
actions.append(candidate)
}
return actions
}
private func safeIsActionableCandidate(_ candidate: XCUIElement, seen: inout Set<String>) -> Bool {
var include = false
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
if !candidate.exists || !candidate.isHittable { return }
if !actionableTypes.contains(candidate.elementType) { return }
let frame = candidate.frame
if frame.isNull || frame.isEmpty { return }
let key = "\(candidate.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(candidate.label)"
if seen.contains(key) { return }
seen.insert(key)
include = true
})
if let exceptionMessage {
NSLog(
"AGENT_DEVICE_RUNNER_MODAL_ACTION_IGNORED_EXCEPTION=%@",
exceptionMessage
)
return false
}
return include
}
private func preferredSystemModalTitle(_ element: XCUIElement) -> String {
let label = element.label
if !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -1088,6 +1185,37 @@ final class RunnerTests: XCTestCase {
)
}
private func safeMakeSnapshotNode(
element: XCUIElement,
index: Int,
type: String,
labelOverride: String? = nil,
identifierOverride: String? = nil,
depth: Int,
hittableOverride: Bool? = nil
) -> SnapshotNode? {
var node: SnapshotNode?
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
node = makeSnapshotNode(
element: element,
index: index,
type: type,
labelOverride: labelOverride,
identifierOverride: identifierOverride,
depth: depth,
hittableOverride: hittableOverride
)
})
if let exceptionMessage {
NSLog(
"AGENT_DEVICE_RUNNER_MODAL_NODE_IGNORED_EXCEPTION=%@",
exceptionMessage
)
return nil
}
return node
}
private func snapshotRect(from frame: CGRect) -> SnapshotRect {
return SnapshotRect(
x: Double(frame.origin.x),
@@ -1213,6 +1341,18 @@ private func resolveRunnerPort() -> UInt16 {
return 0
}
private func isEnvTruthy(_ name: String) -> Bool {
guard let raw = ProcessInfo.processInfo.environment[name] else {
return false
}
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
enum CommandType: String, Codable {
case tap
case tapSeries
+44 -7
View File
@@ -77,6 +77,7 @@ export async function dispatchCommand(
positionals: string[],
outPath?: string,
context?: {
requestId?: string;
appBundleId?: string;
activity?: string;
verbose?: boolean;
@@ -97,6 +98,7 @@ export async function dispatchCommand(
},
): Promise<Record<string, unknown> | void> {
const runnerCtx: RunnerContext = {
requestId: context?.requestId,
appBundleId: context?.appBundleId,
verbose: context?.verbose,
logPath: context?.logPath,
@@ -182,7 +184,12 @@ export async function dispatchCommand(
doubleTap,
appBundleId: context?.appBundleId,
},
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
{
verbose: context?.verbose,
logPath: context?.logPath,
traceLogPath: context?.traceLogPath,
requestId: context?.requestId,
},
);
return { x, y, count, intervalMs, holdMs, jitterPx, doubleTap, timingMode: 'runner-series' };
}
@@ -235,7 +242,12 @@ export async function dispatchCommand(
pattern,
appBundleId: context?.appBundleId,
},
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
{
verbose: context?.verbose,
logPath: context?.logPath,
traceLogPath: context?.traceLogPath,
requestId: context?.requestId,
},
);
return {
x1,
@@ -332,7 +344,12 @@ export async function dispatchCommand(
await runIosRunnerCommand(
device,
{ command: 'pinch', scale, x, y, appBundleId: context?.appBundleId },
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
{
verbose: context?.verbose,
logPath: context?.logPath,
traceLogPath: context?.traceLogPath,
requestId: context?.requestId,
},
);
return { scale, x, y };
}
@@ -348,7 +365,12 @@ export async function dispatchCommand(
await runIosRunnerCommand(
device,
{ command: 'back', appBundleId: context?.appBundleId },
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
{
verbose: context?.verbose,
logPath: context?.logPath,
traceLogPath: context?.traceLogPath,
requestId: context?.requestId,
},
);
return { action: 'back' };
}
@@ -360,7 +382,12 @@ export async function dispatchCommand(
await runIosRunnerCommand(
device,
{ command: 'home', appBundleId: context?.appBundleId },
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
{
verbose: context?.verbose,
logPath: context?.logPath,
traceLogPath: context?.traceLogPath,
requestId: context?.requestId,
},
);
return { action: 'home' };
}
@@ -372,7 +399,12 @@ export async function dispatchCommand(
await runIosRunnerCommand(
device,
{ command: 'appSwitcher', appBundleId: context?.appBundleId },
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
{
verbose: context?.verbose,
logPath: context?.logPath,
traceLogPath: context?.traceLogPath,
requestId: context?.requestId,
},
);
return { action: 'app-switcher' };
}
@@ -413,7 +445,12 @@ export async function dispatchCommand(
scope: context?.snapshotScope,
raw: context?.snapshotRaw,
},
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
{
verbose: context?.verbose,
logPath: context?.logPath,
traceLogPath: context?.traceLogPath,
requestId: context?.requestId,
},
),
{
backend: 'xctest',
+49 -2
View File
@@ -4,7 +4,7 @@ import os from 'node:os';
import path from 'node:path';
import { AppError } from './utils/errors.ts';
import type { DaemonRequest as SharedDaemonRequest, DaemonResponse as SharedDaemonResponse } from './daemon/types.ts';
import { runCmdDetached } from './utils/exec.ts';
import { runCmdDetached, runCmdSync } from './utils/exec.ts';
import { findProjectRoot, readVersion } from './utils/version.ts';
import { createRequestId, emitDiagnostic, withDiagnosticTimer } from './utils/diagnostics.ts';
import {
@@ -41,6 +41,11 @@ const REQUEST_TIMEOUT_MS = resolveDaemonRequestTimeoutMs();
const DAEMON_STARTUP_TIMEOUT_MS = 5000;
const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000;
const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000;
const IOS_RUNNER_XCODEBUILD_KILL_PATTERNS = [
'xcodebuild .*AgentDeviceRunnerUITests/RunnerTests/testCommand',
'xcodebuild .*AgentDeviceRunner\\.env\\.session-',
'xcodebuild build-for-testing .*ios-runner/AgentDeviceRunner/AgentDeviceRunner\\.xcodeproj',
];
export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> {
const requestId = req.meta?.requestId ?? createRequestId();
@@ -242,6 +247,8 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
});
const timeout = setTimeout(() => {
socket.destroy();
const cleanup = cleanupTimedOutIosRunnerBuilds();
const daemonReset = resetDaemonAfterTimeout(info);
emitDiagnostic({
level: 'error',
phase: 'daemon_request_timeout',
@@ -249,13 +256,17 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
timeoutMs: REQUEST_TIMEOUT_MS,
requestId: req.meta?.requestId,
command: req.command,
timedOutRunnerPidsTerminated: cleanup.terminated,
timedOutRunnerCleanupError: cleanup.error,
daemonPidReset: info.pid,
daemonPidForceKilled: daemonReset.forcedKill,
},
});
reject(
new AppError('COMMAND_FAILED', 'Daemon request timed out', {
timeoutMs: REQUEST_TIMEOUT_MS,
requestId: req.meta?.requestId,
hint: 'Retry with --debug and check daemon diagnostics logs.',
hint: 'Retry with --debug and check daemon diagnostics logs. Timed-out iOS runner xcodebuild processes were terminated when detected.',
}),
);
}, REQUEST_TIMEOUT_MS);
@@ -307,6 +318,42 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
});
}
function cleanupTimedOutIosRunnerBuilds(): { terminated: number; error?: string } {
let terminated = 0;
try {
for (const pattern of IOS_RUNNER_XCODEBUILD_KILL_PATTERNS) {
const result = runCmdSync('pkill', ['-f', pattern], { allowFailure: true });
if (result.exitCode === 0) terminated += 1;
}
return { terminated };
} catch (error) {
return {
terminated,
error: error instanceof Error ? error.message : String(error),
};
}
}
function resetDaemonAfterTimeout(info: DaemonInfo): { forcedKill: boolean } {
let forcedKill = false;
try {
if (isAgentDeviceDaemonProcess(info.pid, info.processStartTime)) {
process.kill(info.pid, 'SIGKILL');
forcedKill = true;
}
} catch {
void stopProcessForTakeover(info.pid, {
termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
expectedStartTime: info.processStartTime,
});
} finally {
removeDaemonInfo();
removeDaemonLock();
}
return { forcedKill };
}
export function resolveDaemonRequestTimeoutMs(raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS): number {
if (!raw) return 90000;
const parsed = Number(raw);
+60 -3
View File
@@ -7,7 +7,7 @@ import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
import { isCommandSupportedOnDevice } from './core/capabilities.ts';
import { asAppError, AppError, normalizeError } from './utils/errors.ts';
import { readVersion } from './utils/version.ts';
import { stopAllIosRunnerSessions } from './platforms/ios/runner-client.ts';
import { abortAllIosRunnerSessions, stopAllIosRunnerSessions } from './platforms/ios/runner-client.ts';
import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
import { SessionStore } from './daemon/session-store.ts';
import { contextFromFlags as contextFromFlagsWithLog, type DaemonCommandContext } from './daemon/context.ts';
@@ -18,6 +18,7 @@ import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
import { clearRequestCanceled, isRequestCanceled, markRequestCanceled } from './daemon/request-cancel.ts';
import {
isAgentDeviceDaemonProcess,
readProcessStartTime,
@@ -33,6 +34,8 @@ const sessionStore = new SessionStore(sessionsDir);
const version = readVersion();
const token = crypto.randomBytes(24).toString('hex');
const selectorValidationExemptCommands = new Set(['session_list', 'devices']);
const disconnectAbortPollIntervalMs = 200;
const disconnectAbortMaxWindowMs = 15_000;
type DaemonLockInfo = {
pid: number;
@@ -48,7 +51,11 @@ function contextFromFlags(
appBundleId?: string,
traceLogPath?: string,
): DaemonCommandContext {
return contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath);
const requestId = getDiagnosticsMeta().requestId;
return {
...contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath, requestId),
requestId,
};
}
async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
@@ -292,7 +299,36 @@ function start(): void {
const server = net.createServer((socket) => {
let buffer = '';
let inFlightRequests = 0;
const activeRequestIds = new Set<string>();
let canceledInFlight = false;
const cancelInFlightRunnerSessions = () => {
if (canceledInFlight || inFlightRequests === 0) return;
canceledInFlight = true;
for (const requestId of activeRequestIds) {
markRequestCanceled(requestId);
}
emitDiagnostic({
level: 'warn',
phase: 'request_client_disconnected',
data: {
inFlightRequests,
},
});
// Best effort: if client disconnects mid-request (for example on Ctrl+C),
// repeatedly abort runner sessions while request work is still in-flight.
void (async () => {
const deadline = Date.now() + disconnectAbortMaxWindowMs;
while (inFlightRequests > 0 && Date.now() < deadline) {
await abortAllIosRunnerSessions();
if (inFlightRequests <= 0) break;
await sleep(disconnectAbortPollIntervalMs);
}
})();
};
socket.setEncoding('utf8');
socket.on('close', cancelInFlightRunnerSessions);
socket.on('error', cancelInFlightRunnerSessions);
socket.on('data', async (chunk) => {
buffer += chunk;
let idx = buffer.indexOf('\n');
@@ -304,13 +340,30 @@ function start(): void {
continue;
}
let response: DaemonResponse;
inFlightRequests += 1;
let requestIdForCleanup: string | undefined;
try {
const req = JSON.parse(line) as DaemonRequest;
requestIdForCleanup = req.meta?.requestId;
if (requestIdForCleanup) {
activeRequestIds.add(requestIdForCleanup);
if (isRequestCanceled(requestIdForCleanup)) {
throw new AppError('COMMAND_FAILED', 'request canceled');
}
}
response = await handleRequest(req);
} catch (err) {
response = { ok: false, error: normalizeError(err) };
} finally {
inFlightRequests -= 1;
if (requestIdForCleanup) {
activeRequestIds.delete(requestIdForCleanup);
clearRequestCanceled(requestIdForCleanup);
}
}
if (!socket.destroyed) {
socket.write(`${JSON.stringify(response)}\n`);
}
socket.write(`${JSON.stringify(response)}\n`);
idx = buffer.indexOf('\n');
}
});
@@ -364,4 +417,8 @@ function start(): void {
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
start();
+5
View File
@@ -1,6 +1,8 @@
import type { CommandFlags } from '../core/dispatch.ts';
import { getDiagnosticsMeta } from '../utils/diagnostics.ts';
export type DaemonCommandContext = {
requestId?: string;
appBundleId?: string;
activity?: string;
verbose?: boolean;
@@ -25,8 +27,11 @@ export function contextFromFlags(
flags: CommandFlags | undefined,
appBundleId?: string,
traceLogPath?: string,
requestId?: string,
): DaemonCommandContext {
const effectiveRequestId = requestId ?? getDiagnosticsMeta().requestId;
return {
requestId: effectiveRequestId,
appBundleId,
activity: flags?.activity,
verbose: flags?.verbose,
+18 -3
View File
@@ -234,7 +234,12 @@ export async function handleSnapshotCommands(params: {
const result = (await runIosRunnerCommand(
device,
{ command: 'findText', text, appBundleId: session?.appBundleId },
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
{
verbose: req.flags?.verbose,
logPath,
traceLogPath: session?.trace?.outPath,
requestId: req.meta?.requestId,
},
)) as { found?: boolean };
if (result?.found) {
recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start });
@@ -277,7 +282,12 @@ export async function handleSnapshotCommands(params: {
const data = await runIosRunnerCommand(
device,
{ command: 'alert', action: 'get', appBundleId: session?.appBundleId },
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
{
verbose: req.flags?.verbose,
logPath,
traceLogPath: session?.trace?.outPath,
requestId: req.meta?.requestId,
},
);
recordIfSession(sessionStore, session, req, data as Record<string, unknown>);
return { ok: true, data };
@@ -296,7 +306,12 @@ export async function handleSnapshotCommands(params: {
action === 'accept' || action === 'dismiss' ? (action as 'accept' | 'dismiss') : 'get',
appBundleId: session?.appBundleId,
},
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
{
verbose: req.flags?.verbose,
logPath,
traceLogPath: session?.trace?.outPath,
requestId: req.meta?.requestId,
},
);
recordIfSession(sessionStore, session, req, data as Record<string, unknown>);
return { ok: true, data };
+16
View File
@@ -0,0 +1,16 @@
const canceledRequestIds = new Set<string>();
export function markRequestCanceled(requestId: string | undefined): void {
if (!requestId) return;
canceledRequestIds.add(requestId);
}
export function clearRequestCanceled(requestId: string | undefined): void {
if (!requestId) return;
canceledRequestIds.delete(requestId);
}
export function isRequestCanceled(requestId: string | undefined): boolean {
if (!requestId) return false;
return canceledRequestIds.has(requestId);
}
+78 -8
View File
@@ -11,6 +11,7 @@ import { isProcessAlive } from '../../utils/process-identity.ts';
import net from 'node:net';
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
import { resolveTimeoutMs, resolveTimeoutSeconds } from '../../utils/timeouts.ts';
import { isRequestCanceled } from '../../daemon/request-cancel.ts';
export type RunnerCommand = {
command:
@@ -66,6 +67,7 @@ export type RunnerSession = {
const runnerSessions = new Map<string, RunnerSession>();
const runnerSessionLocks = new Map<string, Promise<unknown>>();
const runnerPrepProcesses = new Set<ExecBackgroundResult['child']>();
const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs(
process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS,
45_000,
@@ -73,7 +75,7 @@ const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs(
);
const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
process.env.AGENT_DEVICE_RUNNER_COMMAND_TIMEOUT_MS,
15_000,
45_000,
1_000,
);
const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = resolveTimeoutMs(
@@ -93,7 +95,7 @@ const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = resolveTimeoutMs(
);
const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
process.env.AGENT_DEVICE_RUNNER_CONNECT_REQUEST_TIMEOUT_MS,
5_000,
20_000,
250,
);
const RUNNER_DEVICE_INFO_TIMEOUT_MS = resolveTimeoutMs(
@@ -125,13 +127,22 @@ export type RunnerSnapshotNode = {
export async function runIosRunnerCommand(
device: DeviceInfo,
command: RunnerCommand,
options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
options: { verbose?: boolean; logPath?: string; traceLogPath?: string; requestId?: string } = {},
): Promise<Record<string, unknown>> {
validateRunnerDevice(device);
assertRunnerRequestActive(options.requestId);
if (isReadOnlyRunnerCommand(command.command)) {
return withRetry(
() => executeRunnerCommand(device, command, options),
{ shouldRetry: isRetryableRunnerError },
() => {
assertRunnerRequestActive(options.requestId);
return executeRunnerCommand(device, command, options);
},
{
shouldRetry: (error) => {
assertRunnerRequestActive(options.requestId);
return isRetryableRunnerError(error);
},
},
);
}
return executeRunnerCommand(device, command, options);
@@ -144,8 +155,9 @@ function withRunnerSessionLock<T>(deviceId: string, task: () => Promise<T>): Pro
async function executeRunnerCommand(
device: DeviceInfo,
command: RunnerCommand,
options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
options: { verbose?: boolean; logPath?: string; traceLogPath?: string; requestId?: string } = {},
): Promise<Record<string, unknown>> {
assertRunnerRequestActive(options.requestId);
let session: RunnerSession | undefined;
try {
session = await ensureRunnerSession(device, options);
@@ -166,6 +178,7 @@ async function executeRunnerCommand(
shouldRetryRunnerConnectError(appErr) &&
session?.ready
) {
assertRunnerRequestActive(options.requestId);
if (session) {
await stopRunnerSession(session);
} else {
@@ -229,12 +242,38 @@ export async function stopIosRunnerSession(deviceId: string): Promise<void> {
});
}
export async function abortAllIosRunnerSessions(): Promise<void> {
const activeSessions = Array.from(runnerSessions.values());
const prepProcesses = Array.from(runnerPrepProcesses);
await Promise.allSettled(activeSessions.map(async (session) => {
await killRunnerProcessTree(session.child.pid, 'SIGINT');
}));
await Promise.allSettled(prepProcesses.map(async (child) => {
await killRunnerProcessTree(child.pid, 'SIGINT');
}));
await Promise.allSettled(activeSessions.map(async (session) => {
await killRunnerProcessTree(session.child.pid, 'SIGTERM');
}));
await Promise.allSettled(prepProcesses.map(async (child) => {
await killRunnerProcessTree(child.pid, 'SIGTERM');
}));
await Promise.allSettled(activeSessions.map(async (session) => {
await killRunnerProcessTree(session.child.pid, 'SIGKILL');
}));
await Promise.allSettled(prepProcesses.map(async (child) => {
await killRunnerProcessTree(child.pid, 'SIGKILL');
runnerPrepProcesses.delete(child);
}));
}
export async function stopAllIosRunnerSessions(): Promise<void> {
await abortAllIosRunnerSessions();
// Shutdown cleanup drains the sessions known at invocation time; daemon shutdown closes intake.
const pending = Array.from(runnerSessions.keys());
await Promise.allSettled(pending.map(async (deviceId) => {
await stopIosRunnerSession(deviceId);
}));
await stopAllRunnerPrepProcesses();
}
async function stopRunnerSession(session: RunnerSession): Promise<void> {
@@ -322,6 +361,7 @@ async function ensureRunnerSession(
{
allowFailure: true,
env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
detached: true,
},
);
child.stdout?.on('data', (chunk: string) => {
@@ -353,15 +393,21 @@ function isRunnerProcessAlive(pid: number | undefined): boolean {
async function killRunnerProcessTree(
pid: number | undefined,
signal: 'SIGTERM' | 'SIGKILL',
signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL',
): Promise<void> {
if (!pid || pid <= 0) return;
// xcodebuild is spawned detached for runner flows, so negative PID targets its process group.
try {
process.kill(-pid, signal);
} catch {
// ignore
}
try {
process.kill(pid, signal);
} catch {
// ignore
}
const pkillSignal = signal === 'SIGTERM' ? 'TERM' : 'KILL';
const pkillSignal = signal === 'SIGINT' ? 'INT' : signal === 'SIGTERM' ? 'TERM' : 'KILL';
try {
await runCmd('pkill', [`-${pkillSignal}`, '-P', String(pid)], { allowFailure: true });
} catch {
@@ -416,6 +462,13 @@ async function ensureXctestrun(
...signingBuildSettings,
],
{
detached: true,
onSpawn: (child) => {
runnerPrepProcesses.add(child);
child.on('close', () => {
runnerPrepProcesses.delete(child);
});
},
onStdoutChunk: (chunk) => {
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
},
@@ -442,6 +495,18 @@ async function ensureXctestrun(
return built;
}
async function stopAllRunnerPrepProcesses(): Promise<void> {
const prepProcesses = Array.from(runnerPrepProcesses);
await Promise.allSettled(prepProcesses.map(async (child) => {
try {
await killRunnerProcessTree(child.pid, 'SIGTERM');
await killRunnerProcessTree(child.pid, 'SIGKILL');
} finally {
runnerPrepProcesses.delete(child);
}
}));
}
function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
if (override) {
@@ -596,6 +661,11 @@ function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
return command === 'snapshot' || command === 'findText' || command === 'listTappables' || command === 'alert';
}
function assertRunnerRequestActive(requestId: string | undefined): void {
if (!isRequestCanceled(requestId)) return;
throw new AppError('COMMAND_FAILED', 'request canceled');
}
function shouldCleanDerived(): boolean {
return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED);
}
+6
View File
@@ -15,11 +15,13 @@ export type ExecOptions = {
binaryStdout?: boolean;
stdin?: string | Buffer;
timeoutMs?: number;
detached?: boolean;
};
export type ExecStreamOptions = ExecOptions & {
onStdoutChunk?: (chunk: string) => void;
onStderrChunk?: (chunk: string) => void;
onSpawn?: (child: ReturnType<typeof spawn>) => void;
};
export type ExecBackgroundResult = {
@@ -37,6 +39,7 @@ export async function runCmd(
cwd: options.cwd,
env: options.env,
stdio: ['pipe', 'pipe', 'pipe'],
detached: options.detached,
});
let stdout = '';
@@ -199,7 +202,9 @@ export async function runCmdStreaming(
cwd: options.cwd,
env: options.env,
stdio: ['pipe', 'pipe', 'pipe'],
detached: options.detached,
});
options.onSpawn?.(child);
let stdout = '';
let stderr = '';
@@ -269,6 +274,7 @@ export function runCmdBackground(
cwd: options.cwd,
env: options.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: options.detached,
});
let stdout = '';
+35 -10
View File
@@ -21,8 +21,10 @@ import {
screenshotIos,
} from '../platforms/ios/index.ts';
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
import { isRequestCanceled } from '../daemon/request-cancel.ts';
export type RunnerContext = {
requestId?: string;
appBundleId?: string;
verbose?: boolean;
logPath?: string;
@@ -85,7 +87,16 @@ type IoRunnerOverrides = Pick<
>;
function iosRunnerOverrides(device: DeviceInfo, ctx: RunnerContext): IoRunnerOverrides {
const runnerOpts = { verbose: ctx.verbose, logPath: ctx.logPath, traceLogPath: ctx.traceLogPath };
const runnerOpts = {
verbose: ctx.verbose,
logPath: ctx.logPath,
traceLogPath: ctx.traceLogPath,
requestId: ctx.requestId,
};
const throwIfCanceled = () => {
if (!isRequestCanceled(ctx.requestId)) return;
throw new AppError('COMMAND_FAILED', 'request canceled');
};
return {
tap: async (x, y) => {
@@ -154,20 +165,34 @@ function iosRunnerOverrides(device: DeviceInfo, ctx: RunnerContext): IoRunnerOve
);
},
scrollIntoView: async (text) => {
const maxAttempts = 8;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
// Check once, then scroll in bursts to avoid slow find->swipe->find cadence on heavy screens.
const initial = (await runIosRunnerCommand(
device,
{ command: 'findText', text, appBundleId: ctx.appBundleId },
runnerOpts,
)) as { found?: boolean };
if (initial?.found) return { attempts: 1 };
const maxBursts = 12;
const swipesPerBurst = 4;
for (let burst = 0; burst < maxBursts; burst += 1) {
for (let i = 0; i < swipesPerBurst; i += 1) {
throwIfCanceled();
await runIosRunnerCommand(
device,
{ command: 'swipe', direction: 'up', appBundleId: ctx.appBundleId },
runnerOpts,
);
// Small settle keeps gesture chain stable without long visible pauses.
await new Promise((resolve) => setTimeout(resolve, 80));
}
throwIfCanceled();
const found = (await runIosRunnerCommand(
device,
{ command: 'findText', text, appBundleId: ctx.appBundleId },
runnerOpts,
)) as { found?: boolean };
if (found?.found) return { attempts: attempt + 1 };
await runIosRunnerCommand(
device,
{ command: 'swipe', direction: 'up', appBundleId: ctx.appBundleId },
runnerOpts,
);
await new Promise((resolve) => setTimeout(resolve, 300));
if (found?.found) return { attempts: burst + 2 };
}
throw new AppError('COMMAND_FAILED', `scrollintoview could not find text: ${text}`);
},