From 4035dfecf7161a767f0456f4f069ccce37aaa467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 17:59:33 +0200 Subject: [PATCH 1/6] fix: bound the iOS system-modal snapshot probe to the capture deadline (#1244) The pre-plan SpringBoard system-modal probe (`blockingSystemAlertSnapshot`) ran before `runSnapshotCapturePlan`, outside the 20s plan budget, the bounded main-thread watchdog, and the XCTest recovery envelope. A slow `springboard.alerts`/`sheets`/descendant enumeration (seen on ASWebAuthenticationSession consent and notification-permission dialogs) could therefore stall `snapshot -i` for 30-39s. Run the probe as a bounded capture tier instead: it shares the snapshot plan deadline and executes on the same `runMainThreadWork` watchdog as the tree and query backends (`systemModalProbeBudget`, clamped by the remaining deadline). On timeout the abandoned probe is tracked like an abandoned tree capture, so the plan recovers through the independent (private-AX on simulator) backend, later commands fail fast as busy instead of queueing behind it, and the runner is never wedged. Diagnostics identify the probe and its elapsed time. The shared abandonment bookkeeping used by both the tree capture and the modal probe is extracted into retain/release helpers. --- .../RunnerTests+Snapshot.swift | 111 ++++++++++++++++-- .../RunnerTests+SnapshotCapturePlan.swift | 6 +- .../RunnerTests+SystemModal.swift | 15 ++- .../RunnerTests.swift | 3 + 4 files changed, 120 insertions(+), 15 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index dea4100db..1ba7ebc19 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -102,14 +102,16 @@ extension RunnerTests { static let flatInteractiveFallbackBudget: TimeInterval = 1.0 func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload { - if let blocking = blockingSystemAlertSnapshot() { + let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget) + if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline) { return blocking } return try runSnapshotCapturePlan( Self.regularVisiblePlan, app: app, options: options, - terminal: .sparseWithFatalOnAXFailure + terminal: .sparseWithFatalOnAXFailure, + deadline: deadline ) } @@ -246,17 +248,86 @@ extension RunnerTests { } func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload { - if let blocking = blockingSystemAlertSnapshot() { + let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget) + if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline) { return blocking } return try runSnapshotCapturePlan( Self.rawDiagnosticPlan, app: app, options: options, - terminal: .throwOnAXFailure + terminal: .throwOnAXFailure, + deadline: deadline ) } + /// Runs the pre-plan SpringBoard system-modal probe as a bounded capture tier sharing the plan + /// deadline, so a slow alert enumeration cannot bypass the snapshot timeout and stall (#1244). + func boundedBlockingSystemAlertSnapshot(deadline: Date) -> DataPayload? { + #if os(macOS) + return nil + #else + let slice = Self.systemModalProbeSlice( + budget: systemModalProbeBudget, + deadlineRemaining: deadline.timeIntervalSinceNow + ) + guard slice > 0 else { + NSLog("AGENT_DEVICE_RUNNER_SYSTEM_MODAL_PROBE_SKIPPED reason=budget_exhausted") + return nil + } + let probeDeadline = Date().addingTimeInterval(slice) + let startedAt = Date() + do { + return try runMainThreadWork( + command: nil, + timeout: slice, + timeoutError: systemModalProbeTimeoutError(slice: slice), + onAbandoned: { + self.retainAbandonedXCTestChannelWork() + NSLog("AGENT_DEVICE_RUNNER_SYSTEM_MODAL_PROBE_TIMEOUT slice=%.1f", slice) + self.penalizeSnapshotXCTestChannel( + bundleId: self.currentBundleId, + reason: "system_modal_probe_timeout" + ) + }, + onDrained: { + self.releaseAbandonedXCTestChannelWork() + NSLog("AGENT_DEVICE_RUNNER_SYSTEM_MODAL_PROBE_DRAINED") + } + ) { + self.blockingSystemAlertSnapshot(deadline: probeDeadline) + } + } catch { + NSLog( + "AGENT_DEVICE_RUNNER_SYSTEM_MODAL_PROBE_ABORTED elapsedMs=%d error=%@", + Int(Date().timeIntervalSince(startedAt) * 1000), + String(describing: error) + ) + return nil + } + #endif + } + + /// The probe gets its own budget, clamped by whatever remains of the shared plan deadline, and + /// 0 (skip entirely) once that deadline is already spent. + static func systemModalProbeSlice( + budget: TimeInterval, + deadlineRemaining: TimeInterval + ) -> TimeInterval { + guard deadlineRemaining > 0 else { return 0 } + return min(budget, deadlineRemaining) + } + + private func systemModalProbeTimeoutError(slice: TimeInterval) -> () -> Error { + { + SnapshotCaptureFailure( + code: Self.xCTestSnapshotTimeoutCode, + message: "the system-modal probe exceeded its \(slice)s time slice", + hint: "The capture plan recovers through non-XCTest snapshot tiers while the modal probe drains." + ) + } + } + func rawTreeSnapshotPayload( context: SnapshotTraversalContext, options: SnapshotOptions @@ -477,6 +548,16 @@ extension RunnerTests { XCTAssertTrue(failure.message.contains("\(Self.rawSnapshotMaxNodes) nodes")) XCTAssertEqual(failure.hint, Self.rawSnapshotTooLargeHint) } + + func testSystemModalProbeSliceSharesAndClampsToPlanDeadline() { + // Fresh plan deadline: the probe gets its full dedicated budget. + XCTAssertEqual(Self.systemModalProbeSlice(budget: 4, deadlineRemaining: 20), 4) + // Nearly-spent plan deadline: the probe is clamped so it can't run past the shared budget. + XCTAssertEqual(Self.systemModalProbeSlice(budget: 4, deadlineRemaining: 1.5), 1.5) + // Exactly/already exhausted deadline: skip the probe entirely (0), never a negative timeout. + XCTAssertEqual(Self.systemModalProbeSlice(budget: 4, deadlineRemaining: 0), 0) + XCTAssertEqual(Self.systemModalProbeSlice(budget: 4, deadlineRemaining: -5), 0) + } #endif private func interactiveRootNode(rect: CGRect) -> SnapshotNode { @@ -610,6 +691,20 @@ extension RunnerTests { return abandonedTreeCaptureCount > 0 } + /// The watchdog abandoned one unit of XCTest main-thread capture work that is still draining on + /// main; XCTest-backed snapshot tiers skip (`hasAbandonedTreeCapture`) until a matching release. + func retainAbandonedXCTestChannelWork() { + treeCaptureLock.lock() + abandonedTreeCaptureCount += 1 + treeCaptureLock.unlock() + } + + func releaseAbandonedXCTestChannelWork() { + treeCaptureLock.lock() + abandonedTreeCaptureCount -= 1 + treeCaptureLock.unlock() + } + /// Runs the blocking tree-snapshot XPC on the main thread bounded by `sliceSeconds`. On /// timeout the XPC keeps running on main (it cannot be cancelled); the capture is marked /// abandoned so plans avoid XCTest-backed tiers until it drains, the tree backend is penalized @@ -627,9 +722,7 @@ extension RunnerTests { timeout: sliceSeconds, timeoutError: treeCaptureTimeoutError(sliceSeconds: sliceSeconds), onAbandoned: { - self.treeCaptureLock.lock() - self.abandonedTreeCaptureCount += 1 - self.treeCaptureLock.unlock() + self.retainAbandonedXCTestChannelWork() NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_SLICE_TIMEOUT slice=%.1f", sliceSeconds) self.penalizeSnapshotXCTestChannel( bundleId: self.currentBundleId, @@ -637,9 +730,7 @@ extension RunnerTests { ) }, onDrained: { - self.treeCaptureLock.lock() - self.abandonedTreeCaptureCount -= 1 - self.treeCaptureLock.unlock() + self.releaseAbandonedXCTestChannelWork() NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_DRAINED") } ) { diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index d0d168441..d3a5a0ddc 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -185,12 +185,14 @@ extension RunnerTests { _ plan: [SnapshotBackendKind], app: XCUIApplication, options: SnapshotOptions, - terminal: SnapshotCaptureTerminalPolicy + terminal: SnapshotCaptureTerminalPolicy, + deadline: Date? = nil ) throws -> DataPayload { var best: (kind: SnapshotBackendKind, capture: SnapshotBackendCapture)? var firstFailure: (reason: String, code: String)? var axFailure: SnapshotCaptureFailure? - let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget) + // A caller may share the pre-plan system-modal probe's deadline; otherwise own the full budget (#1244). + let deadline = deadline ?? Date().addingTimeInterval(Self.snapshotPlanBudget) let suppressXCTestPenalty = consumeSnapshotXCTestPenaltyWarmupExemption() // Reorder is iOS-only because hostile screens can make XCTest tree/query work grind while diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift index b2dd8b2ec..45db60cbe 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift @@ -3,11 +3,11 @@ import XCTest extension RunnerTests { // MARK: - Blocking System Modal Snapshot - func blockingSystemAlertSnapshot() -> DataPayload? { + func blockingSystemAlertSnapshot(deadline: Date = .distantFuture) -> DataPayload? { #if os(macOS) return nil #else - guard let modal = firstBlockingSystemModal(in: springboard) else { + guard let modal = firstBlockingSystemModal(in: springboard, deadline: deadline) else { return nil } let actions = actionableElements(in: modal) @@ -61,7 +61,10 @@ extension RunnerTests { #endif } - func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? { + func firstBlockingSystemModal( + in springboard: XCUIApplication, + deadline: Date = .distantFuture + ) -> XCUIElement? { let disableSafeProbe = RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_SAFE_MODAL_PROBE") let queryElements: (() -> [XCUIElement]) -> [XCUIElement] = { fetch in if disableSafeProbe { @@ -79,6 +82,12 @@ extension RunnerTests { } } + // Don't start the second (sheet) enumeration once the shared probe deadline is spent (#1244). + if Date() >= deadline { + NSLog("AGENT_DEVICE_RUNNER_SYSTEM_MODAL_PROBE_DEADLINE stage=sheets") + return nil + } + let sheets = queryElements { springboard.sheets.allElementsBoundByIndex } diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index d1f6752ac..d4d3ce78b 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -89,6 +89,9 @@ final class RunnerTests: XCTestCase { let treeCaptureLock = NSLock() var abandonedTreeCaptureCount = 0 let treeCaptureSliceBudget: TimeInterval = 8 + // Bounds the pre-plan SpringBoard system-modal probe, which can otherwise grind for tens of + // seconds on remote-hosted consent dialogs and bypass the plan budget (#1244). + let systemModalProbeBudget: TimeInterval = 4 // Observability for the record(_:) suppression below: how many AX-broken-screen snapshot // issues this session muted, so wedge investigations see the volume without grepping logs. let suppressedIssueLock = NSLock() From 1b58d52230c459913bbb9689b8134c4821e207b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 19:53:21 +0200 Subject: [PATCH 2/6] fix: don't re-enter main for bookkeeping behind an abandoned snapshot probe (#1244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the bounded system-modal probe times out, runMainThreadWork abandons its query but that query keeps grinding on the main thread. The capture plan recovers through the independent private-AX backend and returns — but executeSnapshotDispatched then ran its didRecordXCTestFailure/retry 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. Skip that bookkeeping while abandoned XCTest work is outstanding — the policy setNeedsPostSnapshotInteractionDelay already uses — so the recovered response returns immediately and a subsequent command still reports RUNNER_BUSY until the work drains. Adds an in-bundle regression that fails if the guard is removed. The snapshot recovery loop is factored into executeDispatchedWithRecovery so the guard is exercisable without a live capture. The alert command's SpringBoard detection is intentionally left on its existing 30s command watchdog here; giving alert its own physical-iOS capability is tracked as a focused follow-up (#1231). --- .../RunnerTests+CommandExecution.swift | 23 ++++++- .../RunnerTests+Snapshot.swift | 64 +++++++++++++++---- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index c9852205d..9c77235e5 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -836,6 +836,18 @@ extension RunnerTests { } private func executeSnapshotDispatched(command: Command) throws -> Response { + try executeDispatchedWithRecovery(command: command) { + try self.executeSnapshotDispatchedOnce(command: command) + } + } + + /// The dispatched snapshot recovery loop: read-only retry + XCTest-recorded-failure invalidation, + /// matching what `executeOnMainSafely` gives the generic path. `perform` runs the capture and its + /// own bounded main-thread work. + func executeDispatchedWithRecovery( + command: Command, + perform: () throws -> Response + ) throws -> Response { var hasRetried = false while true { let failureCountBefore = try runMainThreadWork( @@ -845,7 +857,16 @@ extension RunnerTests { ) { self.currentXCTestFailureCount() } - let response = try executeSnapshotDispatchedOnce(command: command) + let response = try perform() + // Recovered independently — re-entering main for bookkeeping would queue behind the still- + // abandoned XCTest query and re-stall the command (#1244), so skip it until that work drains. + if hasAbandonedTreeCapture() { + NSLog( + "AGENT_DEVICE_RUNNER_DISPATCH_RECOVERY_SKIPPED_XCTEST_OCCUPIED command=%@", + command.command.rawValue + ) + return response + } let recordedFailureResponse = try runMainThreadWork( command: command, timeout: mainThreadExecutionTimeout, diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index 1ba7ebc19..bad202084 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -281,7 +281,13 @@ extension RunnerTests { return try runMainThreadWork( command: nil, timeout: slice, - timeoutError: systemModalProbeTimeoutError(slice: slice), + timeoutError: { + SnapshotCaptureFailure( + code: Self.xCTestSnapshotTimeoutCode, + message: "the system-modal probe exceeded its \(slice)s time slice", + hint: "The capture plan recovers through non-XCTest snapshot tiers while the modal probe drains." + ) + }, onAbandoned: { self.retainAbandonedXCTestChannelWork() NSLog("AGENT_DEVICE_RUNNER_SYSTEM_MODAL_PROBE_TIMEOUT slice=%.1f", slice) @@ -318,16 +324,6 @@ extension RunnerTests { return min(budget, deadlineRemaining) } - private func systemModalProbeTimeoutError(slice: TimeInterval) -> () -> Error { - { - SnapshotCaptureFailure( - code: Self.xCTestSnapshotTimeoutCode, - message: "the system-modal probe exceeded its \(slice)s time slice", - hint: "The capture plan recovers through non-XCTest snapshot tiers while the modal probe drains." - ) - } - } - func rawTreeSnapshotPayload( context: SnapshotTraversalContext, options: SnapshotOptions @@ -558,6 +554,52 @@ extension RunnerTests { XCTAssertEqual(Self.systemModalProbeSlice(budget: 4, deadlineRemaining: 0), 0) XCTAssertEqual(Self.systemModalProbeSlice(budget: 4, deadlineRemaining: -5), 0) } + + func testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied() { + // The #1244 recovery shape: the modal probe abandoned an XCTest query that is still grinding on + // main, the capture recovered independently, and its response is ready. The recovery loop must + // return it without re-entering the main queue for recorded-failure/retry bookkeeping (that hop + // would block behind the abandoned query and re-stall the command), and a later command must + // still see the runner busy until the abandoned work drains. Removing the guard regresses this. + let command = try! JSONDecoder().decode( + Command.self, + from: Data(#"{"command":"snapshot","commandId":"recovery-guard"}"#.utf8) + ) + let recovered = Response(ok: false, error: ErrorPayload(message: "target is not available")) + + setAbandonedXCTestWork(1) + defer { setAbandonedXCTestWork(0) } + guard case .busy = currentMainThreadBusyState() else { + return XCTFail("expected RUNNER_BUSY while abandoned XCTest work is outstanding") + } + + var occupiedCalls = 0 + let occupied = try! executeDispatchedWithRecovery(command: command) { + occupiedCalls += 1 + return recovered + } + XCTAssertEqual(occupiedCalls, 1, "recovered response must not retry behind abandoned XCTest work") + XCTAssertEqual(occupied.ok, false) + + setAbandonedXCTestWork(0) + guard case .idle = currentMainThreadBusyState() else { + return XCTFail("runner should be idle once the abandoned work drained") + } + var drainedCalls = 0 + _ = try! executeDispatchedWithRecovery(command: command) { + drainedCalls += 1 + return recovered + } + XCTAssertEqual(drainedCalls, 2, "with the channel free the read-only retry runs once") + } + + private func setAbandonedXCTestWork(_ count: Int) { + treeCaptureLock.lock(); abandonedTreeCaptureCount = count; treeCaptureLock.unlock() + mainThreadWorkLock.lock() + abandonedMainThreadWorkCount = count + abandonedMainThreadWorkSince = count > 0 ? Date(timeIntervalSinceNow: -1) : nil + mainThreadWorkLock.unlock() + } #endif private func interactiveRootNode(rect: CGRect) -> SnapshotNode { From 4ef0e417868e3443670e09844b631a59d932c01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 20:51:31 +0200 Subject: [PATCH 3/6] test: force the bounded system-modal probe timeout through snapshotFast Adds a minimal probeWork seam to boundedBlockingSystemAlertSnapshot (threaded through snapshotFast/snapshotRaw, defaulting to today's exact production closure) so a test can substitute a blocking probe body while still running the real runMainThreadWork wrap and the real onAbandoned/onDrained hooks. The new regression test drives snapshotFast (the real entry point, not boundedBlockingSystemAlertSnapshot directly) with an injected probeWork that blocks past the probe's slice, forcing a genuine timeout, and asserts in order: busy/penalty accounting once the timeout fires, a recovered payload returned while the probe is still abandoned (before drain), and release (hasAbandonedTreeCapture() false / idle) once the probe drains. Verified revert-sensitive: bypassing the runMainThreadWork wrap, or dropping the onAbandoned/onDrained hooks, each turn this test red. --- .../RunnerTests+Snapshot.swift | 108 +++++++++++++++++- 1 file changed, 102 insertions(+), 6 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index bad202084..1be00ce6a 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -101,9 +101,13 @@ extension RunnerTests { static let flatInteractiveFallbackBudget: TimeInterval = 1.0 - func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload { + func snapshotFast( + app: XCUIApplication, + options: SnapshotOptions, + probeWork: (() -> DataPayload?)? = nil + ) throws -> DataPayload { let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget) - if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline) { + if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline, probeWork: probeWork) { return blocking } return try runSnapshotCapturePlan( @@ -247,9 +251,13 @@ extension RunnerTests { ) } - func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload { + func snapshotRaw( + app: XCUIApplication, + options: SnapshotOptions, + probeWork: (() -> DataPayload?)? = nil + ) throws -> DataPayload { let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget) - if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline) { + if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline, probeWork: probeWork) { return blocking } return try runSnapshotCapturePlan( @@ -263,7 +271,17 @@ extension RunnerTests { /// Runs the pre-plan SpringBoard system-modal probe as a bounded capture tier sharing the plan /// deadline, so a slow alert enumeration cannot bypass the snapshot timeout and stall (#1244). - func boundedBlockingSystemAlertSnapshot(deadline: Date) -> DataPayload? { + /// + /// `probeWork` exists only as a unit-test seam: production callers (`snapshotFast`/ + /// `snapshotRaw`) always leave it `nil`, in which case the default below reproduces today's + /// exact production body (`self.blockingSystemAlertSnapshot(deadline: probeDeadline)`) run + /// through the real bounding/hook machinery. Tests substitute a slow/blocking closure to force + /// a real timeout without a live SpringBoard alert, while still exercising the real + /// `runMainThreadWork` wrap and the real `onAbandoned`/`onDrained` hooks below. + func boundedBlockingSystemAlertSnapshot( + deadline: Date, + probeWork: (() -> DataPayload?)? = nil + ) -> DataPayload? { #if os(macOS) return nil #else @@ -277,6 +295,7 @@ extension RunnerTests { } let probeDeadline = Date().addingTimeInterval(slice) let startedAt = Date() + let work = probeWork ?? { self.blockingSystemAlertSnapshot(deadline: probeDeadline) } do { return try runMainThreadWork( command: nil, @@ -301,7 +320,7 @@ extension RunnerTests { NSLog("AGENT_DEVICE_RUNNER_SYSTEM_MODAL_PROBE_DRAINED") } ) { - self.blockingSystemAlertSnapshot(deadline: probeDeadline) + work() } } catch { NSLog( @@ -555,6 +574,83 @@ extension RunnerTests { XCTAssertEqual(Self.systemModalProbeSlice(budget: 4, deadlineRemaining: -5), 0) } + /// Regression for #1244/#1248: drives the bounded system-modal probe through `snapshotFast` — + /// the real command entry point, not `boundedBlockingSystemAlertSnapshot` directly — with an + /// injected `probeWork` that blocks past the probe's real slice, forcing a real + /// `runMainThreadWork` timeout. This is revert-sensitive on both halves of the fix: + /// - if `snapshotFast` reverted to calling the unbounded `blockingSystemAlertSnapshot` + /// directly (or dropped the `runMainThreadWork` wrap), nothing here would ever time out, + /// so the mid-flight busy/penalty assertions below would never be met; + /// - if the `onAbandoned`/`onDrained` retain/release hooks were dropped, the timeout would + /// still fire, but the busy/penalty accounting and the drain assertion would not hold. + func testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain() { + currentApp = springboard + currentBundleId = Self.springboardBundleId + defer { + currentApp = nil + currentBundleId = nil + clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") + } + + final class ResultBox { + var payload: DataPayload? + var wasBusyBeforeDrain = false + var hadAbandonedCaptureBeforeDrain = false + var wasPenalizedBeforeDrain = false + } + let box = ResultBox() + // Bounded so a revert that never actually times out (nothing would ever wait on this gate) + // cannot hang the test -- it just leaves `box` at its false/nil defaults, below. + let probeReleaseGate = DispatchSemaphore(value: 0) + let probeWork: () -> DataPayload? = { + _ = probeReleaseGate.wait(timeout: .now() + 8) + return nil + } + + let completion = expectation( + description: "snapshotFast recovered while the probe was abandoned, then released it" + ) + DispatchQueue(label: "agent-device.runner.tests.modal-probe-timeout").async { + box.payload = try? self.snapshotFast( + app: self.springboard, + options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false), + probeWork: probeWork + ) + + // 1) Penalty/busy accounting: must already be in place by the time snapshotFast returns, + // well before we release the still-blocked probe below. + if case .busy = self.currentMainThreadBusyState() { + box.wasBusyBeforeDrain = true + } + box.hadAbandonedCaptureBeforeDrain = self.hasAbandonedTreeCapture() + box.wasPenalizedBeforeDrain = self.isSnapshotXCTestChannelPenalized(bundleId: self.currentBundleId) + + // 2) `box.payload` above was already produced -- through the capture plan's recovery + // tiers -- while the probe is still blocked on `probeReleaseGate`, i.e. recovered before + // drain, not queued behind it. + + // 3) Only now let the abandoned probe finish and drain. + probeReleaseGate.signal() + completion.fulfill() + } + + wait(for: [completion], timeout: 15) + + // 1) Penalty/busy accounting. + XCTAssertTrue(box.wasBusyBeforeDrain, "expected RUNNER_BUSY while the modal probe timeout is outstanding") + XCTAssertTrue(box.hadAbandonedCaptureBeforeDrain, "onAbandoned must retain the abandoned XCTest channel work") + XCTAssertTrue(box.wasPenalizedBeforeDrain, "a timed-out modal probe must penalize the XCTest snapshot channel") + + // 2) Recovered response before drain. + XCTAssertNotNil(box.payload, "snapshotFast must recover a payload through the capture plan while the probe drains") + + // 3) Release after drain. + guard case .idle = currentMainThreadBusyState() else { + return XCTFail("expected the runner to be idle once the abandoned probe drained") + } + XCTAssertFalse(hasAbandonedTreeCapture(), "onDrained must release the abandoned XCTest channel work") + } + func testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied() { // The #1244 recovery shape: the modal probe abandoned an XCTest query that is still grinding on // main, the capture recovered independently, and its response is ready. The recovery loop must From 43d6a8bfb05185153545f4955d51e0b82a40f096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 20:55:05 +0200 Subject: [PATCH 4/6] ci(ios): run the #1244 system-modal probe regressions so a wrapper/hook revert is caught --- .github/workflows/ios.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 851fd81b6..c6d422427 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -66,7 +66,10 @@ jobs: -destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSinglePointerFlingFallsBackToXCTestCoordinateDragWhenPrivateSynthesisFails \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testMissingBundleCommandInvalidatesCompleteCachedTargetState \ - -only-testing:AgentDeviceRunnerUITests/RunnerTests/testCachedTargetInvalidationClearsProcessBoundState + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testCachedTargetInvalidationClearsProcessBoundState \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain - name: Prepare iOS runner run: | From 09367220f648700ae117e928baa34e291f0871ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 21:56:41 +0200 Subject: [PATCH 5/6] fix: gate the #1244 probe-timeout test seam behind the unit-test flag Addresses PR #1248 re-review blockers: 1. `probeWork` was a test-only DI parameter on the production signatures of `snapshotFast`, `snapshotRaw`, and `boundedBlockingSystemAlertSnapshot`. Gate it behind `#if AGENT_DEVICE_RUNNER_UNIT_TESTS` (the same active compilation condition the build script already sets via AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS, and the same one this file already uses to gate its unit-test methods): production callers now compile the original, unparameterized signatures byte-identical to pre-`probeWork`. Both overloads of `boundedBlockingSystemAlertSnapshot` delegate to a new private `boundedBlockingSystemAlertSnapshotBody`, which is the only place the real `runMainThreadWork` wrap and the real `onAbandoned`/`onDrained` hooks are defined, so a revert there fails through both the production and the test-seam overload. 2. The regression test previously asserted `.idle`/`hasAbandonedTreeCapture() == false` right after signaling the probe's release semaphore, racing the drain instead of synchronizing on it. It now polls `hasAbandonedTreeCapture()` (bounded) on the same background queue after signaling release, fulfilling a dedicated `drained` expectation that the test `wait(for:timeout:)`s on before the release assertions, so a slow or missing drain fails deterministically instead of racing. 3. The regression only drove `snapshotFast`, leaving a `snapshotRaw`-only wrapper regression uncaught. The test body is now a private helper parameterized over the entry point, called once for `snapshotFast` (existing test, unchanged name) and once for `snapshotRaw` (new `...ForSnapshotRaw` test), keeping ios.yml's `-only-testing` list in sync. Verified revert-sensitive for both entry points: temporarily bypassing the bounding wrap in `snapshotFast` or `snapshotRaw` turns each entry point's own test red (and only that one); dropping the onAbandoned/onDrained hooks in the shared body turns both tests red. Restoring the code turns all green again. --- .github/workflows/ios.yml | 3 +- .../RunnerTests+Snapshot.swift | 169 ++++++++++++++---- 2 files changed, 141 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index c6d422427..d6e160f5d 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -69,7 +69,8 @@ jobs: -only-testing:AgentDeviceRunnerUITests/RunnerTests/testCachedTargetInvalidationClearsProcessBoundState \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \ - -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrainForSnapshotRaw - name: Prepare iOS runner run: | diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index 1be00ce6a..f772f182e 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -101,6 +101,10 @@ extension RunnerTests { static let flatInteractiveFallbackBudget: TimeInterval = 1.0 + #if AGENT_DEVICE_RUNNER_UNIT_TESTS + // `probeWork` is a unit-test-only seam (see `boundedBlockingSystemAlertSnapshot` below): it + // exists only under `AGENT_DEVICE_RUNNER_UNIT_TESTS` so a test can substitute the probe body + // while still calling this exact entry point. Production never compiles this overload. func snapshotFast( app: XCUIApplication, options: SnapshotOptions, @@ -118,6 +122,21 @@ extension RunnerTests { deadline: deadline ) } + #else + func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload { + let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget) + if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline) { + return blocking + } + return try runSnapshotCapturePlan( + Self.regularVisiblePlan, + app: app, + options: options, + terminal: .sparseWithFatalOnAXFailure, + deadline: deadline + ) + } + #endif func recursiveTreeSnapshotPayload( context: SnapshotTraversalContext, @@ -251,6 +270,9 @@ extension RunnerTests { ) } + #if AGENT_DEVICE_RUNNER_UNIT_TESTS + // See the `snapshotFast` unit-test seam above: `probeWork` only exists in this + // `AGENT_DEVICE_RUNNER_UNIT_TESTS` build. func snapshotRaw( app: XCUIApplication, options: SnapshotOptions, @@ -268,19 +290,58 @@ extension RunnerTests { deadline: deadline ) } + #else + func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload { + let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget) + if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline) { + return blocking + } + return try runSnapshotCapturePlan( + Self.rawDiagnosticPlan, + app: app, + options: options, + terminal: .throwOnAXFailure, + deadline: deadline + ) + } + #endif + #if AGENT_DEVICE_RUNNER_UNIT_TESTS /// Runs the pre-plan SpringBoard system-modal probe as a bounded capture tier sharing the plan /// deadline, so a slow alert enumeration cannot bypass the snapshot timeout and stall (#1244). /// - /// `probeWork` exists only as a unit-test seam: production callers (`snapshotFast`/ - /// `snapshotRaw`) always leave it `nil`, in which case the default below reproduces today's - /// exact production body (`self.blockingSystemAlertSnapshot(deadline: probeDeadline)`) run - /// through the real bounding/hook machinery. Tests substitute a slow/blocking closure to force - /// a real timeout without a live SpringBoard alert, while still exercising the real - /// `runMainThreadWork` wrap and the real `onAbandoned`/`onDrained` hooks below. + /// `probeWork` exists only as a unit-test seam under `AGENT_DEVICE_RUNNER_UNIT_TESTS`: + /// production (the `#else` overload below) never compiles this parameter. When a test passes + /// `probeWork`, it substitutes a slow/blocking closure to force a real timeout without a live + /// SpringBoard alert, while `boundedBlockingSystemAlertSnapshotBody` still runs the real + /// `runMainThreadWork` wrap and the real `onAbandoned`/`onDrained` hooks — the shared body below + /// is the only place that machinery is defined, so a revert there fails both builds. func boundedBlockingSystemAlertSnapshot( deadline: Date, probeWork: (() -> DataPayload?)? = nil + ) -> DataPayload? { + boundedBlockingSystemAlertSnapshotBody(deadline: deadline) { probeDeadline in + probeWork?() ?? self.blockingSystemAlertSnapshot(deadline: probeDeadline) + } + } + #else + /// Runs the pre-plan SpringBoard system-modal probe as a bounded capture tier sharing the plan + /// deadline, so a slow alert enumeration cannot bypass the snapshot timeout and stall (#1244). + func boundedBlockingSystemAlertSnapshot(deadline: Date) -> DataPayload? { + boundedBlockingSystemAlertSnapshotBody(deadline: deadline) { probeDeadline in + self.blockingSystemAlertSnapshot(deadline: probeDeadline) + } + } + #endif + + /// The real bounding/hook machinery shared by both `boundedBlockingSystemAlertSnapshot` + /// overloads above: the production one (always `self.blockingSystemAlertSnapshot`) and the + /// unit-test one (an injectable `probeWork`). Keeping this in one place means the + /// `runMainThreadWork` wrap and the `onAbandoned`/`onDrained` hooks can never drift between what + /// production runs and what the unit tests exercise. + private func boundedBlockingSystemAlertSnapshotBody( + deadline: Date, + probe: @escaping (Date) -> DataPayload? ) -> DataPayload? { #if os(macOS) return nil @@ -295,7 +356,6 @@ extension RunnerTests { } let probeDeadline = Date().addingTimeInterval(slice) let startedAt = Date() - let work = probeWork ?? { self.blockingSystemAlertSnapshot(deadline: probeDeadline) } do { return try runMainThreadWork( command: nil, @@ -320,7 +380,7 @@ extension RunnerTests { NSLog("AGENT_DEVICE_RUNNER_SYSTEM_MODAL_PROBE_DRAINED") } ) { - work() + probe(probeDeadline) } } catch { NSLog( @@ -574,16 +634,26 @@ extension RunnerTests { XCTAssertEqual(Self.systemModalProbeSlice(budget: 4, deadlineRemaining: -5), 0) } - /// Regression for #1244/#1248: drives the bounded system-modal probe through `snapshotFast` — - /// the real command entry point, not `boundedBlockingSystemAlertSnapshot` directly — with an - /// injected `probeWork` that blocks past the probe's real slice, forcing a real - /// `runMainThreadWork` timeout. This is revert-sensitive on both halves of the fix: - /// - if `snapshotFast` reverted to calling the unbounded `blockingSystemAlertSnapshot` + /// Regression for #1244/#1248: drives the bounded system-modal probe through a real command + /// entry point (`snapshotFast` or `snapshotRaw` -- see the two test methods below), not + /// `boundedBlockingSystemAlertSnapshot` directly, with an injected `probeWork` that blocks past + /// the probe's real slice, forcing a real `runMainThreadWork` timeout. This is revert-sensitive + /// on both halves of the fix, for either entry point: + /// - if the entry point reverted to calling the unbounded `blockingSystemAlertSnapshot` /// directly (or dropped the `runMainThreadWork` wrap), nothing here would ever time out, /// so the mid-flight busy/penalty assertions below would never be met; /// - if the `onAbandoned`/`onDrained` retain/release hooks were dropped, the timeout would /// still fire, but the busy/penalty accounting and the drain assertion would not hold. - func testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain() { + /// + /// The drain assertion is synchronized on the *real* release rather than raced: after + /// signaling the probe to finish, the background queue polls `hasAbandonedTreeCapture()` + /// (bounded) and only then fulfills `drained`, which the test `wait(for:timeout:)`s on before + /// asserting `.idle`/`hasAbandonedTreeCapture() == false` below -- so a slow drain fails that + /// assertion instead of racing a fixed-timing guess. + private func assertBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain( + entryPointName: String, + callEntryPoint: @escaping (SnapshotOptions, (() -> DataPayload?)?) throws -> DataPayload + ) { currentApp = springboard currentBundleId = Self.springboardBundleId defer { @@ -608,17 +678,17 @@ extension RunnerTests { } let completion = expectation( - description: "snapshotFast recovered while the probe was abandoned, then released it" + description: "\(entryPointName) recovered while the probe was abandoned, then released it" ) + let drained = expectation(description: "\(entryPointName) modal probe drained") DispatchQueue(label: "agent-device.runner.tests.modal-probe-timeout").async { - box.payload = try? self.snapshotFast( - app: self.springboard, - options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false), - probeWork: probeWork + box.payload = try? callEntryPoint( + SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false), + probeWork ) - // 1) Penalty/busy accounting: must already be in place by the time snapshotFast returns, - // well before we release the still-blocked probe below. + // 1) Penalty/busy accounting: must already be in place by the time the entry point + // returns, well before we release the still-blocked probe below. if case .busy = self.currentMainThreadBusyState() { box.wasBusyBeforeDrain = true } @@ -628,27 +698,66 @@ extension RunnerTests { // 2) `box.payload` above was already produced -- through the capture plan's recovery // tiers -- while the probe is still blocked on `probeReleaseGate`, i.e. recovered before // drain, not queued behind it. + completion.fulfill() - // 3) Only now let the abandoned probe finish and drain. + // 3) Only now let the abandoned probe finish, then block this queue (never the test's + // main-thread wait) on the *real* drain signal -- `onDrained`'s + // `releaseAbandonedXCTestChannelWork` -- bounded so a revert that never drains fulfills + // `drained` anyway and lets the assertions below report the regression explicitly instead + // of just timing out. probeReleaseGate.signal() - completion.fulfill() + let drainDeadline = Date().addingTimeInterval(5) + while self.hasAbandonedTreeCapture(), Date() < drainDeadline { + self.sleepFor(0.002) + } + drained.fulfill() } wait(for: [completion], timeout: 15) // 1) Penalty/busy accounting. - XCTAssertTrue(box.wasBusyBeforeDrain, "expected RUNNER_BUSY while the modal probe timeout is outstanding") - XCTAssertTrue(box.hadAbandonedCaptureBeforeDrain, "onAbandoned must retain the abandoned XCTest channel work") - XCTAssertTrue(box.wasPenalizedBeforeDrain, "a timed-out modal probe must penalize the XCTest snapshot channel") + XCTAssertTrue( + box.wasBusyBeforeDrain, + "expected RUNNER_BUSY while the \(entryPointName) modal probe timeout is outstanding" + ) + XCTAssertTrue( + box.hadAbandonedCaptureBeforeDrain, + "onAbandoned must retain the abandoned XCTest channel work for \(entryPointName)" + ) + XCTAssertTrue( + box.wasPenalizedBeforeDrain, + "a timed-out modal probe must penalize the XCTest snapshot channel for \(entryPointName)" + ) // 2) Recovered response before drain. - XCTAssertNotNil(box.payload, "snapshotFast must recover a payload through the capture plan while the probe drains") + XCTAssertNotNil( + box.payload, + "\(entryPointName) must recover a payload through the capture plan while the probe drains" + ) - // 3) Release after drain. + // 3) Bounded, deterministic drain barrier, then release assertions. + wait(for: [drained], timeout: 6) guard case .idle = currentMainThreadBusyState() else { - return XCTFail("expected the runner to be idle once the abandoned probe drained") + return XCTFail("expected the runner to be idle once the abandoned \(entryPointName) probe drained") + } + XCTAssertFalse( + hasAbandonedTreeCapture(), + "onDrained must release the abandoned XCTest channel work for \(entryPointName)" + ) + } + + func testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain() { + assertBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain(entryPointName: "snapshotFast") { + options, probeWork in + try self.snapshotFast(app: self.springboard, options: options, probeWork: probeWork) + } + } + + func testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrainForSnapshotRaw() { + assertBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain(entryPointName: "snapshotRaw") { + options, probeWork in + try self.snapshotRaw(app: self.springboard, options: options, probeWork: probeWork) } - XCTAssertFalse(hasAbandonedTreeCapture(), "onDrained must release the abandoned XCTest channel work") } func testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied() { From 47c3e58092f2d4f662b0d9ffe00a9402cb10679b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 08:29:19 +0200 Subject: [PATCH 6/6] fix: collapse snapshotFast/snapshotRaw to one production entry point Reviewer blocker on #1248: the previous #if/#else split compiled a unit-test overload with a probeWork parameter that the regression tests called, while shipping builds compiled a separate #else implementation that no test ever exercised. Reverting the shipped snapshotFast/snapshotRaw/ boundedBlockingSystemAlertSnapshot to bypass the bounded probe would have left the tests green. Collapse each command to a single, always-compiled production implementation (no probeWork parameter anywhere), and move the only injectable seam to a tiny systemModalProbeOverrideForTesting property (stored on RunnerTests since extensions can't hold stored properties) consulted from inside boundedBlockingSystemAlertSnapshot's probe closure. Tests now call the real snapshotFast/snapshotRaw entry points and set the override instead of passing probeWork. --- .../RunnerTests+Snapshot.swift | 115 +++++------------- .../RunnerTests.swift | 8 ++ 2 files changed, 39 insertions(+), 84 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index f772f182e..83b8163f6 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -101,28 +101,11 @@ extension RunnerTests { static let flatInteractiveFallbackBudget: TimeInterval = 1.0 - #if AGENT_DEVICE_RUNNER_UNIT_TESTS - // `probeWork` is a unit-test-only seam (see `boundedBlockingSystemAlertSnapshot` below): it - // exists only under `AGENT_DEVICE_RUNNER_UNIT_TESTS` so a test can substitute the probe body - // while still calling this exact entry point. Production never compiles this overload. - func snapshotFast( - app: XCUIApplication, - options: SnapshotOptions, - probeWork: (() -> DataPayload?)? = nil - ) throws -> DataPayload { - let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget) - if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline, probeWork: probeWork) { - return blocking - } - return try runSnapshotCapturePlan( - Self.regularVisiblePlan, - app: app, - options: options, - terminal: .sparseWithFatalOnAXFailure, - deadline: deadline - ) - } - #else + // The single production entry point -- always compiled, no unit-test overload. A unit test + // exercises this exact function; the only injectable seam lives inside + // `boundedBlockingSystemAlertSnapshot`'s probe closure (see `systemModalProbeOverrideForTesting` + // in RunnerTests.swift), so reverting this entry point to bypass the bounded probe fails the + // regression test. func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload { let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget) if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline) { @@ -136,7 +119,6 @@ extension RunnerTests { deadline: deadline ) } - #endif func recursiveTreeSnapshotPayload( context: SnapshotTraversalContext, @@ -270,27 +252,7 @@ extension RunnerTests { ) } - #if AGENT_DEVICE_RUNNER_UNIT_TESTS - // See the `snapshotFast` unit-test seam above: `probeWork` only exists in this - // `AGENT_DEVICE_RUNNER_UNIT_TESTS` build. - func snapshotRaw( - app: XCUIApplication, - options: SnapshotOptions, - probeWork: (() -> DataPayload?)? = nil - ) throws -> DataPayload { - let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget) - if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline, probeWork: probeWork) { - return blocking - } - return try runSnapshotCapturePlan( - Self.rawDiagnosticPlan, - app: app, - options: options, - terminal: .throwOnAXFailure, - deadline: deadline - ) - } - #else + // See `snapshotFast` above: the single production entry point, no unit-test overload. func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload { let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget) if let blocking = boundedBlockingSystemAlertSnapshot(deadline: deadline) { @@ -304,41 +266,25 @@ extension RunnerTests { deadline: deadline ) } - #endif - #if AGENT_DEVICE_RUNNER_UNIT_TESTS - /// Runs the pre-plan SpringBoard system-modal probe as a bounded capture tier sharing the plan - /// deadline, so a slow alert enumeration cannot bypass the snapshot timeout and stall (#1244). - /// - /// `probeWork` exists only as a unit-test seam under `AGENT_DEVICE_RUNNER_UNIT_TESTS`: - /// production (the `#else` overload below) never compiles this parameter. When a test passes - /// `probeWork`, it substitutes a slow/blocking closure to force a real timeout without a live - /// SpringBoard alert, while `boundedBlockingSystemAlertSnapshotBody` still runs the real - /// `runMainThreadWork` wrap and the real `onAbandoned`/`onDrained` hooks — the shared body below - /// is the only place that machinery is defined, so a revert there fails both builds. - func boundedBlockingSystemAlertSnapshot( - deadline: Date, - probeWork: (() -> DataPayload?)? = nil - ) -> DataPayload? { - boundedBlockingSystemAlertSnapshotBody(deadline: deadline) { probeDeadline in - probeWork?() ?? self.blockingSystemAlertSnapshot(deadline: probeDeadline) - } - } - #else /// Runs the pre-plan SpringBoard system-modal probe as a bounded capture tier sharing the plan /// deadline, so a slow alert enumeration cannot bypass the snapshot timeout and stall (#1244). func boundedBlockingSystemAlertSnapshot(deadline: Date) -> DataPayload? { boundedBlockingSystemAlertSnapshotBody(deadline: deadline) { probeDeadline in - self.blockingSystemAlertSnapshot(deadline: probeDeadline) + #if AGENT_DEVICE_RUNNER_UNIT_TESTS + if let override = self.systemModalProbeOverrideForTesting { + return override(probeDeadline) + } + #endif + return self.blockingSystemAlertSnapshot(deadline: probeDeadline) } } - #endif - /// The real bounding/hook machinery shared by both `boundedBlockingSystemAlertSnapshot` - /// overloads above: the production one (always `self.blockingSystemAlertSnapshot`) and the - /// unit-test one (an injectable `probeWork`). Keeping this in one place means the - /// `runMainThreadWork` wrap and the `onAbandoned`/`onDrained` hooks can never drift between what - /// production runs and what the unit tests exercise. + /// The real bounding/hook machinery used by `boundedBlockingSystemAlertSnapshot` above: the + /// probe closure it's given always calls `self.blockingSystemAlertSnapshot` in production, and + /// in unit-test builds may first consult `systemModalProbeOverrideForTesting`. Keeping this in + /// one place means the `runMainThreadWork` wrap and the `onAbandoned`/`onDrained` hooks can + /// never drift between what production runs and what the unit tests exercise. private func boundedBlockingSystemAlertSnapshotBody( deadline: Date, probe: @escaping (Date) -> DataPayload? @@ -634,11 +580,12 @@ extension RunnerTests { XCTAssertEqual(Self.systemModalProbeSlice(budget: 4, deadlineRemaining: -5), 0) } - /// Regression for #1244/#1248: drives the bounded system-modal probe through a real command - /// entry point (`snapshotFast` or `snapshotRaw` -- see the two test methods below), not - /// `boundedBlockingSystemAlertSnapshot` directly, with an injected `probeWork` that blocks past - /// the probe's real slice, forcing a real `runMainThreadWork` timeout. This is revert-sensitive - /// on both halves of the fix, for either entry point: + /// Regression for #1244/#1248: drives the bounded system-modal probe through a real, + /// production-only command entry point (`snapshotFast` or `snapshotRaw` -- see the two test + /// methods below), not `boundedBlockingSystemAlertSnapshot` directly, with + /// `systemModalProbeOverrideForTesting` set to a closure that blocks past the probe's real + /// slice, forcing a real `runMainThreadWork` timeout. This is revert-sensitive on both halves + /// of the fix, for either entry point: /// - if the entry point reverted to calling the unbounded `blockingSystemAlertSnapshot` /// directly (or dropped the `runMainThreadWork` wrap), nothing here would ever time out, /// so the mid-flight busy/penalty assertions below would never be met; @@ -652,13 +599,14 @@ extension RunnerTests { /// assertion instead of racing a fixed-timing guess. private func assertBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain( entryPointName: String, - callEntryPoint: @escaping (SnapshotOptions, (() -> DataPayload?)?) throws -> DataPayload + callEntryPoint: @escaping (SnapshotOptions) throws -> DataPayload ) { currentApp = springboard currentBundleId = Self.springboardBundleId defer { currentApp = nil currentBundleId = nil + systemModalProbeOverrideForTesting = nil clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") } @@ -672,7 +620,7 @@ extension RunnerTests { // Bounded so a revert that never actually times out (nothing would ever wait on this gate) // cannot hang the test -- it just leaves `box` at its false/nil defaults, below. let probeReleaseGate = DispatchSemaphore(value: 0) - let probeWork: () -> DataPayload? = { + systemModalProbeOverrideForTesting = { _ in _ = probeReleaseGate.wait(timeout: .now() + 8) return nil } @@ -683,8 +631,7 @@ extension RunnerTests { let drained = expectation(description: "\(entryPointName) modal probe drained") DispatchQueue(label: "agent-device.runner.tests.modal-probe-timeout").async { box.payload = try? callEntryPoint( - SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false), - probeWork + SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false) ) // 1) Penalty/busy accounting: must already be in place by the time the entry point @@ -748,15 +695,15 @@ extension RunnerTests { func testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain() { assertBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain(entryPointName: "snapshotFast") { - options, probeWork in - try self.snapshotFast(app: self.springboard, options: options, probeWork: probeWork) + options in + try self.snapshotFast(app: self.springboard, options: options) } } func testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrainForSnapshotRaw() { assertBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain(entryPointName: "snapshotRaw") { - options, probeWork in - try self.snapshotRaw(app: self.springboard, options: options, probeWork: probeWork) + options in + try self.snapshotRaw(app: self.springboard, options: options) } } diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index d4d3ce78b..6d82a301b 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -92,6 +92,14 @@ final class RunnerTests: XCTestCase { // Bounds the pre-plan SpringBoard system-modal probe, which can otherwise grind for tens of // seconds on remote-hosted consent dialogs and bypass the plan budget (#1244). let systemModalProbeBudget: TimeInterval = 4 + #if AGENT_DEVICE_RUNNER_UNIT_TESTS + // Unit-test-only injectable override for the system-modal probe (see + // `boundedBlockingSystemAlertSnapshot` in RunnerTests+Snapshot.swift): when set, a test's probe + // body runs in place of `blockingSystemAlertSnapshot` so it can force a real timeout without a + // 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?)? + #endif // Observability for the record(_:) suppression below: how many AX-broken-screen snapshot // issues this session muted, so wedge investigations see the volume without grepping logs. let suppressedIssueLock = NSLock()