From 8f9102a2826ccb9743bac96ac29fdaaaa47dedc0 Mon Sep 17 00:00:00 2001 From: billsbooth <111548547+billsbooth@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:48:30 -0400 Subject: [PATCH 1/5] fix(apple): handle system alerts on physical iOS --- .../RunnerTests+CommandExecution.swift | 112 ++++++++++++++++-- .../RunnerTests.swift | 1 + 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index fcc41fa56..eeaec44c7 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -337,26 +337,81 @@ extension RunnerTests { XCTAssertFalse(snapshotXCTestPenaltyWarmupExemptionPending) } - func testSkipAppActivationPreflightOnlyIncludesCoordinateOnlySynthesizedTaps() throws { + func testSkipAppActivationPreflightIncludesForegroundCachedCoordinateOnlyTaps() throws { + app.launch() currentApp = app currentBundleId = nil defer { currentApp = nil currentBundleId = nil + app.terminate() } let tap = try runnerCommandFixture( - #"{"command":"tap","commandId":"tap-1","x":10,"y":20,"synthesized":true}"# + #"{"command":"tap","commandId":"tap-1","x":10,"y":20}"# ) XCTAssertTrue(shouldSkipAppActivationPreflight(tap)) } + func testSkipAppActivationPreflightRejectsMissingChangedAndBackgroundTargets() throws { + let coordinateTap = try runnerCommandFixture( + #"{"command":"tap","commandId":"tap-1","x":10,"y":20}"# + ) + currentApp = nil + currentBundleId = nil + XCTAssertFalse(shouldSkipAppActivationPreflight(coordinateTap)) + + app.launch() + currentApp = app + currentBundleId = "com.example.current" + defer { + currentApp = nil + currentBundleId = nil + app.terminate() + } + let changedBundleTap = try runnerCommandFixture( + #"{"command":"tap","commandId":"tap-2","appBundleId":"com.example.other","x":10,"y":20}"# + ) + + XCTAssertFalse(shouldSkipAppActivationPreflight(changedBundleTap)) + + app.terminate() + currentApp = app + currentBundleId = nil + + XCTAssertFalse(shouldSkipAppActivationPreflight(coordinateTap)) + } + + func testPrepareActiveCommandContextRoutesBlockingSystemModalToSpringboard() throws { + blockingSystemModalPresenceOverrideForTesting = true + currentApp = nil + currentBundleId = nil + defer { + blockingSystemModalPresenceOverrideForTesting = nil + currentApp = nil + currentBundleId = nil + } + let tap = try runnerCommandFixture( + #"{"command":"tap","commandId":"tap-1","x":10,"y":20}"# + ) + + let preparation = prepareActiveCommandContext(command: tap) + + guard case .context(let context) = preparation else { + XCTFail("expected command context") + return + } + XCTAssertTrue(context.app === springboard) + } + func testSkipAppActivationPreflightRejectsSelectorAndMixedSequenceGestures() throws { + app.launch() currentApp = app currentBundleId = nil defer { currentApp = nil currentBundleId = nil + app.terminate() } let selectorTap = try runnerCommandFixture( #"{"command":"tap","commandId":"tap-1","selectorKey":"label","selectorValue":"Search","synthesized":true}"# @@ -378,7 +433,7 @@ extension RunnerTests { XCTAssertFalse(shouldSkipAppActivationPreflight(mixedSequence)) } - func testSkipAppActivationPreflightRequiresCachedTarget() throws { + func testSkipAppActivationPreflightRequiresCachedForegroundTarget() throws { currentApp = nil currentBundleId = nil let scroll = try runnerCommandFixture( @@ -389,11 +444,13 @@ extension RunnerTests { } func testSkipAppActivationPreflightKeepsDragScrollAndSequenceOnForegroundGuard() throws { + app.launch() currentApp = app currentBundleId = nil defer { currentApp = nil currentBundleId = nil + app.terminate() } let drag = try runnerCommandFixture( #"{"command":"drag","commandId":"drag-1","x":10,"y":20,"x2":30,"y2":40,"synthesized":true}"# @@ -415,6 +472,14 @@ extension RunnerTests { XCTAssertFalse(shouldSkipAppActivationPreflight(sequence)) } + func testSkipAppActivationPreflightIncludesAlertCommands() throws { + let alert = try runnerCommandFixture( + #"{"command":"alert","commandId":"alert-1","action":"get"}"# + ) + + XCTAssertTrue(shouldSkipAppActivationPreflight(alert)) + } + func testExecuteDispatchedReturnsBusyBeforeMainThreadFastPath() throws { let command = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-busy"}"#) abandonedMainThreadWorkCount = 1 @@ -1171,7 +1236,9 @@ extension RunnerTests { private func prepareActiveCommandContext(command: Command) -> ActiveCommandPreparation { var activeApp = currentApp ?? app - if shouldSkipAppActivationPreflight(command) { + if shouldRouteToSpringboardBlockingSystemModal(command) { + activeApp = springboard + } else if shouldSkipAppActivationPreflight(command) { activeApp = resolveAppWithoutActivation(command: command) } else if !isRunnerLifecycleCommand(command.command) { let normalizedBundleId = command.appBundleId? @@ -2034,23 +2101,48 @@ extension RunnerTests { private func shouldSkipAppActivationPreflight(_ command: Command) -> Bool { #if os(iOS) - // Coordinate-only synthesized taps can run after an AX-fatal screen because they do not + if command.command == .alert { + return true + } + // Coordinate-only synthesized taps can run after an AX-fatal foreground screen because they do not // need app activation, window lookup, keyboard lookup, or element resolution. Selector/text // interactions intentionally stay on the normal AX path because they need an element query. // Scroll/drag/sequence keep the normal foreground guard and stabilization path. guard command.text == nil, command.selectorKey == nil else { return false } guard hasCachedTargetForActivationSkip(command: command) else { return false } - return command.command == .tap - && command.synthesized == true - && command.x != nil - && command.y != nil + return isCoordinateOnlyTap(command) #else return false #endif } + private func shouldRouteToSpringboardBlockingSystemModal(_ command: Command) -> Bool { +#if os(iOS) + guard command.command == .alert || isCoordinateOnlyTap(command) else { + return false + } + #if AGENT_DEVICE_RUNNER_UNIT_TESTS + if let override = blockingSystemModalPresenceOverrideForTesting { + return override + } + #endif + let deadline = Date().addingTimeInterval(systemModalProbeBudget) + return firstBlockingSystemModal(in: springboard, deadline: deadline) != nil +#else + return false +#endif + } + + private func isCoordinateOnlyTap(_ command: Command) -> Bool { + return command.command == .tap + && command.text == nil + && command.selectorKey == nil + && command.x != nil + && command.y != nil + } + private func hasCachedTargetForActivationSkip(command: Command) -> Bool { - guard currentApp != nil else { return false } + guard let currentApp, currentApp.state == .runningForeground else { return false } guard let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleId.isEmpty else { diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 955776244..b6d17ac51 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -99,6 +99,7 @@ final class RunnerTests: XCTestCase { // live SpringBoard alert. Production never compiles this property. Stored here (rather than in // the extension that reads it) because Swift extensions cannot hold stored properties. var systemModalProbeOverrideForTesting: ((Date) -> DataPayload?)? + var blockingSystemModalPresenceOverrideForTesting: Bool? var alertResolutionOverrideForTesting: ((Date) -> RunnerAlert?)? #endif // Observability for the record(_:) suppression below: how many AX-broken-screen snapshot From b9b32912c624f06281ad63d93edfbe4f1052efcc Mon Sep 17 00:00:00 2001 From: billsbooth <111548547+billsbooth@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:34:05 -0400 Subject: [PATCH 2/5] fix(apple): skip redundant foreground activation --- .../RunnerTests+Lifecycle.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift index 09721e8f9..7359ead04 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift @@ -169,14 +169,22 @@ extension RunnerTests { func activateTarget(bundleId: String, reason: String) -> XCUIApplication { let target = XCUIApplication(bundleIdentifier: bundleId) + let initialState = target.state NSLog( "AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d reason=%@", bundleId, - target.state.rawValue, + initialState.rawValue, reason ) // activate avoids terminating and relaunching the target app - target.activate() + if initialState == .runningForeground { + NSLog( + "AGENT_DEVICE_RUNNER_ACTIVATE_SKIPPED bundle=%@ reason=already_foreground", + bundleId + ) + } else { + target.activate() + } currentApp = target currentBundleId = bundleId currentAppProcessIdentifier = Self.processIdentifier(of: target) From 1a32866809dbf60bf43499801c4e00797a5cbadc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 15 Jul 2026 11:10:52 +0200 Subject: [PATCH 3/5] fix(apple): recover from blocked modal routing probe --- .../RunnerTests+CommandExecution.swift | 177 +++++++++++++++--- 1 file changed, 156 insertions(+), 21 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index eeaec44c7..5162a0adf 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -395,7 +395,10 @@ extension RunnerTests { #"{"command":"tap","commandId":"tap-1","x":10,"y":20}"# ) - let preparation = prepareActiveCommandContext(command: tap) + let preparation = prepareActiveCommandContext( + command: tap, + routeToSpringboard: shouldRouteToSpringboardBlockingSystemModal(tap) + ) guard case .context(let context) = preparation else { XCTFail("expected command context") @@ -404,6 +407,85 @@ extension RunnerTests { XCTAssertTrue(context.app === springboard) } + func testExecuteDispatchedReturnsBusyBeforeBlockingSystemModalProbeDrains() throws { + app.launch() + currentApp = app + currentBundleId = nil + defer { + currentApp = nil + currentBundleId = nil + systemModalProbeOverrideForTesting = nil + clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") + app.terminate() + } + + final class ResultBox { + var response: Response? + var error: Error? + var commandRecoveredBeforeRelease = false + var wasBusyBeforeRelease = false + var hadAbandonedProbeBeforeRelease = false + var drained = false + } + let box = ResultBox() + let probeStarted = expectation(description: "system-modal routing probe started") + let verificationFinished = expectation(description: "command recovery and modal probe drain verified") + let probeReleaseGate = DispatchSemaphore(value: 0) + let commandFinishedGate = DispatchSemaphore(value: 0) + systemModalProbeOverrideForTesting = { _ in + probeStarted.fulfill() + _ = probeReleaseGate.wait(timeout: .now() + 15) + return DataPayload(message: "late system modal") + } + + let command = try runnerCommandFixture( + #"{"command":"tap","commandId":"bounded-modal-routing","x":10,"y":20}"# + ) + DispatchQueue(label: "agent-device.runner.tests.modal-routing-probe").async { + do { + box.response = try self.executeDispatched(command: command) + } catch { + box.error = error + } + commandFinishedGate.signal() + } + DispatchQueue(label: "agent-device.runner.tests.modal-routing-probe-verifier").async { + let commandWait = commandFinishedGate.wait( + timeout: .now() + self.systemModalProbeBudget + 3 + ) + box.commandRecoveredBeforeRelease = commandWait == .success + && box.error == nil + && box.response?.error?.code == "RUNNER_BUSY" + if case .busy = self.currentMainThreadBusyState() { + box.wasBusyBeforeRelease = true + } + box.hadAbandonedProbeBeforeRelease = self.hasAbandonedTreeCapture() + + // The XCTest main thread is blocked inside the injected probe, so this verifier owns the + // ordered release after recording the command result and abandoned-work state above. + probeReleaseGate.signal() + let deadline = Date().addingTimeInterval(5) + while self.hasAbandonedTreeCapture(), Date() < deadline { + self.sleepFor(0.002) + } + box.drained = !self.hasAbandonedTreeCapture() + verificationFinished.fulfill() + } + + wait(for: [probeStarted, verificationFinished], timeout: 15) + XCTAssertTrue( + box.commandRecoveredBeforeRelease, + "the public coordinate tap must return RUNNER_BUSY before the blocked modal probe drains" + ) + XCTAssertTrue(box.wasBusyBeforeRelease) + XCTAssertTrue(box.hadAbandonedProbeBeforeRelease) + XCTAssertTrue(box.drained) + guard case .idle = currentMainThreadBusyState() else { + return XCTFail("expected the runner to become idle after the routing probe drained") + } + XCTAssertFalse(hasAbandonedTreeCapture()) + } + func testSkipAppActivationPreflightRejectsSelectorAndMixedSequenceGestures() throws { app.launch() currentApp = app @@ -788,24 +870,42 @@ extension RunnerTests { ) } - private func executeDispatched(command: Command) throws -> Response { - // XCTest work cannot be cancelled mid-flight: once the watchdog abandons a main-queue - // block, queueing more main-thread commands behind it only buries the runner deeper. - // Refuse fast instead so the daemon backs off while the abandoned work drains; past the - // wedge threshold, escalate so the daemon recycles this runner (#1105). + private func runnerUnavailableResponse(command: Command) -> Response? { switch currentMainThreadBusyState() { case .idle: - break + return nil case .busy(let abandonedForSeconds): return runnerBusyResponse(command: command, abandonedForSeconds: abandonedForSeconds) case .wedged(let abandonedForSeconds): return runnerWedgedResponse(command: command, abandonedForSeconds: abandonedForSeconds) } + } + + private func executeDispatched(command: Command) throws -> Response { + // XCTest work cannot be cancelled mid-flight: once the watchdog abandons a main-queue + // block, queueing more main-thread commands behind it only buries the runner deeper. + // Refuse fast instead so the daemon backs off while the abandoned work drains; past the + // wedge threshold, escalate so the daemon recycles this runner (#1105). + if let unavailable = runnerUnavailableResponse(command: command) { + return unavailable + } if Thread.isMainThread { + let routeToSpringboard = shouldRouteToSpringboardBlockingSystemModal(command) let alertDeadline = command.command == .alert ? Date().addingTimeInterval(Self.alertCommandTimeout(timeoutMs: command.timeoutMs)) : nil - return try executeOnMainSafely(command: command, alertDeadline: alertDeadline) + return try executeOnMainSafely( + command: command, + alertDeadline: alertDeadline, + routeToSpringboard: routeToSpringboard + ) + } + // Resolve this before the command's outer main-thread block. If the bounded probe abandons + // slow XCTest enumeration, return the established recoverable response instead of queueing + // command preparation behind work that may outlive the 30-second command watchdog. + let routeToSpringboard = shouldRouteToSpringboardBlockingSystemModal(command) + if let unavailable = runnerUnavailableResponse(command: command) { + return unavailable } if command.command == .snapshot { return try executeSnapshotDispatched(command: command) @@ -819,7 +919,11 @@ extension RunnerTests { timeout: max(0.001, deadline.timeIntervalSinceNow), timeoutError: mainThreadExecutionTimeoutError ) { - try self.executeOnMainSafely(command: command, alertDeadline: deadline) + try self.executeOnMainSafely( + command: command, + alertDeadline: deadline, + routeToSpringboard: routeToSpringboard + ) } } return try runMainThreadWork( @@ -827,7 +931,7 @@ extension RunnerTests { timeout: mainThreadExecutionTimeout, timeoutError: mainThreadExecutionTimeoutError ) { - try self.executeOnMainSafely(command: command) + try self.executeOnMainSafely(command: command, routeToSpringboard: routeToSpringboard) } } @@ -907,7 +1011,8 @@ extension RunnerTests { private func executeOnMainSafely( command: Command, - alertDeadline: Date? = nil + alertDeadline: Date? = nil, + routeToSpringboard: Bool ) throws -> Response { var hasRetried = false while true { @@ -916,7 +1021,11 @@ extension RunnerTests { let failureCountBefore = currentXCTestFailureCount() let exceptionMessage = RunnerObjCExceptionCatcher.catchException({ do { - response = try self.executeOnMain(command: command, alertDeadline: alertDeadline) + response = try self.executeOnMain( + command: command, + alertDeadline: alertDeadline, + routeToSpringboard: routeToSpringboard + ) } catch { swiftError = error } @@ -1046,7 +1155,7 @@ extension RunnerTests { timeout: mainThreadExecutionTimeout, timeoutError: mainThreadExecutionTimeoutError ) { - try self.prepareActiveCommandContextSafely(command: command) + try self.prepareActiveCommandContextSafely(command: command, routeToSpringboard: false) } switch preparation { case .response(let response): @@ -1125,10 +1234,16 @@ extension RunnerTests { } } - private func prepareActiveCommandContextSafely(command: Command) throws -> ActiveCommandPreparation { + private func prepareActiveCommandContextSafely( + command: Command, + routeToSpringboard: Bool + ) throws -> ActiveCommandPreparation { var preparation: ActiveCommandPreparation? let exceptionMessage = RunnerObjCExceptionCatcher.catchException({ - preparation = self.prepareActiveCommandContext(command: command) + preparation = self.prepareActiveCommandContext( + command: command, + routeToSpringboard: routeToSpringboard + ) }) if let exceptionMessage { throw NSError( @@ -1147,8 +1262,15 @@ extension RunnerTests { return preparation } - private func executeOnMain(command: Command, alertDeadline: Date?) throws -> Response { - let preparation = prepareActiveCommandContext(command: command) + private func executeOnMain( + command: Command, + alertDeadline: Date?, + routeToSpringboard: Bool + ) throws -> Response { + let preparation = prepareActiveCommandContext( + command: command, + routeToSpringboard: routeToSpringboard + ) let activeApp: XCUIApplication switch preparation { case .response(let response): @@ -1234,9 +1356,12 @@ extension RunnerTests { ) } - private func prepareActiveCommandContext(command: Command) -> ActiveCommandPreparation { + private func prepareActiveCommandContext( + command: Command, + routeToSpringboard: Bool = false + ) -> ActiveCommandPreparation { var activeApp = currentApp ?? app - if shouldRouteToSpringboardBlockingSystemModal(command) { + if routeToSpringboard { activeApp = springboard } else if shouldSkipAppActivationPreflight(command) { activeApp = resolveAppWithoutActivation(command: command) @@ -2126,8 +2251,18 @@ extension RunnerTests { return override } #endif - let deadline = Date().addingTimeInterval(systemModalProbeBudget) - return firstBlockingSystemModal(in: springboard, deadline: deadline) != nil + // `runMainThreadWork` executes inline for a main-thread caller, so that path cannot use its + // timeout machinery. Direct main-thread dispatch keeps the prior synchronous modal check; + // normal off-main command dispatch uses the bounded probe and post-probe busy recovery. + if Thread.isMainThread { + return firstBlockingSystemModal( + in: springboard, + deadline: Date().addingTimeInterval(systemModalProbeBudget) + ) != nil + } + return boundedBlockingSystemAlertSnapshot( + deadline: Date().addingTimeInterval(systemModalProbeBudget) + ) != nil #else return false #endif From 4b7cd01f1d1af2da98afd703d785adea2a3cd3f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 15 Jul 2026 11:27:51 +0200 Subject: [PATCH 4/5] test(apple): cover foreground activation skip --- .../RunnerTests+LifecycleCacheTests.swift | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift index 34898554f..122ab1f1f 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift @@ -1,6 +1,63 @@ import XCTest +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) +import ObjectiveC.runtime + +private enum RunnerTargetActivationSpy { + static var state: XCUIApplication.State = .unknown + static var activationCount = 0 +} + +private final class RunnerTargetActivationStub: NSObject { + @objc var state: XCUIApplication.State { + RunnerTargetActivationSpy.state + } + + @objc func activate() { + RunnerTargetActivationSpy.activationCount += 1 + } +} +#endif + extension RunnerTests { +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) + func testActivateTargetSkipsForegroundAndActivatesNonForegroundApplication() { + let stateSelector = #selector(getter: XCUIApplication.state) + let activateSelector = #selector(XCUIApplication.activate) + guard + let stateMethod = class_getInstanceMethod(XCUIApplication.self, stateSelector), + let stateStubMethod = class_getInstanceMethod(RunnerTargetActivationStub.self, stateSelector), + let activateMethod = class_getInstanceMethod(XCUIApplication.self, activateSelector), + let activateStubMethod = class_getInstanceMethod( + RunnerTargetActivationStub.self, + activateSelector + ) + else { + return XCTFail("unable to install target activation spy") + } + let originalStateImplementation = method_getImplementation(stateMethod) + let originalActivateImplementation = method_getImplementation(activateMethod) + method_setImplementation(stateMethod, method_getImplementation(stateStubMethod)) + method_setImplementation(activateMethod, method_getImplementation(activateStubMethod)) + RunnerTargetActivationSpy.activationCount = 0 + defer { + method_setImplementation(stateMethod, originalStateImplementation) + method_setImplementation(activateMethod, originalActivateImplementation) + RunnerTargetActivationSpy.state = .unknown + RunnerTargetActivationSpy.activationCount = 0 + invalidateCachedTarget(reason: "unit_test_cleanup") + } + + RunnerTargetActivationSpy.state = .runningForeground + _ = activateTarget(bundleId: "com.example.foreground", reason: "unit_test") + XCTAssertEqual(RunnerTargetActivationSpy.activationCount, 0) + + RunnerTargetActivationSpy.state = .runningBackground + _ = activateTarget(bundleId: "com.example.background", reason: "unit_test") + XCTAssertEqual(RunnerTargetActivationSpy.activationCount, 1) + } +#endif + func testCachedTargetRefreshRequiresChangedPositiveProcessIdentity() { XCTAssertFalse( Self.shouldRefreshCachedTarget( From 5c92004391d3497d7d655eda626f7ab3834bdc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 15 Jul 2026 13:17:59 +0200 Subject: [PATCH 5/5] fix(apple): include modal probe in alert deadline --- .../RunnerTests+CommandExecution.swift | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 5162a0adf..add53d278 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -597,6 +597,7 @@ extension RunnerTests { func testAlertResolutionCannotBypassRequestedDeadline() throws { final class ResultBox { var error: Error? + var probeDeadline: Date? var observedDeadline: Date? } let box = ResultBox() @@ -605,10 +606,14 @@ extension RunnerTests { let commandFinished = expectation(description: "alert command respected its deadline") let startedAt = Date() let command = try runnerCommandFixture( - #"{"command":"alert","commandId":"alert-deadline","appBundleId":"com.apple.springboard","action":"get","timeoutMs":50}"# + #"{"command":"alert","commandId":"alert-deadline","appBundleId":"com.apple.springboard","action":"get","timeoutMs":500}"# ) currentApp = springboard currentBundleId = Self.springboardBundleId + systemModalProbeOverrideForTesting = { deadline in + box.probeDeadline = deadline + return nil + } alertResolutionOverrideForTesting = { deadline in box.observedDeadline = deadline _ = releaseResolution.wait(timeout: .now() + 1) @@ -617,6 +622,7 @@ extension RunnerTests { } defer { releaseResolution.signal() + systemModalProbeOverrideForTesting = nil alertResolutionOverrideForTesting = nil currentApp = nil currentBundleId = nil @@ -635,10 +641,12 @@ extension RunnerTests { let error = box.error as NSError? XCTAssertEqual(error?.domain, RunnerErrorDomain.general) XCTAssertEqual(error?.code, RunnerErrorCode.mainThreadExecutionTimedOut) + XCTAssertNotNil(box.probeDeadline) XCTAssertNotNil(box.observedDeadline) - if let observedDeadline = box.observedDeadline { + if let probeDeadline = box.probeDeadline, let observedDeadline = box.observedDeadline { + XCTAssertEqual(probeDeadline.timeIntervalSince(observedDeadline), 0, accuracy: 0.01) XCTAssertGreaterThan(observedDeadline.timeIntervalSince(startedAt), 0) - XCTAssertLessThan(observedDeadline.timeIntervalSince(startedAt), 0.2) + XCTAssertLessThan(observedDeadline.timeIntervalSince(startedAt), 0.75) } releaseResolution.signal() @@ -889,11 +897,14 @@ extension RunnerTests { if let unavailable = runnerUnavailableResponse(command: command) { return unavailable } + let alertDeadline = command.command == .alert + ? Date().addingTimeInterval(Self.alertCommandTimeout(timeoutMs: command.timeoutMs)) + : nil if Thread.isMainThread { - let routeToSpringboard = shouldRouteToSpringboardBlockingSystemModal(command) - let alertDeadline = command.command == .alert - ? Date().addingTimeInterval(Self.alertCommandTimeout(timeoutMs: command.timeoutMs)) - : nil + let routeToSpringboard = shouldRouteToSpringboardBlockingSystemModal( + command, + deadline: alertDeadline + ) return try executeOnMainSafely( command: command, alertDeadline: alertDeadline, @@ -903,17 +914,17 @@ extension RunnerTests { // Resolve this before the command's outer main-thread block. If the bounded probe abandons // slow XCTest enumeration, return the established recoverable response instead of queueing // command preparation behind work that may outlive the 30-second command watchdog. - let routeToSpringboard = shouldRouteToSpringboardBlockingSystemModal(command) + let routeToSpringboard = shouldRouteToSpringboardBlockingSystemModal( + command, + deadline: alertDeadline + ) if let unavailable = runnerUnavailableResponse(command: command) { return unavailable } if command.command == .snapshot { return try executeSnapshotDispatched(command: command) } - if command.command == .alert { - let deadline = Date().addingTimeInterval( - Self.alertCommandTimeout(timeoutMs: command.timeoutMs) - ) + if command.command == .alert, let deadline = alertDeadline { return try runMainThreadWork( command: command, timeout: max(0.001, deadline.timeIntervalSinceNow), @@ -2241,7 +2252,10 @@ extension RunnerTests { #endif } - private func shouldRouteToSpringboardBlockingSystemModal(_ command: Command) -> Bool { + private func shouldRouteToSpringboardBlockingSystemModal( + _ command: Command, + deadline: Date? = nil + ) -> Bool { #if os(iOS) guard command.command == .alert || isCoordinateOnlyTap(command) else { return false @@ -2251,17 +2265,19 @@ extension RunnerTests { return override } #endif + let budgetDeadline = Date().addingTimeInterval(systemModalProbeBudget) + let probeDeadline = deadline.map { min($0, budgetDeadline) } ?? budgetDeadline // `runMainThreadWork` executes inline for a main-thread caller, so that path cannot use its // timeout machinery. Direct main-thread dispatch keeps the prior synchronous modal check; // normal off-main command dispatch uses the bounded probe and post-probe busy recovery. if Thread.isMainThread { return firstBlockingSystemModal( in: springboard, - deadline: Date().addingTimeInterval(systemModalProbeBudget) + deadline: probeDeadline ) != nil } return boundedBlockingSystemAlertSnapshot( - deadline: Date().addingTimeInterval(systemModalProbeBudget) + deadline: probeDeadline ) != nil #else return false