From 303ddf3cc5ebe8c22b884fe58a91de0c03a0c49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Jul 2026 14:03:35 +0200 Subject: [PATCH 1/2] fix: keep XCTest tree snapshots on main --- .../RunnerTests+AXSnapshotFallback.swift | 31 +- .../RunnerTests+CommandExecution.swift | 393 +++++++++++++----- .../RunnerTests+Snapshot.swift | 102 ++--- .../RunnerTests+SnapshotCapturePlan.swift | 24 +- .../apple-runner-package-source.test.ts | 28 ++ 5 files changed, 415 insertions(+), 163 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift index 71f2f3a2d..4e27c37ce 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift @@ -57,14 +57,8 @@ extension RunnerTests { return nil } - // If the app frame is unavailable, the private root's own frame is the reliable screen - // rect here. Avoid public window queries: stale transient windows can record XCTest - // failures after the runner already returned a successful command response. - var viewport = safeSnapshotViewport(app: app) let rootFrame = privateAXRect(root["frame"]) - if viewport.isInfinite || viewport.isNull || viewport.isEmpty, !rootFrame.isEmpty { - viewport = rootFrame - } + let viewport = privateAXSnapshotViewport(app: app, rootFrame: rootFrame) var nodes: [SnapshotNode] = [] appendPrivateAXNode( root, @@ -95,6 +89,29 @@ extension RunnerTests { #endif } + private func privateAXSnapshotViewport(app: XCUIApplication, rootFrame: CGRect) -> CGRect { + let fallback = rootFrame.isEmpty ? CGRect.infinite : rootFrame + guard !hasAbandonedTreeCapture() else { + return fallback + } + do { + let viewport = try runMainThreadWork( + command: nil, + timeout: 1, + timeoutError: snapshotMainThreadTimeoutError("reading private AX viewport") + ) { + self.safeSnapshotViewport(app: app) + } + if viewport.isInfinite || viewport.isNull || viewport.isEmpty { + return fallback + } + return viewport + } catch { + NSLog("AGENT_DEVICE_RUNNER_PRIVATE_AX_VIEWPORT_FALLBACK=%@", String(describing: error)) + return fallback + } + } + private func appendPrivateAXNode( _ rawNode: [String: Any], to nodes: inout [SnapshotNode], diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 0771c52f3..9a5954504 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -213,6 +213,34 @@ extension RunnerTests { XCTAssertEqual(response.error?.code, "RUNNER_WEDGED") XCTAssertTrue(response.error?.hint?.contains("runner session will be restarted") == true) } + + func testRunMainThreadWorkExecutesOffMainCallerOnMainThread() { + final class ResultBox { + var observedMainThread: Bool? + var error: Error? + } + let box = ResultBox() + let finished = expectation(description: "off-main caller finished") + + DispatchQueue(label: "agent-device.runner.tests.off-main").async { + do { + box.observedMainThread = try self.runMainThreadWork( + command: nil, + timeout: 1, + timeoutError: self.mainThreadExecutionTimeoutError + ) { + Thread.isMainThread + } + } catch { + box.error = error + } + finished.fulfill() + } + + wait(for: [finished], timeout: 2) + XCTAssertNil(box.error) + XCTAssertEqual(box.observedMainThread, true) + } #endif func execute(command: Command) throws -> Response { @@ -272,6 +300,15 @@ extension RunnerTests { var abandoned = false } + struct ActiveCommandContext { + let app: XCUIApplication + } + + enum ActiveCommandPreparation { + case response(Response) + case context(ActiveCommandContext) + } + enum MainThreadBusyState { case idle case busy(abandonedForSeconds: TimeInterval) @@ -343,12 +380,35 @@ extension RunnerTests { if Thread.isMainThread { return try executeOnMainSafely(command: command) } - var result: Result? + if command.command == .snapshot { + return try executeSnapshotDispatched(command: command) + } + return try runMainThreadWork( + command: command, + timeout: mainThreadExecutionTimeout, + timeoutError: mainThreadExecutionTimeoutError + ) { + try self.executeOnMainSafely(command: command) + } + } + + func runMainThreadWork( + command: Command?, + timeout: TimeInterval, + timeoutError: @escaping () -> Error, + onAbandoned: (() -> Void)? = nil, + onDrained: (() -> Void)? = nil, + _ work: @escaping () throws -> T + ) throws -> T { + if Thread.isMainThread { + return try work() + } + var result: Result? let semaphore = DispatchSemaphore(value: 0) let workState = MainThreadWorkState() DispatchQueue.main.async { do { - result = .success(try self.executeOnMainSafely(command: command)) + result = .success(try work()) } catch { result = .failure(error) } @@ -359,15 +419,16 @@ extension RunnerTests { self.abandonedMainThreadWorkSince = nil NSLog("AGENT_DEVICE_RUNNER_ABANDONED_WORK_DRAINED") } + self.mainThreadWorkLock.unlock() + onDrained?() } else { workState.finished = true + self.mainThreadWorkLock.unlock() } - self.mainThreadWorkLock.unlock() semaphore.signal() } - let waitResult = semaphore.wait(timeout: .now() + mainThreadExecutionTimeout) + let waitResult = semaphore.wait(timeout: .now() + timeout) if waitResult == .timedOut { - // The main queue work may still be running; we stop waiting and report timeout. mainThreadWorkLock.lock() let stillRunning = !workState.finished if stillRunning { @@ -376,24 +437,14 @@ extension RunnerTests { if abandonedMainThreadWorkSince == nil { abandonedMainThreadWorkSince = Date() } + onAbandoned?() } mainThreadWorkLock.unlock() - if stillRunning && command.command == .snapshot { - // The next capture on this screen must not re-grind the tree backend. - penalizeSnapshotTreeBackend( - bundleId: command.appBundleId, - reason: "main_thread_watchdog" - ) - } - throw NSError( - domain: RunnerErrorDomain.general, - code: RunnerErrorCode.mainThreadExecutionTimedOut, - userInfo: [NSLocalizedDescriptionKey: "main thread execution timed out"] - ) + throw timeoutError() } switch result { - case .success(let response): - return response + case .success(let value): + return value case .failure(let error): throw error case .none: @@ -405,6 +456,14 @@ extension RunnerTests { } } + private func mainThreadExecutionTimeoutError() -> Error { + NSError( + domain: RunnerErrorDomain.general, + code: RunnerErrorCode.mainThreadExecutionTimedOut, + userInfo: [NSLocalizedDescriptionKey: "main thread execution timed out"] + ) + } + // MARK: - Command Handling private func executeOnMainSafely(command: Command) throws -> Response { @@ -468,68 +527,145 @@ extension RunnerTests { } } - private func executeOnMain(command: Command) throws -> Response { - var activeApp = currentApp ?? app - if shouldSkipAppActivationPreflight(command) { - activeApp = resolveAppWithoutActivation(command: command) - } else if !isRunnerLifecycleCommand(command.command) { - let normalizedBundleId = command.appBundleId? - .trimmingCharacters(in: .whitespacesAndNewlines) - let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId - if let bundleId = requestedBundleId { - if currentBundleId != bundleId || currentApp == nil { - _ = activateTarget(bundleId: bundleId, reason: "bundle_changed") + private func executeSnapshotDispatched(command: Command) throws -> Response { + var hasRetried = false + while true { + let failureCountBefore = try runMainThreadWork( + command: command, + timeout: mainThreadExecutionTimeout, + timeoutError: mainThreadExecutionTimeoutError + ) { + self.currentXCTestFailureCount() + } + let response = try executeSnapshotDispatchedOnce(command: command) + let recordedFailureResponse = try runMainThreadWork( + command: command, + timeout: mainThreadExecutionTimeout, + timeoutError: mainThreadExecutionTimeoutError + ) { + self.didRecordXCTestFailure(since: failureCountBefore) + ? self.xctestRecordedFailureResponse(command: command, response: response) + : nil + } + if let recordedFailureResponse { + try runMainThreadWork( + command: command, + timeout: mainThreadExecutionTimeout, + timeoutError: mainThreadExecutionTimeoutError + ) { + self.invalidateCachedTarget(reason: "xctest_recorded_failure") } - } else { - // Do not reuse stale bundle targets when the caller does not explicitly request one. - currentApp = nil - currentBundleId = nil + return recordedFailureResponse } - - activeApp = currentApp ?? app - if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) { - activeApp = activateTarget(bundleId: bundleId, reason: "stale_target") - } else if requestedBundleId == nil, targetNeedsActivation(activeApp) { - ensureRunnerHostAppActive(reason: "missing_app_bundle") - activeApp = app + if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) { + NSLog( + "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable", + command.command.rawValue + ) + hasRetried = true + try runMainThreadWork( + command: command, + timeout: mainThreadExecutionTimeout, + timeoutError: mainThreadExecutionTimeoutError + ) { + self.invalidateCachedTarget(reason: "response_unavailable") + self.sleepFor(self.retryCooldown) + } + continue } + return response + } + } - let skipExistenceWait = canUseFastForegroundAppGuard( - activeApp: activeApp, - requestedBundleId: requestedBundleId, - command: command.command + private func executeSnapshotDispatchedOnce(command: Command) throws -> Response { + let preparation = try runMainThreadWork( + command: command, + timeout: mainThreadExecutionTimeout, + timeoutError: mainThreadExecutionTimeoutError + ) { + self.prepareActiveCommandContext(command: command) + } + switch preparation { + case .response(let response): + return response + case .context(let context): + return try executeSnapshotPrepared(command: command, activeApp: context.app) + } + } + + private func executeSnapshotPrepared(command: Command, activeApp: XCUIApplication) throws -> Response { + let options = SnapshotOptions( + interactiveOnly: command.interactiveOnly ?? false, + depth: command.depth, + scope: command.scope, + raw: command.raw ?? false + ) + do { + let payload: DataPayload + if options.raw { + payload = try snapshotRaw(app: activeApp, options: options) + } else { + payload = try snapshotFast(app: activeApp, options: options) + } + setNeedsPostSnapshotInteractionDelay() + return Response(ok: true, data: payload) + } catch let failure as SnapshotCaptureFailure { + invalidateCachedTargetAfterSnapshotFailure() + return Response( + ok: false, + error: ErrorPayload( + code: failure.code, + message: failure.message, + hint: failure.hint + ) ) - if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) { - if let bundleId = requestedBundleId { - activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait") - guard activeApp.waitForExistence(timeout: appExistenceTimeout) else { - return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available")) - } - } else { - return Response(ok: false, error: ErrorPayload(message: "runner app is not available")) - } + } + } + + private func setNeedsPostSnapshotInteractionDelay() { + if Thread.isMainThread { + needsPostSnapshotInteractionDelay = true + return + } + do { + try runMainThreadWork( + command: nil, + timeout: 1, + timeoutError: mainThreadExecutionTimeoutError + ) { + self.needsPostSnapshotInteractionDelay = true } + } catch { + NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_FAILED=%@", String(describing: error)) + } + } - if isInteractionCommand(command.command) { - if let bundleId = requestedBundleId, activeApp.state != .runningForeground { - activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard") - } else if requestedBundleId == nil, activeApp.state != .runningForeground { - ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle") - activeApp = app - } - let skipInteractionExistenceWait = canUseFastForegroundAppGuard( - activeApp: activeApp, - requestedBundleId: requestedBundleId, - command: command.command - ) - if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) { - if let bundleId = requestedBundleId { - return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available")) - } - return Response(ok: false, error: ErrorPayload(message: "runner app is not available")) - } - applyInteractionStabilizationIfNeeded() + private func invalidateCachedTargetAfterSnapshotFailure() { + if Thread.isMainThread { + invalidateCachedTarget(reason: "ax_snapshot_failure") + return + } + do { + try runMainThreadWork( + command: nil, + timeout: 1, + timeoutError: mainThreadExecutionTimeoutError + ) { + self.invalidateCachedTarget(reason: "ax_snapshot_failure") } + } catch { + NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_INVALIDATION_FAILED=%@", String(describing: error)) + } + } + + private func executeOnMain(command: Command) throws -> Response { + let preparation = prepareActiveCommandContext(command: command) + let activeApp: XCUIApplication + switch preparation { + case .response(let response): + return response + case .context(let context): + activeApp = context.app } switch command.command { @@ -599,6 +735,89 @@ extension RunnerTests { } case .uptime: return executeUptime() + default: + break + } + return try executeOnMainPrepared(command: command, activeApp: activeApp) + } + + private func prepareActiveCommandContext(command: Command) -> ActiveCommandPreparation { + var activeApp = currentApp ?? app + if shouldSkipAppActivationPreflight(command) { + activeApp = resolveAppWithoutActivation(command: command) + } else if !isRunnerLifecycleCommand(command.command) { + let normalizedBundleId = command.appBundleId? + .trimmingCharacters(in: .whitespacesAndNewlines) + let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId + if let bundleId = requestedBundleId { + if currentBundleId != bundleId || currentApp == nil { + _ = activateTarget(bundleId: bundleId, reason: "bundle_changed") + } + } else { + // Do not reuse stale bundle targets when the caller does not explicitly request one. + currentApp = nil + currentBundleId = nil + } + + activeApp = currentApp ?? app + if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) { + activeApp = activateTarget(bundleId: bundleId, reason: "stale_target") + } else if requestedBundleId == nil, targetNeedsActivation(activeApp) { + ensureRunnerHostAppActive(reason: "missing_app_bundle") + activeApp = app + } + + let skipExistenceWait = canUseFastForegroundAppGuard( + activeApp: activeApp, + requestedBundleId: requestedBundleId, + command: command.command + ) + if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) { + if let bundleId = requestedBundleId { + activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait") + guard activeApp.waitForExistence(timeout: appExistenceTimeout) else { + return .response(Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))) + } + } else { + return .response(Response(ok: false, error: ErrorPayload(message: "runner app is not available"))) + } + } + + if isInteractionCommand(command.command) { + if let bundleId = requestedBundleId, activeApp.state != .runningForeground { + activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard") + } else if requestedBundleId == nil, activeApp.state != .runningForeground { + ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle") + activeApp = app + } + let skipInteractionExistenceWait = canUseFastForegroundAppGuard( + activeApp: activeApp, + requestedBundleId: requestedBundleId, + command: command.command + ) + if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) { + if let bundleId = requestedBundleId { + return .response(Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))) + } + return .response(Response(ok: false, error: ErrorPayload(message: "runner app is not available"))) + } + applyInteractionStabilizationIfNeeded() + } + } + return .context(ActiveCommandContext(app: activeApp)) + } + + private func executeOnMainPrepared(command: Command, activeApp: XCUIApplication) throws -> Response { + var activeApp = activeApp + switch command.command { + case .status, .shutdown, .recordStart, .recordStop, .uptime: + return Response( + ok: false, + error: ErrorPayload( + code: "UNSUPPORTED_OPERATION", + message: "\(command.command.rawValue) cannot be executed through the prepared command path" + ) + ) case .tap: if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue { let match = findElement( @@ -921,33 +1140,7 @@ extension RunnerTests { } return Response(ok: true, data: DataPayload(text: text)) case .snapshot: - let options = SnapshotOptions( - interactiveOnly: command.interactiveOnly ?? false, - depth: command.depth, - scope: command.scope, - raw: command.raw ?? false - ) - do { - let payload: DataPayload - if options.raw { - payload = try snapshotRaw(app: activeApp, options: options) - } else { - payload = try snapshotFast(app: activeApp, options: options) - } - needsPostSnapshotInteractionDelay = true - return Response(ok: true, data: payload) - } catch let failure as SnapshotCaptureFailure { - invalidateCachedTarget(reason: "ax_snapshot_failure") - // Other thrown errors fall through to executeOnMainSafely's generic error response. - return Response( - ok: false, - error: ErrorPayload( - code: failure.code, - message: failure.message, - hint: failure.hint - ) - ) - } + return try executeSnapshotPrepared(command: command, activeApp: activeApp) case .screenshot: let screenshot: XCUIScreenshot #if os(macOS) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index 838ad1815..cddc9455a 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -99,7 +99,7 @@ extension RunnerTests { .table ] - private static let flatInteractiveFallbackBudget: TimeInterval = 1.0 + static let flatInteractiveFallbackBudget: TimeInterval = 1.0 func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload { if let blocking = blockingSystemAlertSnapshot() { @@ -572,8 +572,16 @@ extension RunnerTests { options: SnapshotOptions, captureDeadline: Date = .distantFuture ) throws -> SnapshotTraversalContext? { - let viewport = safeSnapshotViewport(app: app) - let queryRoot = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app + let (viewport, queryRoot) = try runMainThreadWork( + command: nil, + timeout: min(1.0, max(0.1, captureDeadline.timeIntervalSinceNow)), + timeoutError: snapshotMainThreadTimeoutError("preparing tree snapshot") + ) { + ( + self.safeSnapshotViewport(app: app), + options.scope.flatMap { self.findScopeElement(app: app, scope: $0) } ?? app + ) + } let slice = min(treeCaptureSliceBudget, max(0.5, captureDeadline.timeIntervalSinceNow)) guard let rootSnapshot = try captureSnapshotRootBounded(queryRoot, sliceSeconds: slice) else { @@ -599,63 +607,59 @@ extension RunnerTests { return abandonedTreeCaptureCount > 0 } - /// Runs the blocking tree-snapshot XPC on a worker thread bounded by `sliceSeconds`. On - /// timeout the XPC keeps running on its worker (it cannot be cancelled); the capture is - /// marked abandoned so plans avoid XCTest-backed tiers until it drains, the tree backend is - /// penalized for this bundle, and the plan moves on to the private AX backend (#1105). + /// 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 + /// for this bundle, and the plan moves on to the private AX backend (#1105/#1122). private func captureSnapshotRootBounded( _ element: XCUIElement, sliceSeconds: TimeInterval ) throws -> XCUIElementSnapshot? { - final class TreeCaptureBox { - var abandoned = false - var outcome: Result? + if Thread.isMainThread { + return try captureSnapshotRoot(element) } - let box = TreeCaptureBox() - let semaphore = DispatchSemaphore(value: 0) - DispatchQueue.global(qos: .userInitiated).async { - var result: Result - do { - result = .success(try self.captureSnapshotRoot(element)) - } catch { - result = .failure(error) - } - self.treeCaptureLock.lock() - if box.abandoned { + return try runMainThreadWork( + command: nil, + timeout: sliceSeconds, + timeoutError: treeCaptureTimeoutError(sliceSeconds: sliceSeconds), + onAbandoned: { + self.treeCaptureLock.lock() + self.abandonedTreeCaptureCount += 1 + self.treeCaptureLock.unlock() + NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_SLICE_TIMEOUT slice=%.1f", sliceSeconds) + self.penalizeSnapshotTreeBackend( + bundleId: self.currentBundleId, + reason: "tree_capture_slice_timeout" + ) + }, + onDrained: { + self.treeCaptureLock.lock() self.abandonedTreeCaptureCount -= 1 self.treeCaptureLock.unlock() NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_DRAINED") - } else { - box.outcome = result - self.treeCaptureLock.unlock() } - semaphore.signal() + ) { + try self.captureSnapshotRoot(element) } - if semaphore.wait(timeout: .now() + sliceSeconds) == .timedOut { - treeCaptureLock.lock() - let timedOut = box.outcome == nil - if timedOut { - box.abandoned = true - abandonedTreeCaptureCount += 1 - } - treeCaptureLock.unlock() - if timedOut { - NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_SLICE_TIMEOUT slice=%.1f", sliceSeconds) - penalizeSnapshotTreeBackend(bundleId: currentBundleId, reason: "tree_capture_slice_timeout") - throw SnapshotCaptureFailure( - code: Self.treeCaptureTimeoutCode, - message: "the XCTest tree capture exceeded its \(Int(sliceSeconds))s time slice", - hint: "The capture plan recovers through the private AX backend on this screen." - ) - } + } + + private func treeCaptureTimeoutError(sliceSeconds: TimeInterval) -> () -> Error { + { + SnapshotCaptureFailure( + code: Self.treeCaptureTimeoutCode, + message: "the XCTest tree capture exceeded its \(Int(sliceSeconds))s time slice", + hint: "The capture plan recovers through the private AX backend on this screen." + ) } - switch box.outcome { - case .success(let snapshot): - return snapshot - case .failure(let error): - throw error - case .none: - return nil + } + + func snapshotMainThreadTimeoutError(_ operation: String) -> () -> Error { + { + SnapshotCaptureFailure( + code: Self.treeCaptureTimeoutCode, + message: "timed out while \(operation) on the XCTest main thread", + hint: "The capture plan will skip XCTest-backed snapshot tiers while the previous main-thread work drains." + ) } } diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index 373f7c4bc..df133694d 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -261,15 +261,25 @@ extension RunnerTests { else { return nil } - let payload = options.raw - ? try rawTreeSnapshotPayload(context: context, options: options) - : recursiveTreeSnapshotPayload(context: context, options: options) + let payload = try runMainThreadWork( + command: nil, + timeout: min(treeCaptureSliceBudget, max(0.5, deadline.timeIntervalSinceNow)), + timeoutError: snapshotMainThreadTimeoutError("processing tree snapshot") + ) { + options.raw + ? try self.rawTreeSnapshotPayload(context: context, options: options) + : self.recursiveTreeSnapshotPayload(context: context, options: options) + } return SnapshotBackendCapture(payload: payload, effectiveDepth: nil) case .querySweep: - return SnapshotBackendCapture( - payload: snapshotFlatInteractive(app: app, options: options, planDeadline: deadline), - effectiveDepth: nil - ) + let payload = try runMainThreadWork( + command: nil, + timeout: min(Self.flatInteractiveFallbackBudget, max(0.1, deadline.timeIntervalSinceNow)), + timeoutError: snapshotMainThreadTimeoutError("running query-sweep snapshot") + ) { + self.snapshotFlatInteractive(app: app, options: options, planDeadline: deadline) + } + return SnapshotBackendCapture(payload: payload, effectiveDepth: nil) case .privateAX: return privateAXSnapshotCapture(app: app, options: options, deadline: deadline) } diff --git a/src/__tests__/apple-runner-package-source.test.ts b/src/__tests__/apple-runner-package-source.test.ts index a86c44237..16e1e0908 100644 --- a/src/__tests__/apple-runner-package-source.test.ts +++ b/src/__tests__/apple-runner-package-source.test.ts @@ -8,6 +8,10 @@ import { runCmd } from '../utils/exec.ts'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const packageScript = path.join(repoRoot, 'scripts', 'package-apple-runner-source.mjs'); +const runnerSnapshotSwiftPath = path.join( + repoRoot, + 'apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift', +); test('package apple runner source strips unit-test blocks without mutating checkout source', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-package-')); @@ -82,8 +86,32 @@ test('package apple runner source strips unit-test blocks without mutating check ); }); +test('apple runner tree snapshot capture stays on the main queue', () => { + const source = fs.readFileSync(runnerSnapshotSwiftPath, 'utf8'); + const boundedCapture = extractSwiftFunction(source, 'captureSnapshotRootBounded'); + + assert.doesNotMatch(boundedCapture, /DispatchQueue\.global/); + assert.match(boundedCapture, /runMainThreadWork/); + assert.match(boundedCapture, /captureSnapshotRoot\(element\)/); +}); + function writeFixtureFile(root: string, relativePath: string, contents: string): void { const filePath = path.join(root, relativePath); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, contents); } + +function extractSwiftFunction(source: string, name: string): string { + const signatureIndex = source.indexOf(`func ${name}`); + assert.notEqual(signatureIndex, -1, `missing Swift function ${name}`); + const bodyStart = source.indexOf('{', signatureIndex); + assert.notEqual(bodyStart, -1, `missing Swift function body ${name}`); + let depth = 0; + for (let index = bodyStart; index < source.length; index += 1) { + const char = source[index]; + if (char === '{') depth += 1; + if (char === '}') depth -= 1; + if (depth === 0) return source.slice(signatureIndex, index + 1); + } + assert.fail(`unterminated Swift function ${name}`); +} From 2a6827cffd35c55cdbf0f7b80ffff2793e554c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Jul 2026 14:35:40 +0200 Subject: [PATCH 2/2] fix: address iOS runner snapshot review --- .../RunnerTests+CommandExecution.swift | 103 +++++++++++++++++- .../RunnerTests+SnapshotCapturePlan.swift | 15 ++- .../RunnerTests.swift | 4 +- .../apple-runner-package-source.test.ts | 2 + 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 9a5954504..694ae1314 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -241,6 +241,81 @@ extension RunnerTests { XCTAssertNil(box.error) XCTAssertEqual(box.observedMainThread, true) } + + func testRunMainThreadWorkTimeoutMarksAbandonedUntilDrained() { + final class ResultBox { + var error: Error? + var abandonedCount: Int? + var abandonedSinceSet: Bool? + var drainedCount: Int? + var drainedSinceCleared: Bool? + } + let box = ResultBox() + let releaseWork = DispatchSemaphore(value: 0) + let observedAbandoned = DispatchSemaphore(value: 0) + let finished = expectation(description: "off-main caller timed out") + let drained = expectation(description: "abandoned main work drained") + + DispatchQueue(label: "agent-device.runner.tests.timeout").async { + do { + _ = try self.runMainThreadWork( + command: nil, + timeout: 0, + timeoutError: self.mainThreadExecutionTimeoutError, + onAbandoned: { + box.abandonedCount = self.abandonedMainThreadWorkCount + box.abandonedSinceSet = self.abandonedMainThreadWorkSince != nil + observedAbandoned.signal() + }, + onDrained: { + self.mainThreadWorkLock.lock() + box.drainedCount = self.abandonedMainThreadWorkCount + box.drainedSinceCleared = self.abandonedMainThreadWorkSince == nil + self.mainThreadWorkLock.unlock() + drained.fulfill() + } + ) { + _ = releaseWork.wait(timeout: .now() + 1) + return true + } + } catch { + box.error = error + } + finished.fulfill() + } + + DispatchQueue(label: "agent-device.runner.tests.release-timeout").async { + _ = observedAbandoned.wait(timeout: .now() + 1) + releaseWork.signal() + } + + wait(for: [finished, drained], timeout: 2) + XCTAssertEqual((box.error as NSError?)?.code, RunnerErrorCode.mainThreadExecutionTimedOut) + XCTAssertEqual(box.abandonedCount, 1) + XCTAssertEqual(box.abandonedSinceSet, true) + XCTAssertEqual(box.drainedCount, 0) + XCTAssertEqual(box.drainedSinceCleared, true) + } + + func testPostSnapshotDelayMarkDoesNotQueueBehindAbandonedTreeCapture() { + abandonedTreeCaptureCount = 1 + defer { + abandonedTreeCaptureCount = 0 + needsPostSnapshotInteractionDelay = false + } + + let finished = expectation(description: "off-main caller finished") + DispatchQueue(label: "agent-device.runner.tests.post-snapshot-delay").async { + self.setNeedsPostSnapshotInteractionDelay() + finished.fulfill() + } + + wait(for: [finished], timeout: 1) + mainThreadWorkLock.lock() + let abandonedWorkCount = abandonedMainThreadWorkCount + mainThreadWorkLock.unlock() + XCTAssertEqual(abandonedWorkCount, 0) + } #endif func execute(command: Command) throws -> Response { @@ -583,7 +658,7 @@ extension RunnerTests { timeout: mainThreadExecutionTimeout, timeoutError: mainThreadExecutionTimeoutError ) { - self.prepareActiveCommandContext(command: command) + try self.prepareActiveCommandContextSafely(command: command) } switch preparation { case .response(let response): @@ -627,6 +702,10 @@ extension RunnerTests { needsPostSnapshotInteractionDelay = true return } + guard !hasAbandonedTreeCapture() else { + NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_SKIPPED_XCTEST_OCCUPIED") + return + } do { try runMainThreadWork( command: nil, @@ -658,6 +737,28 @@ extension RunnerTests { } } + private func prepareActiveCommandContextSafely(command: Command) throws -> ActiveCommandPreparation { + var preparation: ActiveCommandPreparation? + let exceptionMessage = RunnerObjCExceptionCatcher.catchException({ + preparation = self.prepareActiveCommandContext(command: command) + }) + if let exceptionMessage { + throw NSError( + domain: RunnerErrorDomain.exception, + code: RunnerErrorCode.objcException, + userInfo: [NSLocalizedDescriptionKey: exceptionMessage] + ) + } + guard let preparation else { + throw NSError( + domain: RunnerErrorDomain.general, + code: RunnerErrorCode.commandReturnedNoResponse, + userInfo: [NSLocalizedDescriptionKey: "snapshot preflight returned no response"] + ) + } + return preparation + } + private func executeOnMain(command: Command) throws -> Response { let preparation = prepareActiveCommandContext(command: command) let activeApp: XCUIApplication diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index df133694d..2b12f3734 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -103,6 +103,10 @@ extension RunnerTests { return ([.privateAX, .querySweep, .recursiveTree], true) } + func shouldSkipSnapshotBackendForAbandonedTreeCapture(_ kind: SnapshotBackendKind) -> Bool { + kind != .privateAX && hasAbandonedTreeCapture() + } + // MARK: Plan runner func runSnapshotCapturePlan( @@ -144,7 +148,7 @@ extension RunnerTests { } // While an abandoned tree capture is still grinding inside testmanagerd, XCTest-backed // tiers would block behind it; only the private AX backend stays responsive (#1105). - if kind != .privateAX, hasAbandonedTreeCapture() { + if shouldSkipSnapshotBackendForAbandonedTreeCapture(kind) { NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_TIER_SKIPPED_XCTEST_OCCUPIED tier=%@", kind.rawValue) if firstFailure == nil { firstFailure = ( @@ -543,6 +547,15 @@ extension RunnerTests { XCTAssertFalse(isSnapshotTreeBackendPenalized(bundleId: "com.other.app")) } + func testAbandonedTreeCaptureSkipsOnlyXCTestBackedSnapshotTiers() { + abandonedTreeCaptureCount = 1 + defer { abandonedTreeCaptureCount = 0 } + + XCTAssertTrue(shouldSkipSnapshotBackendForAbandonedTreeCapture(.recursiveTree)) + XCTAssertTrue(shouldSkipSnapshotBackendForAbandonedTreeCapture(.querySweep)) + XCTAssertFalse(shouldSkipSnapshotBackendForAbandonedTreeCapture(.privateAX)) + } + // Pins the record(_:) suppression class via its pure classifier. record(_:) itself is not // invoked here: feeding it the must-record variants would record real failures and fail // this very test run. diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index b5dc7cb36..31c0b37e0 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -77,9 +77,9 @@ final class RunnerTests: XCTestCase { // Bluesky-class screens grind ~4-8s before the tree backend fails; anything past this // threshold marks the screen hostile so the next capture leads with private AX. let snapshotTreeSlowCaptureThreshold: TimeInterval = 3 - // The blocking XCTest tree snapshot XPC runs on a worker thread with this slice so a + // The blocking XCTest tree snapshot XPC runs on the main thread with this slice so a // content-dependent grind (#1105: seconds to minutes on live Bluesky screens) cannot pin - // the capture plan. On timeout the XPC keeps grinding on its worker; while any abandoned + // the capture plan. On timeout the XPC keeps grinding on main; while any abandoned // tree capture is outstanding, plans skip the XCTest-backed tiers (tree, query sweep) and // use the private AX backend, which does not go through testmanagerd. let treeCaptureLock = NSLock() diff --git a/src/__tests__/apple-runner-package-source.test.ts b/src/__tests__/apple-runner-package-source.test.ts index 16e1e0908..6210c941e 100644 --- a/src/__tests__/apple-runner-package-source.test.ts +++ b/src/__tests__/apple-runner-package-source.test.ts @@ -106,6 +106,8 @@ function extractSwiftFunction(source: string, name: string): string { assert.notEqual(signatureIndex, -1, `missing Swift function ${name}`); const bodyStart = source.indexOf('{', signatureIndex); assert.notEqual(bodyStart, -1, `missing Swift function body ${name}`); + // This lightweight guard assumes the target Swift function does not contain unmatched braces + // inside string literals or comments; keep the source guard focused on small functions. let depth = 0; for (let index = bodyStart; index < source.length; index += 1) { const char = source[index];