Skip to content

Commit 802429c

Browse files
committed
fix: close the abandoned-probe recovery gap and bound the alert probe (#1244)
Two follow-ups to the bounded system-modal probe: 1. Recovery-path stall (review finding). After the modal probe times out, runMainThreadWork abandons its query but that query keeps grinding on the main thread. The plan recovers via private-AX and returns, but executeSnapshotDispatched then ran the `didRecordXCTestFailure` bookkeeping through another runMainThreadWork hop — which queues behind the same abandoned query and re-stalls the command for up to the 30s execution watchdog (or throws), reintroducing the very stall the recovery avoided. The forced 0.5s-vs-2s validation missed it because the ~2s probe drains before the hop waits long; the real 30-39s signature would block ~30s. Skip the post-recovery bookkeeping while abandoned XCTest work is outstanding, the same policy setNeedsPostSnapshotInteractionDelay already uses. Verified: the command now completes ~180ms before the abandoned probe drains instead of after it. 2. Sibling probe (generalization). resolveAlert runs the same SpringBoard enumeration for the `alert` command, previously only capped by the 30s command watchdog. Route `alert` through a bounded dispatched path (executeAlertDispatched) that runs detection on the shared probe watchdog and fails fast with a bounded ALERT_DETECTION_TIMED_OUT result. The snapshot and alert dispatched paths share executeDispatchedWithRecovery (the read-only retry + XCTest-recorded-failure invalidation the generic path gives via executeOnMainSafely), and the bounded alert bodies keep the generic path's Obj-C exception protection (invalidatingObjCExceptions) so a stale-element frame/label or allElementsBoundByIndex can't escape the dispatched block.
1 parent d216670 commit 802429c

3 files changed

Lines changed: 139 additions & 4 deletions

File tree

‎apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,40 @@ extension RunnerTests {
77
let buttons: [XCUIElement]
88
}
99

10-
func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? {
10+
/// Resolves the alert (`resolveAlert`) on the same bounded main-thread watchdog as the snapshot
11+
/// system-modal probe, so a slow SpringBoard alert/sheet enumeration can't stall the `alert`
12+
/// command up to the full execution watchdog (#1244). Throws the probe timeout on abandonment.
13+
func boundedResolveAlert(app: XCUIApplication, deadline: Date) throws -> RunnerAlert? {
14+
let slice = Self.systemModalProbeSlice(
15+
budget: systemModalProbeBudget,
16+
deadlineRemaining: deadline.timeIntervalSinceNow
17+
)
18+
guard slice > 0 else { return nil }
19+
let probeDeadline = Date().addingTimeInterval(slice)
20+
return try runMainThreadWork(
21+
command: nil,
22+
timeout: slice,
23+
timeoutError: systemModalProbeTimeoutError(slice: slice),
24+
onAbandoned: {
25+
self.retainAbandonedXCTestChannelWork()
26+
NSLog("AGENT_DEVICE_RUNNER_SYSTEM_MODAL_PROBE_TIMEOUT slice=%.1f context=alert", slice)
27+
self.penalizeSnapshotXCTestChannel(
28+
bundleId: self.currentBundleId,
29+
reason: "alert_modal_probe_timeout"
30+
)
31+
},
32+
onDrained: {
33+
self.releaseAbandonedXCTestChannelWork()
34+
NSLog("AGENT_DEVICE_RUNNER_SYSTEM_MODAL_PROBE_DRAINED context=alert")
35+
}
36+
) {
37+
try self.invalidatingObjCExceptions { self.resolveAlert(app: app, deadline: probeDeadline) }
38+
}
39+
}
40+
41+
func resolveAlert(app activeApp: XCUIApplication, deadline: Date = .distantFuture) -> RunnerAlert? {
1142
#if !os(macOS)
12-
if let systemModal = firstBlockingSystemModal(in: springboard) {
43+
if let systemModal = firstBlockingSystemModal(in: springboard, deadline: deadline) {
1344
return runnerAlert(root: systemModal, ownerApp: springboard)
1445
}
1546
#endif

‎apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,12 @@ extension RunnerTests {
691691
if command.command == .snapshot {
692692
return try executeSnapshotDispatched(command: command)
693693
}
694+
// Alert resolution enumerates SpringBoard alerts/sheets — the same query that can grind for
695+
// tens of seconds (#1244). Route it through the bounded dispatched path so its detection can't
696+
// eat the whole main-thread execution watchdog like the snapshot probe used to.
697+
if command.command == .alert {
698+
return try executeAlertDispatched(command: command)
699+
}
694700
return try runMainThreadWork(
695701
command: command,
696702
timeout: mainThreadExecutionTimeout,
@@ -836,6 +842,24 @@ extension RunnerTests {
836842
}
837843

838844
private func executeSnapshotDispatched(command: Command) throws -> Response {
845+
try executeDispatchedWithRecovery(command: command) {
846+
try self.executeSnapshotDispatchedOnce(command: command)
847+
}
848+
}
849+
850+
private func executeAlertDispatched(command: Command) throws -> Response {
851+
try executeDispatchedWithRecovery(command: command) {
852+
try self.executeAlertDispatchedOnce(command: command)
853+
}
854+
}
855+
856+
/// Runs a dispatched command on the background transport thread with the same read-only retry and
857+
/// XCTest-recorded-failure invalidation that `executeOnMainSafely` gives the generic path — the
858+
/// per-command body (`perform`) supplies its own bounded main-thread work.
859+
private func executeDispatchedWithRecovery(
860+
command: Command,
861+
perform: () throws -> Response
862+
) throws -> Response {
839863
var hasRetried = false
840864
while true {
841865
let failureCountBefore = try runMainThreadWork(
@@ -845,7 +869,19 @@ extension RunnerTests {
845869
) {
846870
self.currentXCTestFailureCount()
847871
}
848-
let response = try executeSnapshotDispatchedOnce(command: command)
872+
let response = try perform()
873+
// If the command body abandoned a still-grinding XCTest query and recovered through an
874+
// independent backend, the recorded-failure/retry bookkeeping below would queue behind that
875+
// query on the main thread and re-stall the command for up to the execution watchdog —
876+
// reintroducing the very #1244 stall the recovery just avoided. Skip it while abandoned
877+
// XCTest work is outstanding, the same policy `setNeedsPostSnapshotInteractionDelay` uses.
878+
if hasAbandonedTreeCapture() {
879+
NSLog(
880+
"AGENT_DEVICE_RUNNER_DISPATCH_RECOVERY_SKIPPED_XCTEST_OCCUPIED command=%@",
881+
command.command.rawValue
882+
)
883+
return response
884+
}
849885
let recordedFailureResponse = try runMainThreadWork(
850886
command: command,
851887
timeout: mainThreadExecutionTimeout,
@@ -885,6 +921,74 @@ extension RunnerTests {
885921
}
886922
}
887923

924+
private func executeAlertDispatchedOnce(command: Command) throws -> Response {
925+
let preparation = try runMainThreadWork(
926+
command: command,
927+
timeout: mainThreadExecutionTimeout,
928+
timeoutError: mainThreadExecutionTimeoutError
929+
) {
930+
try self.prepareActiveCommandContextSafely(command: command)
931+
}
932+
let activeApp: XCUIApplication
933+
switch preparation {
934+
case .response(let response):
935+
return response
936+
case .context(let context):
937+
activeApp = context.app
938+
}
939+
940+
let action = (command.action ?? "get").lowercased()
941+
let deadline = Date().addingTimeInterval(systemModalProbeBudget)
942+
let resolved: RunnerAlert?
943+
do {
944+
resolved = try boundedResolveAlert(app: activeApp, deadline: deadline)
945+
} catch is SnapshotCaptureFailure {
946+
// Only the probe budget timeout degrades to a bounded result; an Obj-C exception from
947+
// detection propagates like the generic path (invalidate + surface an error).
948+
NSLog("AGENT_DEVICE_RUNNER_ALERT_DETECTION_ABORTED")
949+
return Response(
950+
ok: false,
951+
error: ErrorPayload(
952+
code: "ALERT_DETECTION_TIMED_OUT",
953+
message: "Detecting a blocking alert exceeded its time budget.",
954+
hint:
955+
"The system alert tree is slow to read on this screen. Retry, or use screenshot as visual truth and dismiss by coordinates."
956+
)
957+
)
958+
}
959+
guard let alert = resolved else {
960+
return Response(ok: false, error: ErrorPayload(message: "alert not found"))
961+
}
962+
return try runMainThreadWork(
963+
command: command,
964+
timeout: mainThreadExecutionTimeout,
965+
timeoutError: mainThreadExecutionTimeoutError
966+
) {
967+
try self.invalidatingObjCExceptions { self.handleAlert(alert, action: action) }
968+
}
969+
}
970+
971+
/// Gives a bounded dispatched command body the same Obj-C exception handling `executeOnMainSafely`
972+
/// gives the generic path. `runMainThreadWork`'s `try` cannot catch an Obj-C exception, so an
973+
/// unwrapped throwing XCUITest call (a stale-element `frame`/`label`, an `allElementsBoundByIndex`
974+
/// on a torn-down alert) would escape the block and crash the runner instead of invalidating the
975+
/// cached target and surfacing as an error.
976+
func invalidatingObjCExceptions<T>(_ body: () -> T) throws -> T {
977+
var result: T?
978+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException {
979+
result = body()
980+
}
981+
if let exceptionMessage {
982+
invalidateCachedTarget(reason: "objc_exception")
983+
throw NSError(
984+
domain: RunnerErrorDomain.exception,
985+
code: RunnerErrorCode.objcException,
986+
userInfo: [NSLocalizedDescriptionKey: exceptionMessage]
987+
)
988+
}
989+
return result!
990+
}
991+
888992
private func executeSnapshotDispatchedOnce(command: Command) throws -> Response {
889993
let preparation = try runMainThreadWork(
890994
command: command,

‎apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ extension RunnerTests {
318318
return min(budget, deadlineRemaining)
319319
}
320320

321-
private func systemModalProbeTimeoutError(slice: TimeInterval) -> () -> Error {
321+
func systemModalProbeTimeoutError(slice: TimeInterval) -> () -> Error {
322322
{
323323
SnapshotCaptureFailure(
324324
code: Self.xCTestSnapshotTimeoutCode,

0 commit comments

Comments
 (0)