mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
fix: harden ios runner resilience and cancellation (#85)
This commit is contained in:
committed by
GitHub
parent
40283f5c27
commit
345b7eee7d
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user