Skip to content

Commit f4fccd0

Browse files
authored
refactor(ios-runner): make off-main access to main-owned capture state fail the runner gates (#2911)
* refactor(ios-runner): own main-thread capture state in RunnerMainOwnedState Target identity, accessibility health and the post-snapshot delay flag move into one @mainactor type, so Swift 5 mode rejects a read or write of them from the command queue instead of leaving the boundary to review (#2882). Every main hop's work closure is @mainactor and runs through one MainActor.assumeIsolated helper; timeoutError and onAbandoned are @sendable, so the #2836 routing-probe shape no longer compiles. Functions that only run on main carry @mainactor; the transport, command queue, capture plan, watchdog and the locked completion/abandonment owner stay nonisolated. Escaping closures carry @mainactor or @sendable: transport completions and in-flight waiters, DispatchSource handlers, the phase-timer clock and the unit-test hooks. The recorder's capture is @mainactor and is called only inside the frame tick's main hop; ScreenRecorder.start takes a non-escaping bootstrap and a @sendable frame. Unit tests that touch the state run on the main actor, or step onto it with MainActor.assumeIsolated where they also dispatch work off main. * build(ios-runner): fail runner builds on actor-isolation diagnostics Swift 5 mode reports several off-main uses of main-actor state only as warnings, for example a RunnerMainOwnedState read inside a DispatchQueue.async closure. build-xcuitest-apple.sh now tees the build-for-testing log, keeps xcodebuild's exit status, and after a successful build fails on any Swift diagnostic line, warning or error, that contains "actor-isolated" or "loses global actor". Other warnings stay ungated. The script says so when it reuses DerivedData, because an incremental build prints diagnostics only for the files it recompiles. * chore(gates): key the runner build cache on the isolation scan The build-for-testing step now runs scripts/runner-isolation-diagnostics.ts, so a change to the scan misses the cache and is exercised on a cold build, like the other scripts that build runs. * refactor(ios-runner): enter the main actor through one shared runOnMainActor The quiescence-skip block converted a @mainactor block to XCTest's nonisolated block parameter, which the compiler accepts without a diagnostic. It is now a plain block that enters isolation through runOnMainActor, as do both applyMainOwnedSnapshotState hops, so every main-actor entry checks the thread in one internal helper. * fix(ios-runner): capture the runner weakly after a response send * build(ios-runner): give the isolation scan a compiled positive control The runner gate builds compile RunnerIsolationCanary.swift with the runner's own flags, and the scan fails unless every canary line carries a concurrency diagnostic, so a reworded diagnostic fails the gate instead of passing it. The scan also matches the ActorIsolatedCall and SendableClosureCaptures groups and Sendable-capture prose. A failed scan drops the intermediates so a rerun recompiles and rescans every file. The npm package does not ship the canary. * refactor(ios-runner): hold main's new gesture-policy and alert-test code to the main-owned state performSynthesizedGesture reads accessibility health through mainOwned and runs on the main actor with its callers; the unit tests that set the health or resolve alerts on main carry @mainactor. * refactor(ios-runner): name the tuple send sendResult and start test recordings through one helper * test(gates): state where the isolation canary compiles and drop the duplicate line-count test
1 parent 76dbee0 commit f4fccd0

61 files changed

Lines changed: 922 additions & 312 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/actions/setup-apple-runner-build/action.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ runs:
4444
id: source-hash
4545
run: |
4646
set -euo pipefail
47-
echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**', 'scripts/build-xcuitest-apple.sh', 'scripts/swift-toolchain-tmpdir.ts', 'scripts/write-xcuitest-cache-metadata.mjs', '.github/actions/setup-apple-runner-build/action.yml') }}" >> "$GITHUB_OUTPUT"
47+
echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**', 'scripts/build-xcuitest-apple.sh', 'scripts/runner-isolation-diagnostics.ts', 'scripts/swift-toolchain-tmpdir.ts', 'scripts/write-xcuitest-cache-metadata.mjs', '.github/actions/setup-apple-runner-build/action.yml') }}" >> "$GITHUB_OUTPUT"
4848
shell: bash
4949

5050
- name: Resolve Apple runner build variant
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#if AGENT_DEVICE_RUNNER_ISOLATION_CANARY
2+
import Foundation
3+
4+
/// Positive control for `scripts/runner-isolation-diagnostics.ts`. `scripts/build-xcuitest-apple.sh`
5+
/// compiles this file into every runner build it scans, with the runner's own flags, and the scan
6+
/// fails unless it reports a diagnostic on every line marked `isolation-canary`: a Swift release
7+
/// that rewords or regroups one of these diagnostics fails the gate instead of passing it. Nothing
8+
/// calls these functions, and the npm package does not ship this file.
9+
enum RunnerIsolationCanary {
10+
private final class Counter {
11+
var value = 0
12+
}
13+
14+
static func readsMainOwnedStateOffMain(_ state: RunnerMainOwnedState) {
15+
DispatchQueue.global().async {
16+
_ = state.bundleId // isolation-canary
17+
}
18+
}
19+
20+
static func callsMainActorClosureOffMain(_ work: @escaping @MainActor () -> Void) {
21+
DispatchQueue.global().async {
22+
work() // isolation-canary
23+
}
24+
}
25+
26+
static func dropsMainActor(_ work: @escaping @MainActor () -> Void) -> @Sendable () -> Void {
27+
work // isolation-canary
28+
}
29+
30+
@MainActor
31+
static func sendsMainFormedStateOffMain() {
32+
let counter = Counter()
33+
DispatchQueue.global().async {
34+
counter.value += 1 // isolation-canary
35+
}
36+
}
37+
}
38+
#endif
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import XCTest
2+
3+
/// Runner state only the main thread reads or writes. Off-main code reads target identity from a
4+
/// `SnapshotCaptureTarget` taken on main and writes through `applyMainOwnedSnapshotState`.
5+
@MainActor
6+
final class RunnerMainOwnedState {
7+
var app: XCUIApplication?
8+
var bundleId: String?
9+
var processIdentifier: Int?
10+
var accessibilityHealth: RunnerAccessibilityHealth = .unknown
11+
var needsPostSnapshotInteractionDelay = false
12+
13+
nonisolated init() {}
14+
}
15+
16+
/// The runner's one entry into main-actor isolation from code that is on the main thread without
17+
/// being statically isolated: the main hops of `runMainThreadWork` and `applyMainOwnedSnapshotState`,
18+
/// and the blocks XCTest calls back. `MainActor.assumeIsolated` traps when the caller is off main.
19+
/// It returns only `Sendable` values, so the result leaves through a captured `Result`: a
20+
/// `T: Sendable` bound would promise something no gate checks.
21+
func runOnMainActor<T>(_ work: @MainActor () throws -> T) -> Result<T, Error> {
22+
var result: Result<T, Error>?
23+
MainActor.assumeIsolated {
24+
result = Result { try work() }
25+
}
26+
return result!
27+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ extension RunnerTests {
2121
return max(0.001, timeoutMs / 1000)
2222
}
2323

24+
@MainActor
2425
func resolveAlert(app activeApp: XCUIApplication, deadline: Date) -> RunnerAlert? {
2526
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
2627
if let override = alertResolutionOverrideForTesting {
@@ -50,6 +51,7 @@ extension RunnerTests {
5051
return nil
5152
}
5253

54+
@MainActor
5355
func handleAlert(_ alert: RunnerAlert, action: String, deadline: Date) -> Response {
5456
if action == "accept" || action == "dismiss" {
5557
guard let button = chooseAlertButton(alert.buttons, action: action) else {
@@ -121,6 +123,7 @@ extension RunnerTests {
121123
)
122124
}
123125

126+
@MainActor
124127
func activateAlertButton(
125128
_ alert: RunnerAlert,
126129
button: XCUIElement,
@@ -296,6 +299,7 @@ extension RunnerTests {
296299
// for a fresh hittable read instead of spending it on a dropped tap. The hittable read
297300
// is itself a synchronous query a starved host can complete past the deadline, so a read
298301
// that lands late forfeits rather than buys back the one activation.
302+
@MainActor
299303
private func waitUntilAlertButtonHittable(_ button: XCUIElement, deadline: Date) -> Bool {
300304
while Date() < deadline {
301305
if probeAlertButtonHittable(button, deadline: deadline) {
@@ -306,6 +310,7 @@ extension RunnerTests {
306310
return false
307311
}
308312

313+
@MainActor
309314
private func probeAlertButtonHittable(_ button: XCUIElement, deadline: Date) -> Bool {
310315
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
311316
if let override = alertButtonHittabilityProbeOverrideForTesting {

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift‎

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ extension RunnerTests {
5050

5151
/// The session app's `XCUIApplication.State` by name. A lifecycle read: the activation preflight
5252
/// is skipped, so `runningBackground` after `home` is reported rather than repaired away.
53+
@MainActor
5354
func executeAppState(command: Command) -> Response {
5455
guard let bundleId = command.appBundleId?.trimmedNonEmpty else {
5556
return Response(
@@ -165,7 +166,7 @@ extension RunnerTests {
165166
return try runMainThreadWork(
166167
"command_execution",
167168
timeout: max(0.001, deadline.timeIntervalSinceNow),
168-
timeoutError: mainThreadExecutionTimeoutError
169+
timeoutError: Self.mainThreadExecutionTimeoutError
169170
) {
170171
try self.executeOnMainSafely(
171172
command: command,
@@ -177,14 +178,15 @@ extension RunnerTests {
177178
return try runMainThreadWork(
178179
"command_execution",
179180
timeout: Self.mainThreadExecutionTimeout,
180-
timeoutError: mainThreadExecutionTimeoutError
181+
timeoutError: Self.mainThreadExecutionTimeoutError
181182
) {
182183
try self.executeOnMainSafely(command: command, routeToSpringboard: routeToSpringboard)
183184
}
184185
}
185186

186187
// MARK: - Command Handling
187188

189+
@MainActor
188190
private func executeOnMainSafely(
189191
command: Command,
190192
alertDeadline: Date? = nil,
@@ -280,7 +282,7 @@ extension RunnerTests {
280282
let failureCountBefore = try runMainThreadWork(
281283
"recorded_failure_count",
282284
timeout: Self.mainThreadExecutionTimeout,
283-
timeoutError: mainThreadExecutionTimeoutError
285+
timeoutError: Self.mainThreadExecutionTimeoutError
284286
) {
285287
self.currentXCTestFailureCount()
286288
}
@@ -297,7 +299,7 @@ extension RunnerTests {
297299
let recordedFailureResponse = try runMainThreadWork(
298300
"recorded_failure_count",
299301
timeout: Self.mainThreadExecutionTimeout,
300-
timeoutError: mainThreadExecutionTimeoutError
302+
timeoutError: Self.mainThreadExecutionTimeoutError
301303
) {
302304
self.didRecordXCTestFailure(since: failureCountBefore)
303305
? self.xctestRecordedFailureResponse(command: command, response: response)
@@ -307,7 +309,7 @@ extension RunnerTests {
307309
try runMainThreadWork(
308310
"target_invalidation",
309311
timeout: Self.mainThreadExecutionTimeout,
310-
timeoutError: mainThreadExecutionTimeoutError
312+
timeoutError: Self.mainThreadExecutionTimeoutError
311313
) {
312314
self.invalidateCachedTarget(reason: "xctest_recorded_failure")
313315
}
@@ -322,7 +324,7 @@ extension RunnerTests {
322324
try runMainThreadWork(
323325
"target_invalidation",
324326
timeout: Self.mainThreadExecutionTimeout,
325-
timeoutError: mainThreadExecutionTimeoutError
327+
timeoutError: Self.mainThreadExecutionTimeoutError
326328
) {
327329
self.invalidateCachedTarget(reason: "response_unavailable")
328330
self.sleepFor(self.retryCooldown)
@@ -333,6 +335,7 @@ extension RunnerTests {
333335
}
334336
}
335337

338+
@MainActor
336339
private func executeOnMain(
337340
command: Command,
338341
alertDeadline: Date?,
@@ -438,7 +441,7 @@ extension RunnerTests {
438441
return Response(ok: false, error: ErrorPayload(message: "terminate requires appBundleId"))
439442
}
440443
XCUIApplication(bundleIdentifier: bundleId).terminate()
441-
if currentBundleId == bundleId {
444+
if mainOwned.bundleId == bundleId {
442445
invalidateCachedTarget(reason: "target_terminated")
443446
}
444447
return Response(ok: true, data: DataPayload(message: "app terminated"))
@@ -455,6 +458,7 @@ extension RunnerTests {
455458
/// The target this command runs against, decided by its `launchPolicy` (#2890). Exhaustive over the
456459
/// policy so a new case is a compile error here rather than a fall-through that quietly launches or
457460
/// quietly refuses.
461+
@MainActor
458462
func prepareActiveCommandContext(
459463
command: Command,
460464
routeToSpringboard: Bool = false
@@ -494,6 +498,7 @@ extension RunnerTests {
494498
/// place, and otherwise the requested session app is resolved and activated. What happens to a
495499
/// stopped app is the caller's `launchPolicy`; the `.existingApp` refusal belongs to
496500
/// `notRunningRefusal` because it is only meaningful once nothing is presented (#2890).
501+
@MainActor
497502
private func prepareActivatedTarget(command: Command) -> ActiveCommandPreparation {
498503
if let presented = presentedSystemSurfaceHost() {
499504
// Serve and drive the presented surface IN PLACE: never activate it (that cancels what it
@@ -514,7 +519,7 @@ extension RunnerTests {
514519
return .response(notRunning)
515520
}
516521
if let bundleId = requestedBundleId {
517-
if currentBundleId != bundleId || currentApp == nil {
522+
if mainOwned.bundleId != bundleId || mainOwned.app == nil {
518523
_ = activateTarget(bundleId: bundleId, reason: "bundle_changed")
519524
} else {
520525
refreshCachedTargetIfProcessChanged(bundleId: bundleId)
@@ -525,7 +530,7 @@ extension RunnerTests {
525530
}
526531

527532
// Read back after the bundle resolution above, which is what may have just bound a target.
528-
var activeApp = currentApp ?? app
533+
var activeApp = mainOwned.app ?? app
529534
if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
530535
activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
531536
} else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
@@ -616,6 +621,7 @@ extension RunnerTests {
616621
/// The one activation bypass that depends on the request rather than on the command: a tap that
617622
/// needs nothing the preflight would bring forward. Commands whose own classification answers
618623
/// without the session app's foreground state are handled by their `launchPolicy` (#2890).
624+
@MainActor
619625
func shouldSkipAppActivationPreflight(_ command: Command) -> Bool {
620626
#if os(iOS)
621627
// Coordinate-only synthesized taps can run after an AX-fatal foreground screen because they do not
@@ -664,25 +670,27 @@ extension RunnerTests {
664670
&& command.y != nil
665671
}
666672

673+
@MainActor
667674
private func hasCachedTargetForActivationSkip(command: Command) -> Bool {
668-
guard let currentApp, currentApp.state == .runningForeground else { return false }
675+
guard let boundApp = mainOwned.app, boundApp.state == .runningForeground else { return false }
669676
guard let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
670677
!bundleId.isEmpty
671678
else {
672679
return true
673680
}
674-
return currentBundleId == bundleId
681+
return mainOwned.bundleId == bundleId
675682
}
676683

684+
@MainActor
677685
func resolveAppWithoutActivation(command: Command) -> XCUIApplication {
678686
guard let bundleId = command.appBundleId?
679687
.trimmingCharacters(in: .whitespacesAndNewlines),
680688
!bundleId.isEmpty
681689
else {
682-
return currentApp ?? app
690+
return mainOwned.app ?? app
683691
}
684-
if currentBundleId == bundleId, let currentApp {
685-
return currentApp
692+
if mainOwned.bundleId == bundleId, let boundApp = mainOwned.app {
693+
return boundApp
686694
}
687695
return XCUIApplication(bundleIdentifier: bundleId)
688696
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import XCTest
22
import AgentDeviceSnapshotPresentation
33

44
extension RunnerTests {
5+
@MainActor
56
func executeOnMainPrepared(
67
command: Command,
78
activeApp: XCUIApplication,
@@ -147,7 +148,7 @@ extension RunnerTests {
147148
}
148149
if let x = command.x, let y = command.y {
149150
let xCTestChannelPenalized = isSnapshotXCTestChannelPenalized(
150-
bundleId: currentBundleId
151+
bundleId: mainOwned.bundleId
151152
)
152153
let xCTestTextInputProbeSkipped = !shouldProbeCoordinateTapTextInput(
153154
xCTestChannelPenalized: xCTestChannelPenalized
@@ -160,7 +161,7 @@ extension RunnerTests {
160161
textInput = nil
161162
NSLog(
162163
"AGENT_DEVICE_RUNNER_COORDINATE_TAP_TEXT_INPUT_PROBE_SKIPPED bundle=%@",
163-
currentBundleId ?? ""
164+
mainOwned.bundleId ?? ""
164165
)
165166
}
166167
var fallback: GestureFallback?

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+GestureExecution.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ extension RunnerTests {
5757
///
5858
/// NOTE: a new SYNTHESIS gesture must pass `idleTimeout: false` — the default `true` would wrap
5959
/// it in the scroll idle-timeout/quiescence-skip path and change its runtime behavior.
60+
@MainActor
6061
func performGesture(
6162
_ app: XCUIApplication,
6263
idleTimeout: Bool = true,

0 commit comments

Comments
 (0)