From 0ae929c0ea9b6b3ea6f0cd908326b1c9f3378621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 25 Oct 2026 10:39:38 +0100 Subject: [PATCH 1/5] refactor(ios): give each snapshot capture step its penalty authority EffectiveSnapshotCapturePlan carried a backend array, the channel state, a tree-slice budget override, and a pin in parallel. Whether the first step was the periodic probe was inferred from position, and the penalty recorder accepted a channel state defaulted to .normal, so a caller that ran a bounded recovery attempt but forgot its state could re-arm the penalty it was recovering from - the #3896 bug class, still reachable at the interface. A factory-built SnapshotCaptureStep now carries the backend together with its execution mode. captureWithBackend returns the attempt carrying that step; the recorder takes the attempt plus the warmup exemption and no other policy arguments. Only an ordinary attempt has an arming branch; only an accepted completed bounded recovery can clear the penalty early. Plan shapes, the selection-cause slots from #3916, the in-band claim after the occupied skip, and SnapshotXCTestChannelPenalty as the sole expiry owner are unchanged. The channel policy and its verdict helpers move to a named policy file. --- .../RunnerTests+SnapshotCapturePlan.swift | 297 ++------------- .../RunnerTests+SnapshotChannelPolicy.swift | 346 ++++++++++++++++++ .../RunnerTests+SnapshotTiming.swift | 65 +--- ...ts+SnapshotCapturePlanOccupancyTests.swift | 2 +- ...RunnerTests+SnapshotCapturePlanTests.swift | 113 +++--- .../RunnerTests+SnapshotTimingTests.swift | 111 +++--- 6 files changed, 530 insertions(+), 404 deletions(-) create mode 100644 apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index d5d691dd4c..19783a2ee6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -41,51 +41,6 @@ struct SnapshotQuality: Codable { var timing: SnapshotCaptureTiming? = nil } -enum SnapshotXCTestChannelPlanState: Equatable { - case normal - case deferredToIndependentBackend - case boundedXCTestProbe -} - -/// The XCTest accessibility channel penalty (#1105/#1156). While it is active, regular plans for its -/// bundle defer XCTest tiers. Each recovery interval, one deferred plan runs the bounded tree probe -/// first, inside that capture, so a screen that is light again can clear the penalty before it -/// expires (#3844). -struct SnapshotXCTestChannelPenalty: Equatable { - static let duration: TimeInterval = 120 - static let recoveryProbeInterval: TimeInterval = 30 - - private(set) var bundleId: String? - private(set) var until = Date.distantPast - private(set) var probeDueAt = Date.distantFuture - - /// A penalty armed without a bundle id applies to whatever target is current. - func isActive(for bundleId: String?, at now: Date) -> Bool { - guard now < until else { return false } - guard let penalized = self.bundleId else { return true } - return penalized == bundleId - } - - func isProbeDue(for bundleId: String?, at now: Date) -> Bool { - isActive(for: bundleId, at: now) && now >= probeDueAt - } - - mutating func arm(bundleId: String?, at now: Date) { - self.bundleId = bundleId - until = now.addingTimeInterval(Self.duration) - probeDueAt = now.addingTimeInterval(Self.recoveryProbeInterval) - } - - /// A started probe moves the next one a full interval out, whatever the probe finds. - mutating func claimProbe(at now: Date) { - probeDueAt = now.addingTimeInterval(Self.recoveryProbeInterval) - } - - mutating func clear() { - self = SnapshotXCTestChannelPenalty() - } -} - /// How one tier's bounded work ended. `deadlineExhausted` is a tier timeout: the tier stopped /// starting work it could not finish inside its own slice, so what it returns is a partial result /// it never completed collecting. The plan keeps that result only as the fallback and lets the next @@ -96,18 +51,21 @@ enum SnapshotTierOutcome: Equatable { case deadlineExhausted } +/// The plan one capture runs: ordered steps, the channel state the selection chose, and the pin — +/// if any — that narrowed it. Each step carries its own backend and execution mode, so the runner +/// never infers recovery status from a position and the recorder never re-derives it from a +/// parallel argument. The selection cause stays separate from the execution mode: an explicit pin, +/// a custom-action pin, and a breaker deferral can share the deferred ordering while their verdict +/// explanations differ, and a pin is never an armed penalty. struct EffectiveSnapshotCapturePlan { - let plan: [SnapshotBackendKind] + let steps: [SnapshotCaptureStep] let xCTestChannelState: SnapshotXCTestChannelPlanState - let treeCaptureSliceBudgetOverride: TimeInterval? /// Non-nil only when the plan was narrowed by an explicit internal backend preference. This /// keeps the quality marker tied to the plan decision rather than to an untrusted request field. let preferredBackend: SnapshotBackendKind? - /// A deferred plan that starts with an XCTest tier is running the due recovery probe. - var leadsWithXCTestRecoveryProbe: Bool { - xCTestChannelState == .deferredToIndependentBackend - && plan.first?.usesXCTestAccessibilityChannel == true + var plan: [SnapshotBackendKind] { + steps.map(\.backend) } } @@ -164,7 +122,6 @@ extension RunnerTests { /// but chained recovery tiers must never stack past the 30s main-thread watchdog: when the /// budget is spent, remaining tiers are skipped and the best payload so far is returned. static let snapshotPlanBudget: TimeInterval = 20 - static let penalizedXCTestProbeTreeSliceBudget: TimeInterval = 1 static let collapsedLeafMinimumSegments = 10 static func payloadNodeCount(_ payload: DataPayload?) -> Int { @@ -179,196 +136,6 @@ extension RunnerTests { /// trait instead of by someone remembering this line. static let rawDiagnosticPlan: [SnapshotBackendKind] = regularVisiblePlan.filter(\.supportsRawProjection) - // MARK: XCTest accessibility channel penalty (cross-attempt memory, #1105/#1156) - // - // On some deep/dynamic screens the XCTest bulk snapshot no longer fails fast with - // kAXErrorIllegalArgument (the #758 signature) — it grinds for many seconds first. One slow - // grind is tolerable; re-grinding on every subsequent capture of the same screen buries the - // main thread past the execution watchdog. After a slow, timed-out, or abandoned XCTest-backed - // capture, later plans for the same bundle use non-XCTest recovery tiers until the penalty expires. - - func penalizeSnapshotXCTestChannel(bundleId: String?, reason: String) { - snapshotXCTestChannelPenaltyLock.lock() - snapshotXCTestChannelPenalty.arm(bundleId: bundleId, at: Date()) - snapshotXCTestChannelPenaltyLock.unlock() - NSLog( - "AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PENALIZED bundle=%@ reason=%@", - bundleId ?? "", - reason - ) - } - - func clearSnapshotXCTestChannelPenalty(reason: String) { - snapshotXCTestChannelPenaltyLock.lock() - let hadActivePenalty = Date() < snapshotXCTestChannelPenalty.until - snapshotXCTestChannelPenalty.clear() - snapshotXCTestChannelPenaltyLock.unlock() - if hadActivePenalty { - NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PENALTY_CLEARED reason=%@", reason) - } - } - - func isSnapshotXCTestChannelPenalized(bundleId: String?) -> Bool { - snapshotXCTestChannelPenaltyLock.lock() - defer { snapshotXCTestChannelPenaltyLock.unlock() } - return snapshotXCTestChannelPenalty.isActive(for: bundleId, at: Date()) - } - - func isSnapshotXCTestRecoveryProbeDue(bundleId: String?) -> Bool { - snapshotXCTestChannelPenaltyLock.lock() - defer { snapshotXCTestChannelPenaltyLock.unlock() } - return snapshotXCTestChannelPenalty.isProbeDue(for: bundleId, at: Date()) - } - - func claimSnapshotXCTestRecoveryProbe(bundleId: String?) { - snapshotXCTestChannelPenaltyLock.lock() - snapshotXCTestChannelPenalty.claimProbe(at: Date()) - snapshotXCTestChannelPenaltyLock.unlock() - NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_RECOVERY_PROBE bundle=%@", bundleId ?? "") - } - - /// The pre-seeded first-failure a penalized plan stamps into its verdict. The deferred case - /// uses the dedicated 'deferred' code: the breaker pre-selected the backend, nothing new - /// degraded on this capture, and the daemon keys warning suppression and the settle budget - /// reset off exactly that distinction. The bounded probe keeps 'budget': its short XCTest - /// slice genuinely constrains what this capture could read. - /// - /// `requestPinnedBackend` separates the two ways the plan can arrive at the same - /// deferred shape. A capture that ASKED for a private-AX-only reading (custom - /// actions) degraded nothing, so it seeds no failure at all — reporting slow - /// accessibility work would name a cause that does not exist. Its answer comes from - /// `snapshotPlanPreSelectionReason`. - static func xcTestChannelStateFirstFailure( - _ state: SnapshotXCTestChannelPlanState, - requestPinnedBackend: Bool = false - ) -> (reason: String, code: String)? { - switch state { - case .normal: - return nil - case .deferredToIndependentBackend: - if requestPinnedBackend { - return nil - } - return ( - "XCTest-backed snapshot tiers were deferred after recent slow accessibility work on this screen", - "deferred" - ) - case .boundedXCTestProbe: - return ( - "XCTest-backed snapshot tiers are running with a short recovery probe after recent slow accessibility work on this screen", - "budget" - ) - } - } - - /// The 'requested-backend' answer for a plan the request itself pinned: the recursive tree the - /// caller asked for, or the private AX the custom-actions reading requires. A pin names a plan - /// decision, not a failure, so it never enters the failure slot; `planVerdictReason` reports it - /// only while no tier failed on this capture. - static func snapshotPlanPreSelectionReason( - _ state: SnapshotXCTestChannelPlanState, - requestPinnedBackend: Bool = false, - preferredBackend: String? = nil - ) -> (reason: String, code: String)? { - if state == .normal && preferredBackend == SnapshotBackendKind.recursiveTree.rawValue { - return ( - "the recursive XCTest tree backend was explicitly selected for this capture", - "requested-backend" - ) - } - if state == .deferredToIndependentBackend && requestPinnedBackend { - return ( - "the private AX backend was selected because this capture asked for accessibility custom actions", - "requested-backend" - ) - } - return nil - } - - /// The reason a capture's verdict carries: the first tier failure when one exists, otherwise the - /// pre-selection the plan started with. Once an XCTest-backed tier ran behind a deferred plan, - /// it ran on the bounded probe's short slice, so the capture reports that constraint ('budget') - /// whether the tier recovered it or the plan ended sparse, rather than the pre-selection - /// ('deferred') the plan was seeded with. - static func planVerdictReason( - xCTestTierRan: Bool, - state: SnapshotXCTestChannelPlanState, - firstFailure: (reason: String, code: String)?, - preSelection: (reason: String, code: String)? - ) -> (reason: String, code: String)? { - guard xCTestTierRan, state == .deferredToIndependentBackend else { return firstFailure ?? preSelection } - return xcTestChannelStateFirstFailure(.boundedXCTestProbe) - } - - /// Pure gate: a capture is planned as penalized when the channel penalty is - /// active OR the daemon pinned the private-AX backend (same-backend evidence - /// probe) — both mean "do not enter XCTest tree work first, and stamp the - /// pre-selection as 'deferred' rather than a degradation". - static func snapshotXCTestChannelTreatedAsPenalized( - penalized: Bool, - preferredBackend: String? - ) -> Bool { - penalized || preferredBackend == SnapshotBackendKind.privateAX.rawValue - } - - /// Pure plan-reorder rule: an internal preferred backend pins the regular plan to that backend; - /// this is the only force seam used by same-backend evidence and conformance captures. A - /// penalized XCTest accessibility channel uses independent backends when the platform has one, - /// otherwise it keeps XCTest work on a short probe. A due recovery probe puts that short tree - /// ahead of the independent backend. The raw diagnostic plan keeps tree-first errors, and unknown - /// plans are left untouched. - static func effectiveSnapshotCapturePlan( - _ plan: [SnapshotBackendKind], - xCTestChannelPenalized: Bool, - xCTestRecoveryProbeDue: Bool = false, - availableBackends: Set = Set(SnapshotBackendKind.allCases), - preferredBackend: String? = nil - ) -> EffectiveSnapshotCapturePlan { - if - plan == Self.regularVisiblePlan, - let preferred = preferredBackend.flatMap(SnapshotBackendKind.init(rawValue:)), - preferred.isForceable, - availableBackends.contains(preferred) - { - return EffectiveSnapshotCapturePlan( - plan: [preferred], - xCTestChannelState: preferred == .privateAX ? .deferredToIndependentBackend : .normal, - treeCaptureSliceBudgetOverride: nil, - preferredBackend: preferred - ) - } - guard xCTestChannelPenalized, plan == Self.regularVisiblePlan else { - return EffectiveSnapshotCapturePlan( - plan: plan, - xCTestChannelState: .normal, - treeCaptureSliceBudgetOverride: nil, - preferredBackend: nil - ) - } - let availablePlan = plan.filter { availableBackends.contains($0) } - let recoveryPlan = availablePlan.filter { !$0.usesXCTestAccessibilityChannel } - if !recoveryPlan.isEmpty { - // The independent backend reads only the app it can match as the active AX application, so - // an out-of-process surface over the app (the Save Password sheet) leaves it empty for the - // whole penalty. The tree, on the bounded probe's short slice, stays behind it, or leads when - // the recovery probe is due; the query sweep does not, since its grind is what the penalty - // keeps off the main thread. - let boundedTree = availablePlan.filter { $0 == .recursiveTree } - return EffectiveSnapshotCapturePlan( - plan: xCTestRecoveryProbeDue ? boundedTree + recoveryPlan : recoveryPlan + boundedTree, - xCTestChannelState: .deferredToIndependentBackend, - treeCaptureSliceBudgetOverride: Self.penalizedXCTestProbeTreeSliceBudget, - preferredBackend: nil - ) - } - return EffectiveSnapshotCapturePlan( - plan: availablePlan.filter(\.usesXCTestAccessibilityChannel), - xCTestChannelState: .boundedXCTestProbe, - treeCaptureSliceBudgetOverride: Self.penalizedXCTestProbeTreeSliceBudget, - preferredBackend: nil - ) - } - func shouldSkipSnapshotBackendForAbandonedMainThreadWork(_ kind: SnapshotBackendKind) -> Bool { kind.usesXCTestAccessibilityChannel && hasAbandonedMainThreadWork() } @@ -397,13 +164,13 @@ extension RunnerTests { // penalized route: privateAX-first plan, 'deferred' verdict — the backend was pre-selected // deliberately, so no degradation warning should render for it. var xCTestChannelPenalized = false - var xCTestChannelPenalizedByBreaker = false + var breakerPenalized = false var xCTestRecoveryProbeDue = false #if os(iOS) - xCTestChannelPenalizedByBreaker = isSnapshotXCTestChannelPenalized(bundleId: target.bundleId) + breakerPenalized = isSnapshotXCTestChannelPenalized(bundleId: target.bundleId) xCTestRecoveryProbeDue = isSnapshotXCTestRecoveryProbeDue(bundleId: target.bundleId) xCTestChannelPenalized = Self.snapshotXCTestChannelTreatedAsPenalized( - penalized: xCTestChannelPenalizedByBreaker, + penalized: breakerPenalized, preferredBackend: options.preferredBackend ) #endif @@ -414,12 +181,11 @@ extension RunnerTests { availableBackends: Set(SnapshotBackendKind.allCases.filter(\.isAvailableOnCurrentPlatform)), preferredBackend: options.preferredBackend ) - let effectivePlan = effective.plan // A request pin answers through its own slot, never the failure slot: a tier that failed on // this capture owns the verdict reason there, and planVerdictReason falls back to the pin only // when nothing failed. Only a customActions-implied pin is request-pinned; the daemon's // same-backend evidence probe pins for its own reasons and keeps its 'deferred' seed. - let requestPinnedBackend = options.customActions && !xCTestChannelPenalizedByBreaker + let requestPinnedBackend = options.customActions && !breakerPenalized firstFailure = Self.xcTestChannelStateFirstFailure( effective.xCTestChannelState, requestPinnedBackend: requestPinnedBackend @@ -438,8 +204,9 @@ extension RunnerTests { NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PROBE_BOUNDED bundle=%@", target.bundleId ?? "") } - for kind in effectivePlan { - if kind != effectivePlan.first && Date() >= deadline { + for step in effective.steps { + let kind = step.backend + if kind != effective.steps.first?.backend && Date() >= deadline { NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_PLAN_BUDGET_EXHAUSTED skipped=%@", kind.rawValue) if firstFailure == nil { firstFailure = ("the capture plan ran out of its time budget", "budget") @@ -448,6 +215,8 @@ extension RunnerTests { } // While abandoned main-thread work is still grinding inside testmanagerd, XCTest-backed // tiers would queue behind it; only independent backends stay responsive (#1105). + // A skipped step starts nothing, so it does not consume the due probe either; the claim + // stays synchronous here in the loop, before the work starts, never after the reply. if shouldSkipSnapshotBackendForAbandonedMainThreadWork(kind) { NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_TIER_SKIPPED_XCTEST_OCCUPIED tier=%@", kind.rawValue) if firstFailure == nil { @@ -458,23 +227,20 @@ extension RunnerTests { } continue } - if kind == effectivePlan.first && effective.leadsWithXCTestRecoveryProbe { + if step.claimsPeriodicRecoveryProbe { claimSnapshotXCTestRecoveryProbe(bundleId: target.bundleId) } if kind.usesXCTestAccessibilityChannel { xCTestTierRan = true } let attempt = try captureWithBackend( - kind, + step, target: target, options: options, - deadline: deadline, - treeCaptureSliceBudgetOverride: effective.treeCaptureSliceBudgetOverride + deadline: deadline ) recordXCTestSnapshotBackendAttemptIfNeeded( - kind, attempt: attempt, bundleId: target.bundleId, - penaltySuppressed: suppressXCTestPenalty, - channelState: effective.xCTestChannelState + penaltySuppressed: suppressXCTestPenalty ) if case let .failed(failure, phase: _) = attempt.outcome { if Self.isAxSnapshotFailure(failure) { axFailure = failure } @@ -509,7 +275,7 @@ extension RunnerTests { continue } - let recovered = kind != effectivePlan.first || effective.xCTestChannelState != .normal + let recovered = kind != effective.steps.first?.backend || effective.xCTestChannelState != .normal let verdictReason = Self.planVerdictReason( xCTestTierRan: kind.usesXCTestAccessibilityChannel, state: effective.xCTestChannelState, @@ -560,20 +326,24 @@ extension RunnerTests { best.map { stampedSnapshotPayload($0.capture, backend: $0.kind, state: .sparse, reason: terminalReason) } ?? stampedSnapshotPayload( SnapshotBackendCapture(payload: sparseTruncatedSnapshotPayload(), effectiveDepth: nil), - backend: effectivePlan.last ?? plan.last ?? .recursiveTree, + backend: effective.steps.last?.backend ?? plan.last ?? .recursiveTree, state: .sparse, reason: terminalReason ) return fallbackPayload } + /// Runs one planned step and returns the attempt carrying that same step, so the penalty + /// recorder sees the authority the work actually executed under. The tree tier's bounded slice + /// comes from the step; no caller passes a plan-wide budget override. private func captureWithBackend( - _ kind: SnapshotBackendKind, + _ step: SnapshotCaptureStep, target: SnapshotCaptureTarget, options: PresentationOptions, - deadline: Date, - treeCaptureSliceBudgetOverride: TimeInterval? + deadline: Date ) throws -> SnapshotBackendAttempt { + let kind = step.backend + let treeCaptureSliceBudgetOverride = step.treeCaptureSliceBudgetOverride let app = target.app let hint = SnapshotPresentation.captureHint(for: options) var timer = SnapshotPhaseTimer() @@ -647,12 +417,14 @@ extension RunnerTests { tierOutcome = measured.1 } catch let failure as SnapshotCaptureFailure { return SnapshotBackendAttempt( + step: step, outcome: .failed(failure, phase: .acquisition), timing: timer.timing ) } guard let acquisition else { return SnapshotBackendAttempt( + step: step, outcome: .noCapture, timing: timer.timing, tierOutcome: tierOutcome @@ -688,12 +460,14 @@ extension RunnerTests { } } catch let failure as SnapshotPresentationFailure { return SnapshotBackendAttempt( + step: step, outcome: .failed(Self.snapshotCaptureFailure(for: failure), phase: .presentation), timing: timer.timing, tierOutcome: tierOutcome ) } catch let failure as SnapshotCaptureFailure { return SnapshotBackendAttempt( + step: step, outcome: .failed(failure, phase: .presentation), timing: timer.timing, tierOutcome: tierOutcome @@ -704,6 +478,7 @@ extension RunnerTests { capture.timing = timer.timing capture.keyboardBand = keyboardBand?.payload return SnapshotBackendAttempt( + step: step, outcome: .captured(capture), timing: timer.timing, tierOutcome: tierOutcome diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift new file mode 100644 index 0000000000..11d4b169e6 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift @@ -0,0 +1,346 @@ +import Foundation + +// MARK: - XCTest accessibility channel policy (ADR 0004, #1105/#1156/#3896) +// +// One owner for what a capture must know about the XCTest channel's health: the penalty value, +// the plan-reorder rule, and the execution mode each plan step carries. A step is built by the +// factory once and travels with the attempt it produces, so a bounded recovery attempt cannot +// reach the penalty recorder looking like an ordinary one. The old interface accepted a defaulted +// `.normal` channel state at evidence time, which is how #3896's self-renewal bug was reachable. + +/// What the plan runner does with one backend on this capture. The penalty recorder reads this, +/// never a re-derived plan position, to decide which evidence an attempt may produce. +enum SnapshotCaptureExecutionMode: Equatable { + /// A regular attempt: its acquisition facts may arm or renew the channel penalty. + case ordinary + /// A bounded attempt inside a penalized plan: it may never arm or renew the penalty. + case boundedRecovery(claimsPeriodicProbe: Bool) +} + +/// One executed step of an effective plan: the backend together with its execution mode, built +/// once by the plan factory. The tree tier's bounded slice and the probe claim derive from the +/// mode, so no caller threads a plan-wide budget override or a positional rule beside the plan it +/// already carries. The penalty recorder reads the mode: only an ordinary attempt may produce +/// arming evidence. +struct SnapshotCaptureStep { + /// The whole-slice budget a bounded tree attempt gets while the channel is penalized. + static let boundedTreeSliceBudget: TimeInterval = 1 + + let backend: SnapshotBackendKind + let execution: SnapshotCaptureExecutionMode + + static func ordinary(_ backend: SnapshotBackendKind) -> SnapshotCaptureStep { + SnapshotCaptureStep(backend: backend, execution: .ordinary) + } + + static func boundedRecovery( + _ backend: SnapshotBackendKind, + claimsPeriodicProbe: Bool = false + ) -> SnapshotCaptureStep { + SnapshotCaptureStep(backend: backend, execution: .boundedRecovery(claimsPeriodicProbe: claimsPeriodicProbe)) + } + + /// The tree tier's bounded slice, from this step's own mode. The query sweep's slice has its own + /// owner (`querySweepSliceDeadline`), which the penalty does not shorten. + var treeCaptureSliceBudgetOverride: TimeInterval? { + guard backend == .recursiveTree, execution != .ordinary else { return nil } + return Self.boundedTreeSliceBudget + } + + /// Only the leading step of the deferred plan that is running the due probe claims it. + var claimsPeriodicRecoveryProbe: Bool { + if case let .boundedRecovery(claims) = execution { return claims } + return false + } +} + +enum SnapshotXCTestChannelPlanState: Equatable { + case normal + case deferredToIndependentBackend + case boundedXCTestProbe +} + +/// The XCTest accessibility channel penalty (#1105/#1156). While it is active, regular plans for its +/// bundle defer XCTest tiers. Each recovery interval, one deferred plan runs the bounded tree probe +/// first, inside that capture, so a screen that is light again can clear the penalty before it +/// expires (#3844). It is the only expiry/due-time owner: no schedule, queue, or cache runs beside it. +struct SnapshotXCTestChannelPenalty: Equatable { + static let duration: TimeInterval = 120 + static let recoveryProbeInterval: TimeInterval = 30 + + private(set) var bundleId: String? + private(set) var until = Date.distantPast + private(set) var probeDueAt = Date.distantFuture + + /// A penalty armed without a bundle id applies to whatever target is current. + func isActive(for bundleId: String?, at now: Date) -> Bool { + guard now < until else { return false } + guard let penalized = self.bundleId else { return true } + return penalized == bundleId + } + + func isProbeDue(for bundleId: String?, at now: Date) -> Bool { + isActive(for: bundleId, at: now) && now >= probeDueAt + } + + mutating func arm(bundleId: String?, at now: Date) { + self.bundleId = bundleId + until = now.addingTimeInterval(Self.duration) + probeDueAt = now.addingTimeInterval(Self.recoveryProbeInterval) + } + + /// A started probe moves the next one a full interval out, whatever the probe finds. + mutating func claimProbe(at now: Date) { + probeDueAt = now.addingTimeInterval(Self.recoveryProbeInterval) + } + + mutating func clear() { + self = SnapshotXCTestChannelPenalty() + } +} + +extension RunnerTests { + func penalizeSnapshotXCTestChannel(bundleId: String?, reason: String) { + snapshotXCTestChannelPenaltyLock.lock() + snapshotXCTestChannelPenalty.arm(bundleId: bundleId, at: Date()) + snapshotXCTestChannelPenaltyLock.unlock() + NSLog( + "AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PENALIZED bundle=%@ reason=%@", + bundleId ?? "", + reason + ) + } + + func clearSnapshotXCTestChannelPenalty(reason: String) { + snapshotXCTestChannelPenaltyLock.lock() + let hadActivePenalty = Date() < snapshotXCTestChannelPenalty.until + snapshotXCTestChannelPenalty.clear() + snapshotXCTestChannelPenaltyLock.unlock() + if hadActivePenalty { + NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PENALTY_CLEARED reason=%@", reason) + } + } + + func isSnapshotXCTestChannelPenalized(bundleId: String?) -> Bool { + snapshotXCTestChannelPenaltyLock.lock() + defer { snapshotXCTestChannelPenaltyLock.unlock() } + return snapshotXCTestChannelPenalty.isActive(for: bundleId, at: Date()) + } + + func isSnapshotXCTestRecoveryProbeDue(bundleId: String?) -> Bool { + snapshotXCTestChannelPenaltyLock.lock() + defer { snapshotXCTestChannelPenaltyLock.unlock() } + return snapshotXCTestChannelPenalty.isProbeDue(for: bundleId, at: Date()) + } + + func claimSnapshotXCTestRecoveryProbe(bundleId: String?) { + snapshotXCTestChannelPenaltyLock.lock() + snapshotXCTestChannelPenalty.claimProbe(at: Date()) + snapshotXCTestChannelPenaltyLock.unlock() + NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_RECOVERY_PROBE bundle=%@", bundleId ?? "") + } + + /// The pre-seeded first-failure a penalized plan stamps into its verdict. The deferred case + /// uses the dedicated 'deferred' code: the breaker pre-selected the backend, nothing new + /// degraded on this capture, and the daemon keys warning suppression and the settle budget + /// reset off exactly that distinction. The bounded probe keeps 'budget': its short XCTest + /// slice genuinely constrains what this capture could read. + /// + /// `requestPinnedBackend` separates the two ways the plan can arrive at the same + /// deferred shape. A capture that ASKED for a private-AX-only reading (custom + /// actions) degraded nothing, so it seeds no failure at all — reporting slow + /// accessibility work would name a cause that does not exist. Its answer comes from + /// `snapshotPlanPreSelectionReason`. + static func xcTestChannelStateFirstFailure( + _ state: SnapshotXCTestChannelPlanState, + requestPinnedBackend: Bool = false + ) -> (reason: String, code: String)? { + switch state { + case .normal: + return nil + case .deferredToIndependentBackend: + if requestPinnedBackend { + return nil + } + return ( + "XCTest-backed snapshot tiers were deferred after recent slow accessibility work on this screen", + "deferred" + ) + case .boundedXCTestProbe: + return ( + "XCTest-backed snapshot tiers are running with a short recovery probe after recent slow accessibility work on this screen", + "budget" + ) + } + } + + /// The 'requested-backend' answer for a plan the request itself pinned: the recursive tree the + /// caller asked for, or the private AX the custom-actions reading requires. A pin names a plan + /// decision, not a failure, so it never enters the failure slot; `planVerdictReason` reports it + /// only while no tier failed on this capture. + static func snapshotPlanPreSelectionReason( + _ state: SnapshotXCTestChannelPlanState, + requestPinnedBackend: Bool = false, + preferredBackend: String? = nil + ) -> (reason: String, code: String)? { + if state == .normal && preferredBackend == SnapshotBackendKind.recursiveTree.rawValue { + return ( + "the recursive XCTest tree backend was explicitly selected for this capture", + "requested-backend" + ) + } + if state == .deferredToIndependentBackend && requestPinnedBackend { + return ( + "the private AX backend was selected because this capture asked for accessibility custom actions", + "requested-backend" + ) + } + return nil + } + + /// The reason a capture's verdict carries: the first tier failure when one exists, otherwise the + /// pre-selection the plan started with. Once an XCTest-backed tier ran behind a deferred plan, + /// it ran on the bounded probe's short slice, so the capture reports that constraint ('budget') + /// whether the tier recovered it or the plan ended sparse, rather than the pre-selection + /// ('deferred') the plan was seeded with. + static func planVerdictReason( + xCTestTierRan: Bool, + state: SnapshotXCTestChannelPlanState, + firstFailure: (reason: String, code: String)?, + preSelection: (reason: String, code: String)? + ) -> (reason: String, code: String)? { + guard xCTestTierRan, state == .deferredToIndependentBackend else { return firstFailure ?? preSelection } + return xcTestChannelStateFirstFailure(.boundedXCTestProbe) + } + + /// Pure gate: a capture is planned as penalized when the channel penalty is + /// active OR the daemon pinned the private-AX backend (same-backend evidence + /// probe) — both mean "do not enter XCTest tree work first, and stamp the + /// pre-selection as 'deferred' rather than a degradation". + static func snapshotXCTestChannelTreatedAsPenalized( + penalized: Bool, + preferredBackend: String? + ) -> Bool { + penalized || preferredBackend == SnapshotBackendKind.privateAX.rawValue + } + + /// Pure plan-reorder rule: an internal preferred backend pins the regular plan to that backend; + /// this is the only force seam used by same-backend evidence and conformance captures. A + /// penalized XCTest accessibility channel uses independent backends when the platform has one, + /// otherwise it keeps XCTest work on a short probe. A due recovery probe puts that short tree + /// ahead of the independent backend, and only that leading tree step claims the probe. The raw + /// diagnostic plan keeps tree-first errors, and unknown plans are left untouched. Each step + /// carries the execution mode this rule chose; nothing downstream re-derives it from position. + static func effectiveSnapshotCapturePlan( + _ plan: [SnapshotBackendKind], + xCTestChannelPenalized: Bool, + xCTestRecoveryProbeDue: Bool = false, + availableBackends: Set = Set(SnapshotBackendKind.allCases), + preferredBackend: String? = nil + ) -> EffectiveSnapshotCapturePlan { + if + plan == Self.regularVisiblePlan, + let preferred = preferredBackend.flatMap(SnapshotBackendKind.init(rawValue:)), + preferred.isForceable, + availableBackends.contains(preferred) + { + let state: SnapshotXCTestChannelPlanState = preferred == .privateAX ? .deferredToIndependentBackend : .normal + return EffectiveSnapshotCapturePlan( + steps: Self.snapshotCaptureSteps(for: [preferred], state: state, probeLeads: false), + xCTestChannelState: state, + preferredBackend: preferred + ) + } + guard xCTestChannelPenalized, plan == Self.regularVisiblePlan else { + return EffectiveSnapshotCapturePlan( + steps: Self.snapshotCaptureSteps(for: plan, state: .normal, probeLeads: false), + xCTestChannelState: .normal, + preferredBackend: nil + ) + } + let availablePlan = plan.filter { availableBackends.contains($0) } + let recoveryPlan = availablePlan.filter { !$0.usesXCTestAccessibilityChannel } + if !recoveryPlan.isEmpty { + // The independent backend reads only the app it can match as the active AX application, so + // an out-of-process surface over the app (the Save Password sheet) leaves it empty for the + // whole penalty. The tree, on the bounded probe's short slice, stays behind it, or leads when + // the recovery probe is due; the query sweep does not, since its grind is what the penalty + // keeps off the main thread. + let boundedTree = availablePlan.filter { $0 == .recursiveTree } + let probeLeads = xCTestRecoveryProbeDue && !boundedTree.isEmpty + return EffectiveSnapshotCapturePlan( + steps: Self.snapshotCaptureSteps( + for: probeLeads ? boundedTree + recoveryPlan : recoveryPlan + boundedTree, + state: .deferredToIndependentBackend, + probeLeads: probeLeads + ), + xCTestChannelState: .deferredToIndependentBackend, + preferredBackend: nil + ) + } + return EffectiveSnapshotCapturePlan( + steps: Self.snapshotCaptureSteps( + for: availablePlan.filter(\.usesXCTestAccessibilityChannel), + state: .boundedXCTestProbe, + probeLeads: false + ), + xCTestChannelState: .boundedXCTestProbe, + preferredBackend: nil + ) + } + + /// Factory: one step per planned backend, each carrying the execution mode the penalized state + /// chose. A normal plan runs ordinary attempts; any penalized state runs every tier as bounded + /// recovery, so no attempt from it can arm or renew the penalty it is recovering from. Only the + /// leading XCTest step of the deferred plan that is running the due probe claims the probe. + static func snapshotCaptureSteps( + for plan: [SnapshotBackendKind], + state: SnapshotXCTestChannelPlanState, + probeLeads: Bool + ) -> [SnapshotCaptureStep] { + plan.enumerated().map { offset, kind in + guard state != .normal else { return .ordinary(kind) } + return .boundedRecovery(kind, claimsPeriodicProbe: probeLeads && offset == 0) + } + } + + /// Why an ordinary XCTest attempt shows a hostile channel, or nil. A bounded recovery attempt + /// has no arming branch here at all: forgetting that would let a probe renew its own penalty, + /// which is exactly the self-renewal #3896 removed at the recorder. + /// + /// The breaker observes acquisition facts only. Presentation is a separate phase and cannot arm + /// the breaker, even when it is slower than the acquisition that produced the tree. + static func snapshotXCTestPenaltyReason( + for attempt: SnapshotBackendAttempt, + slowThresholdMs: Double + ) -> String? { + guard case .ordinary = attempt.step.execution else { return nil } + let kind = attempt.step.backend + guard kind.usesXCTestAccessibilityChannel else { return nil } + if case let .failed(failure, phase: .acquisition) = attempt.outcome, + failure.code == Self.xCTestSnapshotTimeoutCode + { + return "\(kind.rawValue)_backend_timeout" + } + if attempt.tierOutcome == .deadlineExhausted { + return "\(kind.rawValue)_backend_timeout" + } + guard attempt.timing.acquisitionMs > slowThresholdMs else { return nil } + return "slow_\(kind.rawValue)_capture_\(Int(attempt.timing.acquisitionMs))ms" + } + + /// Only an accepted, fast tree from a bounded recovery attempt proves that the channel recovered + /// and clears the penalty early. An ordinary tree capture never clears it; it can only arm it. + static func snapshotBoundedRecoveryRestoredChannel(_ attempt: SnapshotBackendAttempt) -> Bool { + guard case .boundedRecovery = attempt.step.execution, + attempt.step.backend == .recursiveTree, + attempt.timing.acquisitionMs <= SnapshotCaptureStep.boundedTreeSliceBudget * 1_000, + case let .captured(capture) = attempt.outcome + else { return false } + return Self.snapshotTierRejectionReason( + outcome: attempt.tierOutcome, + kind: attempt.step.backend, + payload: capture.qualityPayload ?? capture.payload + ) == nil + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift index 86b8454926..2b1526a36a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift @@ -87,6 +87,9 @@ final class SnapshotXCTestPenaltyWarmupExemption { } extension RunnerTests { + /// What one planned step did, recording the step that executed it. The step travels with the + /// attempt, so penalty policy reads the authority the work ran under instead of a parameter a + /// caller could omit or default (`RunnerTests+SnapshotChannelPolicy.swift`). struct SnapshotBackendAttempt { enum Outcome { case noCapture @@ -94,6 +97,7 @@ extension RunnerTests { case failed(SnapshotCaptureFailure, phase: SnapshotCapturePhase) } + let step: SnapshotCaptureStep /// The failure phase is part of the result, so penalty policy cannot infer it from duration /// or error text. let outcome: Outcome @@ -104,77 +108,36 @@ extension RunnerTests { let tierOutcome: SnapshotTierOutcome init( + step: SnapshotCaptureStep, outcome: Outcome, timing: SnapshotCaptureTiming, tierOutcome: SnapshotTierOutcome = .completed ) { + self.step = step self.outcome = outcome self.timing = timing self.tierOutcome = tierOutcome } } - /// The penalty breaker observes only acquisition facts. Presentation is a separate phase and - /// cannot arm the breaker, even when it is slower than the acquisition that produced the tree. - static func snapshotXCTestPenaltyReason( - kind: SnapshotBackendKind, - attempt: SnapshotBackendAttempt, - slowThresholdMs: Double, - channelState: SnapshotXCTestChannelPlanState = .normal - ) -> String? { - guard channelState == .normal, kind.usesXCTestAccessibilityChannel else { return nil } - if case let .failed(failure, phase: .acquisition) = attempt.outcome, - failure.code == Self.xCTestSnapshotTimeoutCode - { - return "\(kind.rawValue)_backend_timeout" - } - if attempt.tierOutcome == .deadlineExhausted { - return "\(kind.rawValue)_backend_timeout" - } - guard attempt.timing.acquisitionMs > slowThresholdMs else { return nil } - return "slow_\(kind.rawValue)_capture_\(Int(attempt.timing.acquisitionMs))ms" - } - - /// Only an accepted, fast tree proves that a bounded recovery probe restored the channel. - static func snapshotXCTestRecoveryProbeSucceeded( - kind: SnapshotBackendKind, - attempt: SnapshotBackendAttempt, - channelState: SnapshotXCTestChannelPlanState - ) -> Bool { - guard channelState != .normal, - kind == .recursiveTree, - attempt.timing.acquisitionMs <= Self.penalizedXCTestProbeTreeSliceBudget * 1_000, - case let .captured(capture) = attempt.outcome - else { return false } - return Self.snapshotTierRejectionReason( - outcome: attempt.tierOutcome, - kind: kind, - payload: capture.qualityPayload ?? capture.payload - ) == nil - } - + /// Records what one attempt shows about the XCTest channel, given the step that ran it. An + /// ordinary attempt is the only one with an arming branch; a bounded recovery attempt can only + /// clear the penalty by answering with an accepted fast tree. The warmup exemption suppresses + /// arming on the first plan against a fresh process, never the early clear. func recordXCTestSnapshotBackendAttemptIfNeeded( - _ kind: SnapshotBackendKind, attempt: SnapshotBackendAttempt, bundleId: String?, - penaltySuppressed: Bool, - channelState: SnapshotXCTestChannelPlanState + penaltySuppressed: Bool ) { guard !penaltySuppressed else { return } - if Self.snapshotXCTestRecoveryProbeSucceeded( - kind: kind, - attempt: attempt, - channelState: channelState - ) { + if Self.snapshotBoundedRecoveryRestoredChannel(attempt) { clearSnapshotXCTestChannelPenalty(reason: "healthy_tree_recovery_probe") return } guard let reason = Self.snapshotXCTestPenaltyReason( - kind: kind, - attempt: attempt, - slowThresholdMs: snapshotXCTestSlowCaptureThreshold * 1_000, - channelState: channelState + for: attempt, + slowThresholdMs: snapshotXCTestSlowCaptureThreshold * 1_000 ) else { return } penalizeSnapshotXCTestChannel( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift index 9f7e97ece6..9b5b7d9508 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift @@ -448,7 +448,7 @@ extension RunnerTests { XCTAssertEqual(quality.backend, SnapshotBackendKind.recursiveTree.rawValue) XCTAssertGreaterThan(payload.nodes?.count ?? 0, 1, "the bounded tree answers with a real tree") let timing = try XCTUnwrap(quality.timing) - guard timing.acquisitionMs <= Self.penalizedXCTestProbeTreeSliceBudget * 1_000 else { + guard timing.acquisitionMs <= SnapshotCaptureStep.boundedTreeSliceBudget * 1_000 else { throw XCTSkip("the accepted tree's total acquisition exceeded the fast-probe threshold on this host") } XCTAssertFalse( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift index 34b827b91b..764acf1c3e 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift @@ -498,7 +498,10 @@ extension RunnerTests { ) XCTAssertEqual(regular.plan, [.privateAX, .recursiveTree]) XCTAssertEqual(regular.xCTestChannelState, .deferredToIndependentBackend) - XCTAssertEqual(regular.treeCaptureSliceBudgetOverride, Self.penalizedXCTestProbeTreeSliceBudget) + XCTAssertEqual( + regular.steps.map(\.treeCaptureSliceBudgetOverride), + [nil, SnapshotCaptureStep.boundedTreeSliceBudget] + ) let unpenalized = Self.effectiveSnapshotCapturePlan( Self.regularVisiblePlan, @@ -506,7 +509,7 @@ extension RunnerTests { ) XCTAssertEqual(unpenalized.plan, Self.regularVisiblePlan) XCTAssertEqual(unpenalized.xCTestChannelState, .normal) - XCTAssertNil(unpenalized.treeCaptureSliceBudgetOverride) + XCTAssertNil(unpenalized.steps.first?.treeCaptureSliceBudgetOverride) // The raw diagnostic plan preserves tree-first error propagation even under penalty. let raw = Self.effectiveSnapshotCapturePlan( @@ -515,15 +518,16 @@ extension RunnerTests { ) XCTAssertEqual(raw.plan, Self.rawDiagnosticPlan) XCTAssertEqual(raw.xCTestChannelState, .normal) - XCTAssertNil(raw.treeCaptureSliceBudgetOverride) + XCTAssertNil(raw.steps.first?.treeCaptureSliceBudgetOverride) } func testPlanVerdictReasonNamesTheBoundedProbeOnceAnXCTestTierRanBehindPrivateAX() { + // The runner seeds a deferred plan's `deferred` into the failure slot, not the pre-selection. let deferred = Self.xcTestChannelStateFirstFailure(.deferredToIndependentBackend) XCTAssertEqual( Self.planVerdictReason( xCTestTierRan: true, state: .deferredToIndependentBackend, - firstFailure: deferred, preSelection: deferred + firstFailure: deferred, preSelection: nil )?.code, "budget" ) @@ -534,11 +538,11 @@ extension RunnerTests { let pin = Self.snapshotPlanPreSelectionReason( .normal, preferredBackend: SnapshotBackendKind.recursiveTree.rawValue) XCTAssertEqual(pin?.code, "requested-backend") - // Nothing failed: the pre-selection owns the verdict. + // Nothing failed on this capture: the breaker's seed owns the verdict from the failure slot. XCTAssertEqual( Self.planVerdictReason( xCTestTierRan: false, state: .deferredToIndependentBackend, - firstFailure: nil, preSelection: deferred + firstFailure: deferred, preSelection: nil )?.code, "deferred" ) @@ -571,8 +575,8 @@ extension RunnerTests { XCTAssertEqual(physicalDevicePlan.plan, [.recursiveTree, .querySweep]) XCTAssertEqual(physicalDevicePlan.xCTestChannelState, .boundedXCTestProbe) XCTAssertEqual( - physicalDevicePlan.treeCaptureSliceBudgetOverride, - Self.penalizedXCTestProbeTreeSliceBudget + physicalDevicePlan.steps.map(\.treeCaptureSliceBudgetOverride), + [SnapshotCaptureStep.boundedTreeSliceBudget, nil] ) } @@ -657,75 +661,92 @@ extension RunnerTests { planTestNode(index: 1, type: "Button", label: "Open", hittable: true, parentIndex: 0), ], truncated: false) let healthy = SnapshotBackendCapture(payload: payload, effectiveDepth: nil) - let fast = SnapshotBackendAttempt( - outcome: .captured(healthy), - timing: SnapshotCaptureTiming(acquisitionMs: 100, presentationMs: 10) - ) - for state in [SnapshotXCTestChannelPlanState.deferredToIndependentBackend, .boundedXCTestProbe] { - XCTAssertTrue(Self.snapshotXCTestRecoveryProbeSucceeded( - kind: .recursiveTree, attempt: fast, channelState: state - )) - for kind in [SnapshotBackendKind.querySweep, .privateAX] { - XCTAssertFalse(Self.snapshotXCTestRecoveryProbeSucceeded( - kind: kind, attempt: fast, channelState: state - )) - } - XCTAssertTrue(Self.snapshotXCTestRecoveryProbeSucceeded( - kind: .recursiveTree, - attempt: SnapshotBackendAttempt(outcome: .captured(healthy), timing: SnapshotCaptureTiming(acquisitionMs: 1_000, presentationMs: 10)), - channelState: state + func bounded( + _ kind: SnapshotBackendKind, + outcome: SnapshotBackendAttempt.Outcome, + acquisitionMs: Double, + tierOutcome: SnapshotTierOutcome = .completed + ) -> SnapshotBackendAttempt { + SnapshotBackendAttempt( + step: .boundedRecovery(kind), + outcome: outcome, + timing: SnapshotCaptureTiming(acquisitionMs: acquisitionMs, presentationMs: 10), + tierOutcome: tierOutcome + ) + } + XCTAssertTrue(Self.snapshotBoundedRecoveryRestoredChannel( + bounded(.recursiveTree, outcome: .captured(healthy), acquisitionMs: 100) + )) + for kind in [SnapshotBackendKind.querySweep, .privateAX] { + XCTAssertFalse(Self.snapshotBoundedRecoveryRestoredChannel( + bounded(kind, outcome: .captured(healthy), acquisitionMs: 100) )) - let rejected = [ - SnapshotBackendAttempt(outcome: .captured(healthy), timing: fast.timing, tierOutcome: .deadlineExhausted), - SnapshotBackendAttempt(outcome: .noCapture, timing: fast.timing), - SnapshotBackendAttempt(outcome: .captured(healthy), timing: SnapshotCaptureTiming(acquisitionMs: 1_001, presentationMs: 0)), - SnapshotBackendAttempt(outcome: .captured(SnapshotBackendCapture( - payload: payload, effectiveDepth: nil, qualityPayload: DataPayload(nodes: [], truncated: false) - )), timing: fast.timing), - ] - for attempt in rejected { - XCTAssertFalse(Self.snapshotXCTestRecoveryProbeSucceeded( - kind: .recursiveTree, attempt: attempt, channelState: state - )) - } } - XCTAssertFalse(Self.snapshotXCTestRecoveryProbeSucceeded( - kind: .recursiveTree, attempt: fast, channelState: .normal + XCTAssertTrue(Self.snapshotBoundedRecoveryRestoredChannel( + bounded(.recursiveTree, outcome: .captured(healthy), acquisitionMs: 1_000) )) + let rejected = [ + bounded(.recursiveTree, outcome: .captured(healthy), acquisitionMs: 100, tierOutcome: .deadlineExhausted), + bounded(.recursiveTree, outcome: .noCapture, acquisitionMs: 100), + bounded(.recursiveTree, outcome: .captured(healthy), acquisitionMs: 1_001), + bounded(.recursiveTree, outcome: .captured(SnapshotBackendCapture( + payload: payload, effectiveDepth: nil, qualityPayload: DataPayload(nodes: [], truncated: false) + )), acquisitionMs: 100), + ] + for attempt in rejected { + XCTAssertFalse(Self.snapshotBoundedRecoveryRestoredChannel(attempt)) + } + // An ordinary accepted fast tree never clears the penalty; it is the attempt the arming + // branch reads, not a recovery signal. + XCTAssertFalse(Self.snapshotBoundedRecoveryRestoredChannel(SnapshotBackendAttempt( + step: .ordinary(.recursiveTree), + outcome: .captured(healthy), + timing: SnapshotCaptureTiming(acquisitionMs: 100, presentationMs: 10) + ))) } + /// The probe's claim authority belongs to the step that starts it, not to a plan position: + /// only the leading tree step of the due deferred Simulator plan claims the periodic probe. + /// The physical-device bounded plan probes on every capture without a claim, and pins and the + /// raw plan never gain the sweep or a claim. func testDueRecoveryProbeLeadsOnlyTheDeferredRegularPlanAndNeverAddsTheSweep() { let due = Self.effectiveSnapshotCapturePlan( Self.regularVisiblePlan, xCTestChannelPenalized: true, xCTestRecoveryProbeDue: true ) XCTAssertEqual(due.plan, [.recursiveTree, .privateAX]) XCTAssertEqual(due.xCTestChannelState, .deferredToIndependentBackend) - XCTAssertEqual(due.treeCaptureSliceBudgetOverride, Self.penalizedXCTestProbeTreeSliceBudget) - XCTAssertTrue(due.leadsWithXCTestRecoveryProbe) + XCTAssertEqual( + due.steps.map(\.treeCaptureSliceBudgetOverride), + [SnapshotCaptureStep.boundedTreeSliceBudget, nil] + ) + XCTAssertEqual(due.steps.map(\.claimsPeriodicRecoveryProbe), [true, false]) let notDue = Self.effectiveSnapshotCapturePlan(Self.regularVisiblePlan, xCTestChannelPenalized: true) XCTAssertEqual(notDue.plan, [.privateAX, .recursiveTree]) - XCTAssertFalse(notDue.leadsWithXCTestRecoveryProbe) + XCTAssertEqual(notDue.steps.map(\.claimsPeriodicRecoveryProbe), [false, false]) let physicalDevice = Self.effectiveSnapshotCapturePlan( Self.regularVisiblePlan, xCTestChannelPenalized: true, xCTestRecoveryProbeDue: true, availableBackends: [.recursiveTree, .querySweep] ) XCTAssertEqual(physicalDevice.plan, [.recursiveTree, .querySweep]) - XCTAssertFalse(physicalDevice.leadsWithXCTestRecoveryProbe, "the bounded plan probes on every capture") + XCTAssertEqual( + physicalDevice.steps.map(\.claimsPeriodicRecoveryProbe), [false, false], + "the bounded plan probes on every capture" + ) for pinned in ["private-ax", "tree"] { let plan = Self.effectiveSnapshotCapturePlan( Self.regularVisiblePlan, xCTestChannelPenalized: true, xCTestRecoveryProbeDue: true, preferredBackend: pinned ) XCTAssertEqual(plan.plan.count, 1) - XCTAssertFalse(plan.leadsWithXCTestRecoveryProbe) + XCTAssertEqual(plan.steps.map(\.claimsPeriodicRecoveryProbe), [false]) } let raw = Self.effectiveSnapshotCapturePlan( Self.rawDiagnosticPlan, xCTestChannelPenalized: true, xCTestRecoveryProbeDue: true ) XCTAssertEqual(raw.plan, Self.rawDiagnosticPlan) - XCTAssertFalse(raw.leadsWithXCTestRecoveryProbe) + XCTAssertEqual(raw.steps.map(\.claimsPeriodicRecoveryProbe), [false, false]) } func testChannelPenaltyProbeIsDueEachIntervalUntilItExpiresOrClears() { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTimingTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTimingTests.swift index 9a5033934b..771f779aa3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTimingTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTimingTests.swift @@ -5,25 +5,25 @@ import XCTest extension RunnerTests { func testXCTestPenaltyDecisionSeparatesAcquisitionAndPresentation() { let slowPresentation = SnapshotBackendAttempt( + step: .ordinary(.recursiveTree), outcome: .noCapture, timing: SnapshotCaptureTiming(acquisitionMs: 100, presentationMs: 4_000) ) XCTAssertNil( Self.snapshotXCTestPenaltyReason( - kind: .recursiveTree, - attempt: slowPresentation, + for: slowPresentation, slowThresholdMs: 3_000 ) ) let slowAcquisition = SnapshotBackendAttempt( + step: .ordinary(.recursiveTree), outcome: .noCapture, timing: SnapshotCaptureTiming(acquisitionMs: 3_001, presentationMs: 100) ) XCTAssertEqual( Self.snapshotXCTestPenaltyReason( - kind: .recursiveTree, - attempt: slowAcquisition, + for: slowAcquisition, slowThresholdMs: 3_000 ), "slow_tree_capture_3001ms" @@ -35,26 +35,26 @@ extension RunnerTests { hint: "test" ) let acquisitionFailure = SnapshotBackendAttempt( + step: .ordinary(.recursiveTree), outcome: .failed(timeout, phase: .acquisition), timing: SnapshotCaptureTiming(acquisitionMs: 100, presentationMs: 100) ) XCTAssertEqual( Self.snapshotXCTestPenaltyReason( - kind: .recursiveTree, - attempt: acquisitionFailure, + for: acquisitionFailure, slowThresholdMs: 3_000 ), "tree_backend_timeout" ) let presentationFailure = SnapshotBackendAttempt( + step: .ordinary(.recursiveTree), outcome: .failed(timeout, phase: .presentation), timing: SnapshotCaptureTiming(acquisitionMs: 100, presentationMs: 100) ) XCTAssertNil( Self.snapshotXCTestPenaltyReason( - kind: .recursiveTree, - attempt: presentationFailure, + for: presentationFailure, slowThresholdMs: 3_000 ) ) @@ -69,36 +69,41 @@ extension RunnerTests { effectiveDepth: nil ) let exhausted = SnapshotBackendAttempt( + step: .ordinary(.querySweep), outcome: .captured(capture), timing: SnapshotCaptureTiming(acquisitionMs: 1_000, presentationMs: 10), tierOutcome: .deadlineExhausted ) XCTAssertEqual( Self.snapshotXCTestPenaltyReason( - kind: .querySweep, - attempt: exhausted, + for: exhausted, slowThresholdMs: 3_000 ), "queries_backend_timeout" ) let completed = SnapshotBackendAttempt( + step: .ordinary(.querySweep), outcome: .captured(capture), timing: SnapshotCaptureTiming(acquisitionMs: 1_000, presentationMs: 10), tierOutcome: .completed ) XCTAssertNil( Self.snapshotXCTestPenaltyReason( - kind: .querySweep, - attempt: completed, + for: completed, slowThresholdMs: 3_000 ) ) + let unownedExhausted = SnapshotBackendAttempt( + step: .ordinary(.privateAX), + outcome: exhausted.outcome, + timing: exhausted.timing, + tierOutcome: exhausted.tierOutcome + ) XCTAssertNil( Self.snapshotXCTestPenaltyReason( - kind: .privateAX, - attempt: exhausted, + for: unownedExhausted, slowThresholdMs: 3_000 ), "a tier that owes nothing to the XCTest channel cannot penalize it" @@ -128,68 +133,84 @@ extension RunnerTests { code: Self.xCTestSnapshotTimeoutCode, message: "same timeout text", hint: "test" ) for outcome in [SnapshotBackendAttempt.Outcome.failed(timeout, phase: .acquisition), .noCapture] { - let short = SnapshotBackendAttempt( - outcome: outcome, - timing: SnapshotCaptureTiming(acquisitionMs: 1_000, presentationMs: 10), - tierOutcome: .deadlineExhausted - ) + let short = { (kind: SnapshotBackendKind) in + SnapshotBackendAttempt( + step: .ordinary(kind), + outcome: outcome, + timing: SnapshotCaptureTiming(acquisitionMs: 1_000, presentationMs: 10), + tierOutcome: .deadlineExhausted + ) + } XCTAssertEqual(Self.snapshotXCTestPenaltyReason( - kind: .querySweep, attempt: short, slowThresholdMs: 3_000 + for: short(.querySweep), slowThresholdMs: 3_000 ), "queries_backend_timeout") XCTAssertEqual(Self.snapshotXCTestPenaltyReason( - kind: .recursiveTree, attempt: short, slowThresholdMs: 3_000 + for: short(.recursiveTree), slowThresholdMs: 3_000 ), "tree_backend_timeout") - let slow = SnapshotBackendAttempt( - outcome: outcome, - timing: SnapshotCaptureTiming(acquisitionMs: 3_001, presentationMs: 10), - tierOutcome: .deadlineExhausted - ) + let slow = { (kind: SnapshotBackendKind) in + SnapshotBackendAttempt( + step: .ordinary(kind), + outcome: outcome, + timing: SnapshotCaptureTiming(acquisitionMs: 3_001, presentationMs: 10), + tierOutcome: .deadlineExhausted + ) + } XCTAssertEqual(Self.snapshotXCTestPenaltyReason( - kind: .querySweep, attempt: slow, slowThresholdMs: 3_000 + for: slow(.querySweep), slowThresholdMs: 3_000 ), "queries_backend_timeout") } let untyped = SnapshotBackendAttempt( + step: .ordinary(.recursiveTree), outcome: .failed(SnapshotCaptureFailure( code: "OTHER_FAILURE", message: "same timeout text", hint: "test" ), phase: .acquisition), timing: SnapshotCaptureTiming(acquisitionMs: 100, presentationMs: 0) ) XCTAssertNil(Self.snapshotXCTestPenaltyReason( - kind: .recursiveTree, attempt: untyped, slowThresholdMs: 3_000 + for: untyped, slowThresholdMs: 3_000 )) } + /// The shape #3896 removed and this refactor makes unreachable: an attempt produced by a + /// bounded-recovery step — the leading probe or the trailing bounded tier, on either the + /// Simulator deferred plan or the physical-device bounded plan — has no arming branch at all, + /// whatever its timing or failure. The attempt carries its step, so there is no channel-state + /// argument left to forget. Ordinary tree attempts must still penalize on the same facts. func testBoundedRecoveryAttemptsCannotRenewPenaltyOnEitherPlatformPlan() { let timeout = SnapshotCaptureFailure( code: Self.xCTestSnapshotTimeoutCode, message: "test timeout", hint: "test" ) - let attempts = [ - SnapshotBackendAttempt( - outcome: .failed(timeout, phase: .acquisition), - timing: SnapshotCaptureTiming(acquisitionMs: 1_000, presentationMs: 0) - ), - SnapshotBackendAttempt( - outcome: .noCapture, - timing: SnapshotCaptureTiming(acquisitionMs: 1_000, presentationMs: 0), - tierOutcome: .deadlineExhausted - ), - SnapshotBackendAttempt( - outcome: .noCapture, - timing: SnapshotCaptureTiming(acquisitionMs: 3_001, presentationMs: 0) - ), + let attempts: [( + outcome: SnapshotBackendAttempt.Outcome, timing: SnapshotCaptureTiming, tier: SnapshotTierOutcome + )] = [ + (.failed(timeout, phase: .acquisition), SnapshotCaptureTiming(acquisitionMs: 1_000, presentationMs: 0), .completed), + (.noCapture, SnapshotCaptureTiming(acquisitionMs: 1_000, presentationMs: 0), .deadlineExhausted), + (.noCapture, SnapshotCaptureTiming(acquisitionMs: 3_001, presentationMs: 0), .completed), ] - for state in [SnapshotXCTestChannelPlanState.deferredToIndependentBackend, .boundedXCTestProbe] { + for claimsProbe in [true, false] { for kind in [SnapshotBackendKind.recursiveTree, .querySweep] { for attempt in attempts { XCTAssertNil(Self.snapshotXCTestPenaltyReason( - kind: kind, attempt: attempt, slowThresholdMs: 3_000, channelState: state + for: SnapshotBackendAttempt( + step: .boundedRecovery(kind, claimsPeriodicProbe: claimsProbe), + outcome: attempt.outcome, + timing: attempt.timing, + tierOutcome: attempt.tier + ), + slowThresholdMs: 3_000 )) } } } for attempt in attempts { XCTAssertNotNil(Self.snapshotXCTestPenaltyReason( - kind: .recursiveTree, attempt: attempt, slowThresholdMs: 3_000 + for: SnapshotBackendAttempt( + step: .ordinary(.recursiveTree), + outcome: attempt.outcome, + timing: attempt.timing, + tierOutcome: attempt.tier + ), + slowThresholdMs: 3_000 ), "ordinary tree timeouts and slow acquisitions must still penalize") } } From f873db3629012df3e696e4c826cead330d9d099b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 25 Oct 2026 11:56:57 +0100 Subject: [PATCH 2/5] refactor(ios): encode the probe claim in the step factory Bind the periodic-probe claim to an XCTest-backed leading step at the factory, so the mode cannot hand claim authority to a non-XCTest step. Align the warmup-exemption and claim-ordering comments with the code, and drop repeated narration of the step-carries-authority constraint. --- .../RunnerTests+SnapshotCapturePlan.swift | 4 ++-- .../RunnerTests+SnapshotChannelPolicy.swift | 10 +++++----- .../RunnerTests+SnapshotTiming.swift | 7 +++---- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index 19783a2ee6..33e3f7cdc9 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -215,8 +215,6 @@ extension RunnerTests { } // While abandoned main-thread work is still grinding inside testmanagerd, XCTest-backed // tiers would queue behind it; only independent backends stay responsive (#1105). - // A skipped step starts nothing, so it does not consume the due probe either; the claim - // stays synchronous here in the loop, before the work starts, never after the reply. if shouldSkipSnapshotBackendForAbandonedMainThreadWork(kind) { NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_TIER_SKIPPED_XCTEST_OCCUPIED tier=%@", kind.rawValue) if firstFailure == nil { @@ -227,6 +225,8 @@ extension RunnerTests { } continue } + // The claim is synchronous, before the work starts: a step that is skipped above starts + // nothing and must leave the due probe for the next plan. if step.claimsPeriodicRecoveryProbe { claimSnapshotXCTestRecoveryProbe(bundleId: target.bundleId) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift index 11d4b169e6..be2cefd3ed 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift @@ -3,10 +3,9 @@ import Foundation // MARK: - XCTest accessibility channel policy (ADR 0004, #1105/#1156/#3896) // // One owner for what a capture must know about the XCTest channel's health: the penalty value, -// the plan-reorder rule, and the execution mode each plan step carries. A step is built by the -// factory once and travels with the attempt it produces, so a bounded recovery attempt cannot -// reach the penalty recorder looking like an ordinary one. The old interface accepted a defaulted -// `.normal` channel state at evidence time, which is how #3896's self-renewal bug was reachable. +// the plan-reorder rule, and the execution mode each plan step carries. The old interface accepted +// a defaulted `.normal` channel state at evidence time, which is how #3896's self-renewal bug was +// reachable. /// What the plan runner does with one backend on this capture. The penalty recorder reads this, /// never a re-derived plan position, to decide which evidence an attempt may produce. @@ -300,7 +299,8 @@ extension RunnerTests { ) -> [SnapshotCaptureStep] { plan.enumerated().map { offset, kind in guard state != .normal else { return .ordinary(kind) } - return .boundedRecovery(kind, claimsPeriodicProbe: probeLeads && offset == 0) + let claims = probeLeads && offset == 0 && kind.usesXCTestAccessibilityChannel + return .boundedRecovery(kind, claimsPeriodicProbe: claims) } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift index 2b1526a36a..7400acd7e8 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift @@ -87,9 +87,8 @@ final class SnapshotXCTestPenaltyWarmupExemption { } extension RunnerTests { - /// What one planned step did, recording the step that executed it. The step travels with the - /// attempt, so penalty policy reads the authority the work ran under instead of a parameter a - /// caller could omit or default (`RunnerTests+SnapshotChannelPolicy.swift`). + /// What one planned step did. The step travels with the attempt, so penalty policy reads the + /// authority the work ran under (`RunnerTests+SnapshotChannelPolicy.swift`). struct SnapshotBackendAttempt { enum Outcome { case noCapture @@ -123,7 +122,7 @@ extension RunnerTests { /// Records what one attempt shows about the XCTest channel, given the step that ran it. An /// ordinary attempt is the only one with an arming branch; a bounded recovery attempt can only /// clear the penalty by answering with an accepted fast tree. The warmup exemption suppresses - /// arming on the first plan against a fresh process, never the early clear. + /// both branches on the first plan against a fresh process. func recordXCTestSnapshotBackendAttemptIfNeeded( attempt: SnapshotBackendAttempt, bundleId: String?, From be6098bdab07d55a618936fa2f9c7f07d83a213d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 25 Oct 2026 12:13:43 +0100 Subject: [PATCH 3/5] refactor(ios): keep snapshot channel policy at constraint-only comments Collapse the step's execution mode into the step itself, drop the accessors the step made redundant, and trim the moved policy's comments to constraints the code cannot show. Net production lines vs main now shrink. --- .../RunnerTests+SnapshotCapturePlan.swift | 18 +-- .../RunnerTests+SnapshotChannelPolicy.swift | 141 +++++++----------- ...RunnerTests+SnapshotCapturePlanTests.swift | 28 ++-- 3 files changed, 68 insertions(+), 119 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index 33e3f7cdc9..9d63558106 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -52,21 +52,15 @@ enum SnapshotTierOutcome: Equatable { } /// The plan one capture runs: ordered steps, the channel state the selection chose, and the pin — -/// if any — that narrowed it. Each step carries its own backend and execution mode, so the runner -/// never infers recovery status from a position and the recorder never re-derives it from a -/// parallel argument. The selection cause stays separate from the execution mode: an explicit pin, -/// a custom-action pin, and a breaker deferral can share the deferred ordering while their verdict -/// explanations differ, and a pin is never an armed penalty. +/// if any — that narrowed it. The selection cause stays separate from the step's execution mode: +/// an explicit pin, a custom-action pin, and a breaker deferral can share the deferred ordering +/// while their verdict explanations differ, and a pin is never an armed penalty. struct EffectiveSnapshotCapturePlan { let steps: [SnapshotCaptureStep] let xCTestChannelState: SnapshotXCTestChannelPlanState /// Non-nil only when the plan was narrowed by an explicit internal backend preference. This /// keeps the quality marker tied to the plan decision rather than to an untrusted request field. let preferredBackend: SnapshotBackendKind? - - var plan: [SnapshotBackendKind] { - steps.map(\.backend) - } } /// What the plan runner does when every backend failed or stayed sparse. @@ -333,9 +327,6 @@ extension RunnerTests { return fallbackPayload } - /// Runs one planned step and returns the attempt carrying that same step, so the penalty - /// recorder sees the authority the work actually executed under. The tree tier's bounded slice - /// comes from the step; no caller passes a plan-wide budget override. private func captureWithBackend( _ step: SnapshotCaptureStep, target: SnapshotCaptureTarget, @@ -343,7 +334,6 @@ extension RunnerTests { deadline: Date ) throws -> SnapshotBackendAttempt { let kind = step.backend - let treeCaptureSliceBudgetOverride = step.treeCaptureSliceBudgetOverride let app = target.app let hint = SnapshotPresentation.captureHint(for: options) var timer = SnapshotPhaseTimer() @@ -361,7 +351,7 @@ extension RunnerTests { app: app, hint: hint, captureDeadline: deadline, - treeCaptureSliceBudgetOverride: treeCaptureSliceBudgetOverride + treeCaptureSliceBudgetOverride: step.treeCaptureSliceBudgetOverride ) else { return (nil, .completed) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift index be2cefd3ed..9d2f7b67b5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift @@ -2,53 +2,38 @@ import Foundation // MARK: - XCTest accessibility channel policy (ADR 0004, #1105/#1156/#3896) // -// One owner for what a capture must know about the XCTest channel's health: the penalty value, -// the plan-reorder rule, and the execution mode each plan step carries. The old interface accepted -// a defaulted `.normal` channel state at evidence time, which is how #3896's self-renewal bug was -// reachable. +// On some deep screens the XCTest bulk snapshot grinds for many seconds before failing; re-grinding +// on every capture buries the main thread past the watchdog. After a slow, timed-out, or abandoned +// XCTest-backed capture, later plans for the same bundle run bounded recovery tiers. -/// What the plan runner does with one backend on this capture. The penalty recorder reads this, -/// never a re-derived plan position, to decide which evidence an attempt may produce. -enum SnapshotCaptureExecutionMode: Equatable { +/// One executed step of an effective plan. The mode decides which evidence the attempt may produce +/// and travels with it, so evidence cannot reach the recorder detached from its authority (#3896). +enum SnapshotCaptureStep { /// A regular attempt: its acquisition facts may arm or renew the channel penalty. - case ordinary + case ordinary(SnapshotBackendKind) /// A bounded attempt inside a penalized plan: it may never arm or renew the penalty. - case boundedRecovery(claimsPeriodicProbe: Bool) -} + case boundedRecovery(SnapshotBackendKind, claimsPeriodicProbe: Bool) -/// One executed step of an effective plan: the backend together with its execution mode, built -/// once by the plan factory. The tree tier's bounded slice and the probe claim derive from the -/// mode, so no caller threads a plan-wide budget override or a positional rule beside the plan it -/// already carries. The penalty recorder reads the mode: only an ordinary attempt may produce -/// arming evidence. -struct SnapshotCaptureStep { - /// The whole-slice budget a bounded tree attempt gets while the channel is penalized. + /// The slice budget a bounded tree attempt gets while the channel is penalized. The query + /// sweep's slice has its own owner (`querySweepSliceDeadline`), which the penalty does not + /// shorten. static let boundedTreeSliceBudget: TimeInterval = 1 - let backend: SnapshotBackendKind - let execution: SnapshotCaptureExecutionMode - - static func ordinary(_ backend: SnapshotBackendKind) -> SnapshotCaptureStep { - SnapshotCaptureStep(backend: backend, execution: .ordinary) - } - - static func boundedRecovery( - _ backend: SnapshotBackendKind, - claimsPeriodicProbe: Bool = false - ) -> SnapshotCaptureStep { - SnapshotCaptureStep(backend: backend, execution: .boundedRecovery(claimsPeriodicProbe: claimsPeriodicProbe)) + var backend: SnapshotBackendKind { + switch self { + case let .ordinary(kind): return kind + case let .boundedRecovery(kind, _): return kind + } } - /// The tree tier's bounded slice, from this step's own mode. The query sweep's slice has its own - /// owner (`querySweepSliceDeadline`), which the penalty does not shorten. var treeCaptureSliceBudgetOverride: TimeInterval? { - guard backend == .recursiveTree, execution != .ordinary else { return nil } + guard backend == .recursiveTree, case .boundedRecovery = self else { return nil } return Self.boundedTreeSliceBudget } - /// Only the leading step of the deferred plan that is running the due probe claims it. + /// Only the leading step of the deferred plan running the due probe claims it. var claimsPeriodicRecoveryProbe: Bool { - if case let .boundedRecovery(claims) = execution { return claims } + if case let .boundedRecovery(_, claims) = self { return claims } return false } } @@ -59,10 +44,9 @@ enum SnapshotXCTestChannelPlanState: Equatable { case boundedXCTestProbe } -/// The XCTest accessibility channel penalty (#1105/#1156). While it is active, regular plans for its -/// bundle defer XCTest tiers. Each recovery interval, one deferred plan runs the bounded tree probe -/// first, inside that capture, so a screen that is light again can clear the penalty before it -/// expires (#3844). It is the only expiry/due-time owner: no schedule, queue, or cache runs beside it. +/// The channel penalty (#1105/#1156): regular plans defer XCTest tiers while it is active, and +/// each recovery interval one deferred plan runs the bounded tree probe first, inside that capture, +/// so a light screen clears the penalty early (#3844). It is the only expiry/due-time owner. struct SnapshotXCTestChannelPenalty: Equatable { static let duration: TimeInterval = 120 static let recoveryProbeInterval: TimeInterval = 30 @@ -139,17 +123,9 @@ extension RunnerTests { NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_RECOVERY_PROBE bundle=%@", bundleId ?? "") } - /// The pre-seeded first-failure a penalized plan stamps into its verdict. The deferred case - /// uses the dedicated 'deferred' code: the breaker pre-selected the backend, nothing new - /// degraded on this capture, and the daemon keys warning suppression and the settle budget - /// reset off exactly that distinction. The bounded probe keeps 'budget': its short XCTest - /// slice genuinely constrains what this capture could read. - /// - /// `requestPinnedBackend` separates the two ways the plan can arrive at the same - /// deferred shape. A capture that ASKED for a private-AX-only reading (custom - /// actions) degraded nothing, so it seeds no failure at all — reporting slow - /// accessibility work would name a cause that does not exist. Its answer comes from - /// `snapshotPlanPreSelectionReason`. + /// Deferred uses the 'deferred' code, not a failure code: the daemon keys warning suppression + /// and the settle-budget reset off that distinction. The bounded probe keeps 'budget' because + /// its short slice genuinely constrained the capture. static func xcTestChannelStateFirstFailure( _ state: SnapshotXCTestChannelPlanState, requestPinnedBackend: Bool = false @@ -173,10 +149,8 @@ extension RunnerTests { } } - /// The 'requested-backend' answer for a plan the request itself pinned: the recursive tree the - /// caller asked for, or the private AX the custom-actions reading requires. A pin names a plan - /// decision, not a failure, so it never enters the failure slot; `planVerdictReason` reports it - /// only while no tier failed on this capture. + /// A pin names a plan decision, not a failure, so it reports through this slot and never + /// through the failure slot. static func snapshotPlanPreSelectionReason( _ state: SnapshotXCTestChannelPlanState, requestPinnedBackend: Bool = false, @@ -197,11 +171,8 @@ extension RunnerTests { return nil } - /// The reason a capture's verdict carries: the first tier failure when one exists, otherwise the - /// pre-selection the plan started with. Once an XCTest-backed tier ran behind a deferred plan, - /// it ran on the bounded probe's short slice, so the capture reports that constraint ('budget') - /// whether the tier recovered it or the plan ended sparse, rather than the pre-selection - /// ('deferred') the plan was seeded with. + /// Once an XCTest tier ran behind a deferred plan, it ran on the probe's short slice, so the + /// capture reports 'budget' rather than the 'deferred' pre-selection it was seeded with. static func planVerdictReason( xCTestTierRan: Bool, state: SnapshotXCTestChannelPlanState, @@ -212,10 +183,7 @@ extension RunnerTests { return xcTestChannelStateFirstFailure(.boundedXCTestProbe) } - /// Pure gate: a capture is planned as penalized when the channel penalty is - /// active OR the daemon pinned the private-AX backend (same-backend evidence - /// probe) — both mean "do not enter XCTest tree work first, and stamp the - /// pre-selection as 'deferred' rather than a degradation". + /// The daemon's private-AX pin takes the penalized route deliberately, not from degradation. static func snapshotXCTestChannelTreatedAsPenalized( penalized: Bool, preferredBackend: String? @@ -223,13 +191,11 @@ extension RunnerTests { penalized || preferredBackend == SnapshotBackendKind.privateAX.rawValue } - /// Pure plan-reorder rule: an internal preferred backend pins the regular plan to that backend; - /// this is the only force seam used by same-backend evidence and conformance captures. A - /// penalized XCTest accessibility channel uses independent backends when the platform has one, - /// otherwise it keeps XCTest work on a short probe. A due recovery probe puts that short tree - /// ahead of the independent backend, and only that leading tree step claims the probe. The raw - /// diagnostic plan keeps tree-first errors, and unknown plans are left untouched. Each step - /// carries the execution mode this rule chose; nothing downstream re-derives it from position. + /// An internal preferred backend pins the regular plan. A penalized channel defers XCTest tiers + /// to independent backends where the platform has one (every step bounded recovery, so no + /// attempt can renew the penalty), otherwise it keeps XCTest work on a short probe. A due + /// recovery probe puts the bounded tree first, claiming the probe. Raw and unknown plans are + /// untouched. static func effectiveSnapshotCapturePlan( _ plan: [SnapshotBackendKind], xCTestChannelPenalized: Bool, @@ -260,11 +226,10 @@ extension RunnerTests { let availablePlan = plan.filter { availableBackends.contains($0) } let recoveryPlan = availablePlan.filter { !$0.usesXCTestAccessibilityChannel } if !recoveryPlan.isEmpty { - // The independent backend reads only the app it can match as the active AX application, so - // an out-of-process surface over the app (the Save Password sheet) leaves it empty for the - // whole penalty. The tree, on the bounded probe's short slice, stays behind it, or leads when - // the recovery probe is due; the query sweep does not, since its grind is what the penalty - // keeps off the main thread. + // The independent backend reads only the app it matches as the active AX application, so an + // out-of-process surface leaves it empty for the whole penalty; the bounded tree stays + // behind it as the sparse fallback. The query sweep stays deferred: its grind is what the + // penalty keeps off the main thread. let boundedTree = availablePlan.filter { $0 == .recursiveTree } let probeLeads = xCTestRecoveryProbeDue && !boundedTree.isEmpty return EffectiveSnapshotCapturePlan( @@ -288,10 +253,9 @@ extension RunnerTests { ) } - /// Factory: one step per planned backend, each carrying the execution mode the penalized state - /// chose. A normal plan runs ordinary attempts; any penalized state runs every tier as bounded - /// recovery, so no attempt from it can arm or renew the penalty it is recovering from. Only the - /// leading XCTest step of the deferred plan that is running the due probe claims the probe. + /// A penalized state runs every tier as bounded recovery, so no attempt from it can arm or + /// renew the penalty it is recovering from. Only the leading XCTest step of a probe-leading + /// deferred plan claims the probe. static func snapshotCaptureSteps( for plan: [SnapshotBackendKind], state: SnapshotXCTestChannelPlanState, @@ -304,18 +268,14 @@ extension RunnerTests { } } - /// Why an ordinary XCTest attempt shows a hostile channel, or nil. A bounded recovery attempt - /// has no arming branch here at all: forgetting that would let a probe renew its own penalty, - /// which is exactly the self-renewal #3896 removed at the recorder. - /// - /// The breaker observes acquisition facts only. Presentation is a separate phase and cannot arm - /// the breaker, even when it is slower than the acquisition that produced the tree. + /// A bounded recovery attempt has no arming branch at all: that gate is what stops a probe from + /// renewing its own penalty (#3896). The breaker observes acquisition facts only; presentation + /// is a separate phase and cannot arm it. static func snapshotXCTestPenaltyReason( for attempt: SnapshotBackendAttempt, slowThresholdMs: Double ) -> String? { - guard case .ordinary = attempt.step.execution else { return nil } - let kind = attempt.step.backend + guard case .ordinary(let kind) = attempt.step else { return nil } guard kind.usesXCTestAccessibilityChannel else { return nil } if case let .failed(failure, phase: .acquisition) = attempt.outcome, failure.code == Self.xCTestSnapshotTimeoutCode @@ -329,17 +289,16 @@ extension RunnerTests { return "slow_\(kind.rawValue)_capture_\(Int(attempt.timing.acquisitionMs))ms" } - /// Only an accepted, fast tree from a bounded recovery attempt proves that the channel recovered - /// and clears the penalty early. An ordinary tree capture never clears it; it can only arm it. + /// Only an accepted, fast tree from a bounded recovery attempt clears the penalty early; an + /// ordinary tree capture can only arm it. static func snapshotBoundedRecoveryRestoredChannel(_ attempt: SnapshotBackendAttempt) -> Bool { - guard case .boundedRecovery = attempt.step.execution, - attempt.step.backend == .recursiveTree, + guard case .boundedRecovery(.recursiveTree, _) = attempt.step, attempt.timing.acquisitionMs <= SnapshotCaptureStep.boundedTreeSliceBudget * 1_000, case let .captured(capture) = attempt.outcome else { return false } return Self.snapshotTierRejectionReason( outcome: attempt.tierOutcome, - kind: attempt.step.backend, + kind: .recursiveTree, payload: capture.qualityPayload ?? capture.payload ) == nil } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift index 764acf1c3e..388bb94ea5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift @@ -416,7 +416,7 @@ extension RunnerTests { xCTestChannelPenalized: treated, preferredBackend: options.preferredBackend ) - XCTAssertEqual(pinned.plan, [.privateAX]) + XCTAssertEqual(pinned.steps.map(\.backend), [.privateAX]) XCTAssertEqual(pinned.xCTestChannelState, .deferredToIndependentBackend) let raw = Self.effectiveSnapshotCapturePlan( @@ -424,7 +424,7 @@ extension RunnerTests { xCTestChannelPenalized: treated, preferredBackend: options.preferredBackend ) - XCTAssertEqual(raw.plan, Self.rawDiagnosticPlan) + XCTAssertEqual(raw.steps.map(\.backend), Self.rawDiagnosticPlan) // A command without the field decodes to no pin and a normal plan. let bare = try JSONDecoder().decode( @@ -441,7 +441,7 @@ extension RunnerTests { xCTestChannelPenalized: true, preferredBackend: SnapshotBackendKind.recursiveTree.rawValue ) - XCTAssertEqual(forced.plan, [.recursiveTree]) + XCTAssertEqual(forced.steps.map(\.backend), [.recursiveTree]) XCTAssertEqual(forced.xCTestChannelState, .normal) XCTAssertEqual( Self.snapshotPlanPreSelectionReason( @@ -475,7 +475,7 @@ extension RunnerTests { ), preferredBackend: "private-ax" ) - XCTAssertEqual(pinned.plan, [.privateAX]) + XCTAssertEqual(pinned.steps.map(\.backend), [.privateAX]) XCTAssertEqual(pinned.xCTestChannelState, .deferredToIndependentBackend) // The requested-backend rule covers this pin too: it answers through the pre-selection // slot and seeds no failure, so a failing private-AX tier owns the verdict reason. @@ -496,7 +496,7 @@ extension RunnerTests { Self.regularVisiblePlan, xCTestChannelPenalized: true ) - XCTAssertEqual(regular.plan, [.privateAX, .recursiveTree]) + XCTAssertEqual(regular.steps.map(\.backend), [.privateAX, .recursiveTree]) XCTAssertEqual(regular.xCTestChannelState, .deferredToIndependentBackend) XCTAssertEqual( regular.steps.map(\.treeCaptureSliceBudgetOverride), @@ -507,7 +507,7 @@ extension RunnerTests { Self.regularVisiblePlan, xCTestChannelPenalized: false ) - XCTAssertEqual(unpenalized.plan, Self.regularVisiblePlan) + XCTAssertEqual(unpenalized.steps.map(\.backend), Self.regularVisiblePlan) XCTAssertEqual(unpenalized.xCTestChannelState, .normal) XCTAssertNil(unpenalized.steps.first?.treeCaptureSliceBudgetOverride) @@ -516,7 +516,7 @@ extension RunnerTests { Self.rawDiagnosticPlan, xCTestChannelPenalized: true ) - XCTAssertEqual(raw.plan, Self.rawDiagnosticPlan) + XCTAssertEqual(raw.steps.map(\.backend), Self.rawDiagnosticPlan) XCTAssertEqual(raw.xCTestChannelState, .normal) XCTAssertNil(raw.steps.first?.treeCaptureSliceBudgetOverride) } @@ -572,7 +572,7 @@ extension RunnerTests { availableBackends: [.recursiveTree, .querySweep] ) - XCTAssertEqual(physicalDevicePlan.plan, [.recursiveTree, .querySweep]) + XCTAssertEqual(physicalDevicePlan.steps.map(\.backend), [.recursiveTree, .querySweep]) XCTAssertEqual(physicalDevicePlan.xCTestChannelState, .boundedXCTestProbe) XCTAssertEqual( physicalDevicePlan.steps.map(\.treeCaptureSliceBudgetOverride), @@ -668,7 +668,7 @@ extension RunnerTests { tierOutcome: SnapshotTierOutcome = .completed ) -> SnapshotBackendAttempt { SnapshotBackendAttempt( - step: .boundedRecovery(kind), + step: .boundedRecovery(kind, claimsPeriodicProbe: false), outcome: outcome, timing: SnapshotCaptureTiming(acquisitionMs: acquisitionMs, presentationMs: 10), tierOutcome: tierOutcome @@ -713,7 +713,7 @@ extension RunnerTests { let due = Self.effectiveSnapshotCapturePlan( Self.regularVisiblePlan, xCTestChannelPenalized: true, xCTestRecoveryProbeDue: true ) - XCTAssertEqual(due.plan, [.recursiveTree, .privateAX]) + XCTAssertEqual(due.steps.map(\.backend), [.recursiveTree, .privateAX]) XCTAssertEqual(due.xCTestChannelState, .deferredToIndependentBackend) XCTAssertEqual( due.steps.map(\.treeCaptureSliceBudgetOverride), @@ -722,14 +722,14 @@ extension RunnerTests { XCTAssertEqual(due.steps.map(\.claimsPeriodicRecoveryProbe), [true, false]) let notDue = Self.effectiveSnapshotCapturePlan(Self.regularVisiblePlan, xCTestChannelPenalized: true) - XCTAssertEqual(notDue.plan, [.privateAX, .recursiveTree]) + XCTAssertEqual(notDue.steps.map(\.backend), [.privateAX, .recursiveTree]) XCTAssertEqual(notDue.steps.map(\.claimsPeriodicRecoveryProbe), [false, false]) let physicalDevice = Self.effectiveSnapshotCapturePlan( Self.regularVisiblePlan, xCTestChannelPenalized: true, xCTestRecoveryProbeDue: true, availableBackends: [.recursiveTree, .querySweep] ) - XCTAssertEqual(physicalDevice.plan, [.recursiveTree, .querySweep]) + XCTAssertEqual(physicalDevice.steps.map(\.backend), [.recursiveTree, .querySweep]) XCTAssertEqual( physicalDevice.steps.map(\.claimsPeriodicRecoveryProbe), [false, false], "the bounded plan probes on every capture" @@ -739,13 +739,13 @@ extension RunnerTests { Self.regularVisiblePlan, xCTestChannelPenalized: true, xCTestRecoveryProbeDue: true, preferredBackend: pinned ) - XCTAssertEqual(plan.plan.count, 1) + XCTAssertEqual(plan.steps.count, 1) XCTAssertEqual(plan.steps.map(\.claimsPeriodicRecoveryProbe), [false]) } let raw = Self.effectiveSnapshotCapturePlan( Self.rawDiagnosticPlan, xCTestChannelPenalized: true, xCTestRecoveryProbeDue: true ) - XCTAssertEqual(raw.plan, Self.rawDiagnosticPlan) + XCTAssertEqual(raw.steps.map(\.backend), Self.rawDiagnosticPlan) XCTAssertEqual(raw.steps.map(\.claimsPeriodicRecoveryProbe), [false, false]) } From 14644f9b83a864e1231e15631604d5fc53f0a396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 25 Oct 2026 12:27:06 +0100 Subject: [PATCH 4/5] refactor(ios): shrink snapshot channel policy below its removed surface Merge the plan construction into one site, drop the step factory and the plan accessor the steps made redundant, and cut narration to the constraints the code cannot show. --- .../RunnerTests+SnapshotCapturePlan.swift | 16 +-- .../RunnerTests+SnapshotChannelPolicy.swift | 107 +++++++----------- .../RunnerTests+SnapshotTiming.swift | 8 +- 3 files changed, 48 insertions(+), 83 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index 9d63558106..35e4799102 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -52,9 +52,8 @@ enum SnapshotTierOutcome: Equatable { } /// The plan one capture runs: ordered steps, the channel state the selection chose, and the pin — -/// if any — that narrowed it. The selection cause stays separate from the step's execution mode: -/// an explicit pin, a custom-action pin, and a breaker deferral can share the deferred ordering -/// while their verdict explanations differ, and a pin is never an armed penalty. +/// if any — that narrowed it. A pin shares the deferred ordering with a breaker deferral without +/// arming the penalty, so the selection cause stays separate from each step's mode. struct EffectiveSnapshotCapturePlan { let steps: [SnapshotCaptureStep] let xCTestChannelState: SnapshotXCTestChannelPlanState @@ -151,12 +150,8 @@ extension RunnerTests { let suppressXCTestPenalty = snapshotXCTestPenaltyWarmupExemption.consume() var xCTestTierRan = false - // Reorder is iOS-only because hostile screens can make XCTest tree/query work grind while - // the app remains visually responsive. Simulators can avoid that channel through private AX; - // physical devices have no independent semantic backend yet, so they use a bounded probe. - // A daemon-preferred private-AX capture (same-backend evidence probe) takes the exact - // penalized route: privateAX-first plan, 'deferred' verdict — the backend was pre-selected - // deliberately, so no degradation warning should render for it. + // Reorder is iOS-only: physical devices have no independent semantic backend, so a penalized + // channel there uses the bounded probe instead of deferral. var xCTestChannelPenalized = false var breakerPenalized = false var xCTestRecoveryProbeDue = false @@ -219,8 +214,7 @@ extension RunnerTests { } continue } - // The claim is synchronous, before the work starts: a step that is skipped above starts - // nothing and must leave the due probe for the next plan. + // The claim is synchronous: a skipped step must leave the probe for the next plan. if step.claimsPeriodicRecoveryProbe { claimSnapshotXCTestRecoveryProbe(bundleId: target.bundleId) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift index 9d2f7b67b5..3f77bbb663 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotChannelPolicy.swift @@ -1,10 +1,6 @@ import Foundation // MARK: - XCTest accessibility channel policy (ADR 0004, #1105/#1156/#3896) -// -// On some deep screens the XCTest bulk snapshot grinds for many seconds before failing; re-grinding -// on every capture buries the main thread past the watchdog. After a slow, timed-out, or abandoned -// XCTest-backed capture, later plans for the same bundle run bounded recovery tiers. /// One executed step of an effective plan. The mode decides which evidence the attempt may produce /// and travels with it, so evidence cannot reach the recorder detached from its authority (#3896). @@ -31,7 +27,6 @@ enum SnapshotCaptureStep { return Self.boundedTreeSliceBudget } - /// Only the leading step of the deferred plan running the due probe claims it. var claimsPeriodicRecoveryProbe: Bool { if case let .boundedRecovery(_, claims) = self { return claims } return false @@ -44,9 +39,10 @@ enum SnapshotXCTestChannelPlanState: Equatable { case boundedXCTestProbe } -/// The channel penalty (#1105/#1156): regular plans defer XCTest tiers while it is active, and -/// each recovery interval one deferred plan runs the bounded tree probe first, inside that capture, -/// so a light screen clears the penalty early (#3844). It is the only expiry/due-time owner. +/// The channel penalty (#1105/#1156): re-grinding a slow XCTest capture on every plan buries the +/// main thread past the watchdog, so while it is active regular plans defer XCTest tiers, and each +/// recovery interval one deferred plan runs the bounded tree probe first, inside that capture, so a +/// light screen clears the penalty early (#3844). It is the only expiry/due-time owner. struct SnapshotXCTestChannelPenalty: Equatable { static let duration: TimeInterval = 120 static let recoveryProbeInterval: TimeInterval = 30 @@ -124,8 +120,7 @@ extension RunnerTests { } /// Deferred uses the 'deferred' code, not a failure code: the daemon keys warning suppression - /// and the settle-budget reset off that distinction. The bounded probe keeps 'budget' because - /// its short slice genuinely constrained the capture. + /// and the settle-budget reset off that distinction. The bounded probe keeps 'budget'. static func xcTestChannelStateFirstFailure( _ state: SnapshotXCTestChannelPlanState, requestPinnedBackend: Bool = false @@ -150,7 +145,7 @@ extension RunnerTests { } /// A pin names a plan decision, not a failure, so it reports through this slot and never - /// through the failure slot. + /// through the failure slot (`planVerdictReason`). static func snapshotPlanPreSelectionReason( _ state: SnapshotXCTestChannelPlanState, requestPinnedBackend: Bool = false, @@ -203,69 +198,49 @@ extension RunnerTests { availableBackends: Set = Set(SnapshotBackendKind.allCases), preferredBackend: String? = nil ) -> EffectiveSnapshotCapturePlan { + var ordered = plan + var state: SnapshotXCTestChannelPlanState = .normal + var preferred: SnapshotBackendKind? + var probeLeads = false if plan == Self.regularVisiblePlan, - let preferred = preferredBackend.flatMap(SnapshotBackendKind.init(rawValue:)), - preferred.isForceable, - availableBackends.contains(preferred) + let candidate = preferredBackend.flatMap(SnapshotBackendKind.init(rawValue:)), + candidate.isForceable, + availableBackends.contains(candidate) { - let state: SnapshotXCTestChannelPlanState = preferred == .privateAX ? .deferredToIndependentBackend : .normal - return EffectiveSnapshotCapturePlan( - steps: Self.snapshotCaptureSteps(for: [preferred], state: state, probeLeads: false), - xCTestChannelState: state, - preferredBackend: preferred - ) - } - guard xCTestChannelPenalized, plan == Self.regularVisiblePlan else { - return EffectiveSnapshotCapturePlan( - steps: Self.snapshotCaptureSteps(for: plan, state: .normal, probeLeads: false), - xCTestChannelState: .normal, - preferredBackend: nil - ) - } - let availablePlan = plan.filter { availableBackends.contains($0) } - let recoveryPlan = availablePlan.filter { !$0.usesXCTestAccessibilityChannel } - if !recoveryPlan.isEmpty { - // The independent backend reads only the app it matches as the active AX application, so an - // out-of-process surface leaves it empty for the whole penalty; the bounded tree stays - // behind it as the sparse fallback. The query sweep stays deferred: its grind is what the - // penalty keeps off the main thread. - let boundedTree = availablePlan.filter { $0 == .recursiveTree } - let probeLeads = xCTestRecoveryProbeDue && !boundedTree.isEmpty - return EffectiveSnapshotCapturePlan( - steps: Self.snapshotCaptureSteps( - for: probeLeads ? boundedTree + recoveryPlan : recoveryPlan + boundedTree, - state: .deferredToIndependentBackend, - probeLeads: probeLeads - ), - xCTestChannelState: .deferredToIndependentBackend, - preferredBackend: nil - ) + ordered = [candidate] + preferred = candidate + state = candidate == .privateAX ? .deferredToIndependentBackend : .normal + } else if xCTestChannelPenalized, plan == Self.regularVisiblePlan { + let availablePlan = plan.filter { availableBackends.contains($0) } + let recoveryPlan = availablePlan.filter { !$0.usesXCTestAccessibilityChannel } + if !recoveryPlan.isEmpty { + // The independent backend reads only the app it matches as the active AX application, so an + // out-of-process surface leaves it empty for the whole penalty; the bounded tree stays + // behind it as the sparse fallback. The query sweep stays deferred: its grind is what the + // penalty keeps off the main thread. + let boundedTree = availablePlan.filter { $0 == .recursiveTree } + probeLeads = xCTestRecoveryProbeDue && !boundedTree.isEmpty + ordered = probeLeads ? boundedTree + recoveryPlan : recoveryPlan + boundedTree + state = .deferredToIndependentBackend + } else { + ordered = availablePlan.filter(\.usesXCTestAccessibilityChannel) + state = .boundedXCTestProbe + } } - return EffectiveSnapshotCapturePlan( - steps: Self.snapshotCaptureSteps( - for: availablePlan.filter(\.usesXCTestAccessibilityChannel), - state: .boundedXCTestProbe, - probeLeads: false - ), - xCTestChannelState: .boundedXCTestProbe, - preferredBackend: nil - ) - } - - /// A penalized state runs every tier as bounded recovery, so no attempt from it can arm or - /// renew the penalty it is recovering from. Only the leading XCTest step of a probe-leading - /// deferred plan claims the probe. - static func snapshotCaptureSteps( - for plan: [SnapshotBackendKind], - state: SnapshotXCTestChannelPlanState, - probeLeads: Bool - ) -> [SnapshotCaptureStep] { - plan.enumerated().map { offset, kind in + // A penalized state runs every tier as bounded recovery, so no attempt from it can arm or + // renew the penalty it is recovering from. Only the leading XCTest step of a probe-leading + // deferred plan claims the probe. + let steps = ordered.enumerated().map { offset, kind -> SnapshotCaptureStep in guard state != .normal else { return .ordinary(kind) } let claims = probeLeads && offset == 0 && kind.usesXCTestAccessibilityChannel return .boundedRecovery(kind, claimsPeriodicProbe: claims) } + return EffectiveSnapshotCapturePlan( + steps: steps, + xCTestChannelState: state, + preferredBackend: preferred + ) } /// A bounded recovery attempt has no arming branch at all: that gate is what stops a probe from diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift index 7400acd7e8..62e6819a81 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift @@ -87,8 +87,6 @@ final class SnapshotXCTestPenaltyWarmupExemption { } extension RunnerTests { - /// What one planned step did. The step travels with the attempt, so penalty policy reads the - /// authority the work ran under (`RunnerTests+SnapshotChannelPolicy.swift`). struct SnapshotBackendAttempt { enum Outcome { case noCapture @@ -119,10 +117,8 @@ extension RunnerTests { } } - /// Records what one attempt shows about the XCTest channel, given the step that ran it. An - /// ordinary attempt is the only one with an arming branch; a bounded recovery attempt can only - /// clear the penalty by answering with an accepted fast tree. The warmup exemption suppresses - /// both branches on the first plan against a fresh process. + /// The warmup exemption suppresses both the arming and the clearing branch on the first plan + /// against a fresh process. func recordXCTestSnapshotBackendAttemptIfNeeded( attempt: SnapshotBackendAttempt, bundleId: String?, From 1e39504de91aff979494bcd75bdc8b3b84d0a949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 25 Oct 2026 12:37:58 +0100 Subject: [PATCH 5/5] refactor(ios): drop the pin doc sentence the step type already decides A tree pin stays an ordinary attempt and can arm the penalty; only the private-AX pin defers. The step mode owns that, so the struct says nothing. --- .../RunnerTests+SnapshotCapturePlan.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index 35e4799102..c47b77842a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -52,8 +52,7 @@ enum SnapshotTierOutcome: Equatable { } /// The plan one capture runs: ordered steps, the channel state the selection chose, and the pin — -/// if any — that narrowed it. A pin shares the deferred ordering with a breaker deferral without -/// arming the penalty, so the selection cause stays separate from each step's mode. +/// if any — that narrowed it. struct EffectiveSnapshotCapturePlan { let steps: [SnapshotCaptureStep] let xCTestChannelState: SnapshotXCTestChannelPlanState