Repository navigation
Apple runner: make off-main access to main-owned capture state fail the runner gates (RunnerMainOwnedState) #2882
Description
Purpose
The Apple runner has two execution contexts. Main-thread lifecycle code binds the target app and owns its state. The snapshot capture plan runs on the private serial commandExecutionQueue. The runner target builds with SWIFT_VERSION = 5.0 and no strict-concurrency setting (project.pbxproj:450,476). Because of this, the compiler accepts code on the command queue that reads or writes main-owned vars. The only defence is review.
This bug class has come back three times. Each time, an audit or a review round found it. No gate caught it:
- fix(ios): keep the runner alive through hostile snapshots #2621 added
invalidateCachedTargetAfterSnapshotFailure, a hop to main that runs only when no abandoned main-thread work is outstanding. One capture site (snapshotAccessibilityUnavailable) was not switched to it. It kept writingrunnerAccessibilityHealthand invalidating the target from the command queue. - fix(ios-runner): snapshot capture plan reads and writes main-owned target state off the main thread #2781 then listed seven off-main reads and writes in the capture plan: target identity (
currentApp,currentBundleId,currentAppProcessIdentifier),runnerAccessibilityHealth, and the penalty warm-up flag. - fix(ios-runner): keep snapshot-plan target state on main and bound the query sweep by its slice #2836 fixed those seven sites. Its own review round then found another one: the coordinate-tap system-modal routing probe armed its abandonment penalty with
currentBundleId, read on the command queue (commit message ofeabe119233, "Two regressions fix(ios-runner): snapshot capture plan reads and writes main-owned target state off the main thread #2781 still leaves open").
At origin/main (6debef0634) no violation remains. Every capture-path access goes through the SnapshotCaptureTarget taken on main (RunnerTests+SnapshotCaptureTarget.swift:12-16,70-76), through applyMainOwnedSnapshotState (:81-105), or through a runMainThreadWork block (RunnerTests+Snapshot.swift:366). The protection is a comment (RunnerTests.swift:57-58). The next edit can reintroduce the same race, and nothing will fail.
Invariant. Code that can run off main cannot name a RunnerMainOwnedState member, or call a main-isolated closure, without failing the swift-runner-ios and swift-runner-macos gates.
The state goes into one @MainActor ownership type. Every main hop's work closure becomes @MainActor. The transport, the command queue, the capture plan, the off-main watchdog and the locked completion/abandonment owner from #2837 all stay nonisolated. The whole runner does not move to the main actor.
Swift 5 minimal checking alone does not hold the invariant. It rejects only some shapes. The design enforces it in three places:
- Compiler errors for the direct shapes.
- A build-log scan in both runner gates that fails on any actor-isolation diagnostic, warnings included. Swift 5 mode reports several real violations only as warnings.
- A source guard for the shapes the compiler does not diagnose at all.
Two corrections to the premise, verified at 6debef0634:
- The warm-up exemption is no longer main-owned. The third commit of fix(ios-runner): keep snapshot-plan target state on main and bound the query sweep by its slice #2836 moved it into the lock-owned
SnapshotXCTestPenaltyWarmupExemption(RunnerTests+SnapshotTiming.swift:63-87). Lifecycle code arms it on main (RunnerTests+Lifecycle.swift:205,250), and the plan consumes it on the queue (RunnerTests+SnapshotCapturePlan.swift:277). The lock is correct for this flag. It is out of scope. - The capture path also writes
needsPostSnapshotInteractionDelaythrough a hop (RunnerTests+SnapshotExecution.swift:119-135), and main reads it (RunnerTests+Lifecycle.swift:492-496). It is main-owned state that the capture path touches, so it is in scope.
What Swift 5 mode diagnoses
The toys are throwaway files, not repo code:
.claude/worktrees/_scratch/capture-ownership-spike/toy{2..6}.swiftandrev2/*.swift.claude/worktrees/_scratch/review-capture-ownership/*.swift
Swift 6.2.3 (Xcode 26.2) type-checked them with -swift-version 5 against the iOS-simulator SDK, and rev2/sdk.swift also against the macOS SDK. @MainActor final class Owned { var bundleId … } stands in for the ownership type.
Errors:
- a read or write of a
@MainActorclass member from a nonisolated function body - a call to a
@MainActormethod or a@MainActorclosure parameter from a nonisolated function body (closcall.swift:6,9) - a main member read inside a closure passed to a runner-declared
@Sendableparameter, including one formed in a@MainActorfunction (sendcall.swift:11, the fix(ios-runner): keep snapshot-plan target state on main and bound the query sweep by its slice #2836onAbandonedshape;rev2/rulee.swift:34-35) - a
@MainActorclosure parameter called directly inside a runner-declared@Sendableclosure (rev2/rulee.swift:26,rev2/rec.swift:21) - a main member read inside a
{ @Sendable in … }handler literal (rev2/rulee.swift:21) - a main member read in a closure assigned to
NWListener.stateUpdateHandlerfrom a@MainActorfunction (rev2/timer.swift:12)
Warnings only. These are real violations that build clean unless the gate scans the log:
- a main member read or write inside a
DispatchQueue.asyncclosure (sendcall.swift:8,rev2/timer.swift:13,16). Dispatch closures are imported as@preconcurrency @Sendable. That covers everycommandExecutionQueue.asyncandtransportQueueclosure. - a main member read inside
NWConnection.receive,.contentProcessedorAVAssetWriter.finishWritingcompletions (rev2/sdk.swift:11,14,16) - a
@MainActorclosure parameter called inside a Dispatch closure (sendcall.swift:7,[#ActorIsolatedCall]) - a
@MainActorfunction value converted to a nonisolated or@Sendablefunction type in a nonisolated context: "loses global actor 'MainActor'" (rev2/rulee.swift:23,32,33,37) - XCUI use inside a runner-declared
@Sendableclosure, for exampletarget.state(rev2/xcuisend.swift:7-8,11)
Not diagnosed at all. These are the holes the source guard closes:
- A non-
Sendableescaping closure formed in a@MainActorfunction silently takes main isolation, and a nonisolated callee can then run it off main (hole.swift, hole 1). Real carriers after the cascade:idleKeepaliveTimer.setEventHandleratRunnerTests.swift:319, formed in@MainActor testCommand(:285) and run ontransportQueue(:314)- the record-start
captureclosure (RunnerTests+CommandDispatch.swift:361-364), formed inexecuteOnMainPreparedand passed tostartRecording(capture:)andScreenRecorder.start(bootstrap:frame:)(RunnerTests+ScreenRecorder.swift:38-41,302-315) - the Transport
completionandafterSendparameters (RunnerTests+Transport.swift:51,92,190,205)
- A non-
Sendableclosure stored in a property from a@MainActorcontext (hole.swift, hole 2). The existing stored closures arerunnerMarkerWriter(RunnerTests.swift:93),inFlightCommandWaiters(:106) and the unit-test hooks (:207-213). - A
@MainActorclosure converted to a plain function type inside a@MainActorfunction, through an annotated local or anascast, and then captured by a Dispatch or@Sendableclosure (hole.swifthole 3,rev2/block.swift:16-17). - A closure literal bound to an unannotated local
letin a@MainActorfunction and then called from a Dispatch or@Sendableclosure (rev2/infer.swift). DispatchSourcesetEventHandlerandsetCancelHandlerclosures formed in a@MainActorfunction (rev2/timer.swift:11,rev2/sdk.swift:22). These handler parameters are not imported as@Sendable.
Compiled clean, as intended:
@MainActorwork closures passed through a generic hopMainActor.assumeIsolatedwith a capturedResult- closures handed to an ObjC-exception-catcher-style function from inside a
@MainActormethod DispatchQueue.main.asyncandasyncAfterclosures formed in a@MainActorfunction. They run on main.- runner-declared
@Sendableclosures that captureselfor anNWConnection. Minimal checking reports no capture warning for these (rev2/rulee.swift:28-29). @MainActor @convention(block) () -> Voidlocal bindings (rev2/block.swift:10)
Baseline: CI run 35981303070 (iOS workflow, PR #2873 branch, 2026-09-24) compiled the runner including UnitTests/ on a cache miss. It logged four distinct warnings: Lifecycle.swift:313 NSNumber cast, RunnerXCTestEventBridge.h:67 nullability, and SnapshotTimingTests.swift:112,115 redundant _. None is a concurrency diagnostic. Treating all warnings as errors would fail the base, and the targeted scan starts at zero.
Required behavior
Paths are under apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/ unless they start with scripts/.
-
Ownership type. Add
RunnerMainOwnedState.swift. The project uses file-system-synchronized groups (objectVersion = 77), so noproject.pbxprojedit is needed./// Runner state only the main thread reads or writes. Off-main code reads target identity from a /// `SnapshotCaptureTarget` taken on main and writes through `applyMainOwnedSnapshotState`. @MainActor final class RunnerMainOwnedState { var app: XCUIApplication? // was RunnerTests.currentApp var bundleId: String? // was currentBundleId var processIdentifier: Int? // was currentAppProcessIdentifier var accessibilityHealth: RunnerAccessibilityHealth = .unknown // was runnerAccessibilityHealth var needsPostSnapshotInteractionDelay = false nonisolated init() {} }
RunnerTestsdeclareslet mainOwned = RunnerMainOwnedState(). Delete the five old stored properties (RunnerTests.swift:57-61,86,99) and their comments. Do not keep forwarding accessors. Rename all 48 production reference lines and 118 unit-test reference lines. -
Main-hop boundary (
RunnerTests+MainThreadWork.swift,RunnerTests+SnapshotCaptureTarget.swift):func runMainThreadWork<T>( _ operation: String, timeout: TimeInterval, timeoutError: @escaping @Sendable () -> Error, onAbandoned: (@Sendable () -> Void)? = nil, _ work: @escaping @MainActor () throws -> T ) throws -> T func runMainThreadWorkIfIdle<T>(…, _ work: @escaping @MainActor () throws -> T) throws -> T? func applyMainOwnedSnapshotState(_ operation: String, _ write: @escaping @MainActor () -> Void)
- The
Thread.isMainThreadshortcut and theDispatchQueue.main.asyncblock both runworkthrough one private helper. The helper callsMainActor.assumeIsolated { result = Result { try work() } }and returns the capturedResult. - Do not use the
T: Sendableform (return try MainActor.assumeIsolated { try work() }). That form would add aT: Sendablerequirement to the generic hop and so to every caller's return type. Minimal checking never verifies that requirement.rev2/xcui.swiftpasses a non-Sendableclass throughassumeIsolatedand through aT: Sendableparameter with no diagnostic. The requirement would promise something no gate checks. Making the values that cross the hop honestlySendablebelongs to the strict-concurrency step (non-goal). This is not aboutXCUIApplication: XCUI classes are main-actor annotated and thereforeSendable(see non-goals). - Do not rely on implicit
@MainActorinference forDispatchQueue.main.asyncclosures. - Leave the lock order and the
finished/abandonedlogic (:110-192, fix(ios-runner): return work that finished at the timeout boundary and bound recorder frame capture #2837) unchanged. onAbandonedruns on the command queue.@Sendablemakes it nonisolated even when a@MainActorcontext forms it. That is the exact shape of the routing-probe bug from fix(ios-runner): keep snapshot-plan target state on main and bound the query sweep by its slice #2836 (RunnerTests+Snapshot.swift:359-364, the only production hook).timeoutErrorbecomes@Sendable. Call sites pass a closure literal or a static function. A bound instance-method reference such asself.mainThreadExecutionTimeoutErrorconverted to@Sendablewarns "converting non-Sendable function value" (rev2/sdk.swift:24). MakemainThreadExecutionTimeoutError()static, or wrap it.
- The
-
Closure isolation. Every escaping function type in production runner Swift carries
@MainActoror@Sendable(guard rules e to g).- Use
@MainActorfor a closure that runs only on main. - Use
@Sendablefor a closure that runs off main. - Never add
@MainActorto a closure that the compiler shows running off main.
The sites at
6debef0634:@Sendable: the TransportafterSendandcompletionparameters (Transport.swift:51,92,190,205),inFlightCommandWaiters(RunnerTests.swift:106), the localwaiters(Transport.swift:229),timeoutErrorandonAbandoned(MainThreadWork.swift:60-61,88,150-151),mainThreadWorkTimedOutForTesting(RunnerTests.swift:213, runs on the waiting thread), andSnapshotPhaseTimer.now(SnapshotTiming.swift:27,31).@MainActor:workandwrite(item 2),probe(Snapshot.swift:332, runs inside the probe's main work),systemModalProbeOverrideForTesting(RunnerTests.swift:207), andrunnerMarkerWriter(RunnerTests.swift:93, called from main-only marker writers).- Let the compiler decide for
alertResolutionOverrideForTestingandalertButtonHittabilityProbeOverrideForTesting(:209-210). If they run on main, they become@MainActor. performWithQuiescenceSkippedIfSupported(Lifecycle.swift:412-451) joins the cascade. Its localblockbecomes@MainActor @convention(block) () -> Void.- Recorder.
startRecordingbecomes@MainActor, and itscaptureparameter becomes@escaping @MainActor.ScreenRecorder.startstays nonisolated. Itsbootstrapparameter becomes non-escaping, because it runs only insidestart's bootstrap loop on the caller's thread. Itsframeparameter becomes@escaping @Sendable.- The frame tick keeps calling
captureonly inside therunMainThreadWorkIfIdlework closure.rev2/rec.swifttype-checks this shape.
DispatchSourcehandlers. The handlers atRunnerTests.swift:319andScreenRecorder.swift:135open with@Sendable({ @Sendable inand{ @Sendable [weak self] in).- Captured
vars. A@Sendableclosure that reads or mutates a captured localvarwarns[#SendableClosureCaptures](rev2/capvar.swift). The scan does not fail on that warning, but the PR adds no new warnings. Where a unit test injects such a closure (SnapshotTimingTests.swift:110), capture a reference box instead.
- Use
-
Cascade. Let the compiler list the sites. Add
@MainActorto each function it reports that runs only on main. The expected set is about 42 functions:Lifecycle:ensureRunnerHostAppActive,invalidateCachedTarget,resetTargetAfterExternalRelaunch,refreshCachedTargetIfProcessChanged,canUseFastForegroundAppGuard,activateTarget,applyInteractionStabilizationIfNeeded,performWithQuiescenceSkippedIfSupportedCommandDispatch:executeOnMainSafely,executeOnMain,prepareActiveCommandContext,shouldSkipAppActivationPreflight,hasCachedTargetForActivationSkip,resolveAppWithoutActivationexecuteOnMainPrepared,executeTypeCommand,tapInAppBackControl,tapTopLeadingNavigationFallback,startRecordingrememberTextEntryTap,rememberedTextEntryTarget,focusTextInputForTextEntry,typeTextReliably,runSynthesizedReplacementRoutesynthesizedCoordinateContext,synthesizedSequenceCoordinateContext,synthesizedTapAt,synthesizedDragAt,axFreeSynthesizedDragPlanexecuteDragGesture,executeScrollDragGesture,executeSynthesizedDragGesture,executeSequence,performSequenceSteptakeSnapshotCaptureTarget,prepareActiveCommandContextSafely- the main-slice capture helpers
snapshotElementsQuery(RunnerTests+SnapshotAcquisition.swift:522-535),recursiveTreeSnapshotAcquisitionandquerySweepSnapshotAcquisition, which run only insiderunMainThreadWorkblocks (RunnerTests+SnapshotCapturePlan.swift:459-481)
-
Command-queue functions stay nonisolated. Never add
@MainActor,nonisolated(unsafe)orMainActor.assumeIsolatedto these functions to make them compile or to silence the scan:- capture plan:
runSnapshotCapturePlan,snapshotFast,snapshotRaw,executeSnapshotDispatched*,executeSnapshotPrepared - dispatch:
executeAccepted,executeDispatched,executeDispatchedWithRecovery,enqueueAccepted,shouldRouteToSpringboardBlockingSystemModal,boundedBlockingSystemAlertSnapshot* - snapshot tiers:
makeSnapshotTraversalContext,privateAXSnapshotAcquisition stampedSnapshotPayload,snapshotAccessibilityUnavailable- every
Transportfunction, everyMainThreadWorkfunction, andScreenRecorder.start
If one of them fails the build or the scan, it contains a real off-main access. Fix it by reading from
SnapshotCaptureTargetor by writing throughapplyMainOwnedSnapshotState. If neither fits, stop and report the site. Do not widen isolation.The same applies when a closure made
@Sendablein item 3 touches XCUI in its body. The scan reports that as a main actor-isolated warning. It is a real off-main XCUI use. Report it and do not change the closure to@MainActorunless it runs on main. - capture plan:
-
Unit tests. Mark test extensions or methods that touch
mainOwned, or that call a newly isolated function, with@MainActor. XCTest already runs them on main, so this changes no behavior.MainActor.assumeIsolatedis allowed underUnitTests/. Do not rename or move tests. Keep the#if AGENT_DEVICE_RUNNER_UNIT_TESTSguards unchanged. -
Isolation diagnostics fail the runner gates. Both
swift-runner-iosandswift-runner-macosrunscripts/build-xcuitest-apple.sh(checks.ts:75-76).- The script captures the
xcodebuild build-for-testingoutput and keeps xcodebuild's exit status. After a successful build it fails when any Swift diagnostic line,warningorerror, containsactor-isolatedorloses global actor. It prints each matching line. This pattern covers every warning-only shape listed above. The[#ActorIsolatedCall]group tag is not required to match. - Do not use the
-Werror <group>flag. It needs Swift 6.1 or later and conflicts with the Xcode 16 floor (ci.yml:48-65). - Do not use
SWIFT_TREAT_WARNINGS_AS_ERRORS. It fails the base on four unrelated warnings. - Other concurrency warnings (
[#SendableClosureCaptures], "converting non-Sendable function value") are not gated. They belong to strict concurrency. - The CI cache key hashes
apple/runner/**andscripts/build-xcuitest-apple.sh(setup-apple-runner-build/action.yml). Any runner or script change therefore misses the cache and compiles every file cold, and the scan sees every diagnostic. - A local incremental build prints diagnostics only for recompiled files. The script says so when it reuses DerivedData.
- tvOS and visionOS builds run the same script and get the same scan.
- The script captures the
-
Source guard. Add
scripts/__tests__/runner-main-actor-boundary.test.tsto the explicitunit-coreinclude list invitest.config.ts. It reads the trackedapple/runner/**/*.swiftfiles with comments removed byscripts/strip-swift-comments.mjs. It fails when any of these is true:- (a) the
RunnerMainOwnedStatedeclaration is not@MainActor. - (b) a member of that class is
nonisolated, other thannonisolated init(). - (c)
nonisolated(unsafe)appears in production runner Swift (outsideUnitTests/). - (d)
MainActor.assumeIsolatedappears in production runner Swift outsideRunnerTests+MainThreadWork.swiftandRunnerTests+SnapshotCaptureTarget.swift. - (e) in production runner Swift, an escaping function-typed parameter is not declared
@MainActoror@Sendable. This covers@escapingparameters and optional function-typed parameters, which are escaping without the keyword (for exampleonAbandoned: (() -> Void)?). The same applies to a stored property whose type contains a function type, including insideOptional,ArrayorDictionary(for exampleinFlightCommandWaiters). - (f) in production runner Swift, a function type in a local binding annotation or in an
as,as?oras!cast lacks@MainActoror@Sendable, or a locallet/varis bound directly to a closure literal without a type annotation. This rule closes the conversion hole and the inferred-local hole. No such unannotated local exists at6debef0634. - (g) in production runner Swift, a closure literal passed to a
DispatchSourcehandler setter (setEventHandler,setCancelHandler,setRegistrationHandler) does not open with@Sendable. Of the SDK callbacks the runner uses, these are the only escaping handler parameters that are imported without@Sendableand run off main (rev2/sdk.swift,rev2/timer.swift).
Rules (e) and (f) exempt non-escaping parameters, parameter positions inside a written function type, and
@convention(c)types, because none of these can carry a closure past the call. Each rule has an in-memory fixture case that trips it. One case runs over the real tree and passes. The guard keys on declarations and syntax, not on a list of property names, so the type stays the only source of truth.Remaining gap: a future SDK API that takes a non-
Sendableescaping handler and runs it off main. Rule (g) names today's setters. A PR that adds such a call in a@MainActorfunction must extend rule (g). - (a) the
No change to runtime behavior, timeouts, budgets, wire shape or log markers. No change to the daemon or TypeScript production code.
Completion conditions
git grep -nE '\b(currentApp|currentBundleId|currentAppProcessIdentifier|runnerAccessibilityHealth)\b' apple/runnerfinds no code, only prose that names the retired fields, if any.needsPostSnapshotInteractionDelayappears only as a member ofRunnerMainOwnedStateand throughmainOwned..- Gate-failure proof, in the PR body. Apply each of these throwaway edits to the PR head alone. Run
pnpm build:xcuitest:ios, the command theswift-runner-iosgate runs, withAGENT_DEVICE_IOS_CLEAN_DERIVED=1or on a file that recompiles. Each run must exit non-zero. Paste the diagnostic line, and for (d) also the scan's failure line.- (a) the fix(ios-runner): snapshot capture plan reads and writes main-owned target state off the main thread #2781 shape:
RunnerTests+SnapshotCapturePlan.swift:288readsmainOwned.bundleIdinstead oftarget.bundleId. Expect a compile error. - (b) the fix(ios): keep the runner alive through hostile snapshots #2621/fix(ios-runner): snapshot capture plan reads and writes main-owned target state off the main thread #2781 shape:
snapshotAccessibilityUnavailablecallsinvalidateCachedTarget(reason:)directly instead of throughapplyMainOwnedSnapshotState. Expect a compile error. - (c) the fix(ios-runner): keep snapshot-plan target state on main and bound the query sweep by its slice #2836 shape: the system-modal probe's
onAbandonedhook (RunnerTests+Snapshot.swift:359-364) penalizesself.mainOwned.bundleId. Expect a compile error. - (d) a Dispatch closure read: inside
enqueueAccepted'scommandExecutionQueue.async { … }(RunnerTests+Transport.swift:193), readself.mainOwned.bundleId. The compiler only warns. The build-log scan must fail the run. - (e) the record-start capture: the
captureclosure atRunnerTests+CommandDispatch.swift:361-364readsself.mainOwned.app, and the frame tick instartRecordingcallscapture()directly instead of inside therunMainThreadWorkIfIdlework closure. Expect a compile error,[#ActorIsolatedCall].
- (a) the fix(ios-runner): snapshot capture plan reads and writes main-owned target state off the main thread #2781 shape:
- The new guard test fails on
origin/main, where the type is absent, and passes on the PR. Each of rules (a) to (g) has a fixture case that turns it red. - The scan has a fixture test. A synthetic log containing a
main actor-isolatedwarning line and aloses global actorwarning line fails the scan. A log with only the four baseline warnings passes. - The
swift-runner-iosgate (ios.yml:118) and theswift-runner-macosgate (macos.yml:90) build green with the scan enabled. The PR adds no new compiler warnings of any kind relative to the base. Put the per-gate warning counts, base and head, in the PR body. - CI compiles neither tvOS nor visionOS.
#if os(tvOS)branches inNavigation,TextEntryFocusandSynthesizedInteractiontouch this state. Runpnpm build:xcuitest:tvosandpnpm build:xcuitest:visionoslocally, with the scan, and record the Xcode version. If an SDK is not installed, say so in the PR body. Do not skip this silently. - Runner unit tests pass on the macOS host lane, and the iOS simulator lane with
AGENT_DEVICE_RUNNER_UNIT_TESTSpasses, includingMainThreadWorkTests,SnapshotCaptureTargetTests,SnapshotCapturePlanOccupancyTests,SnapshotExecutionTests,SnapshotTests,LifecycleCacheTests,LifecycleTestsandRecordingTests. Signing notes are indocs/agents/testing.md. pnpm check:xctest-selection,pnpm check:packaged-runner-swift(parse and line parity for the new file),pnpm format,pnpm lint,pnpm typecheckandpnpm check:affected --runpass. The iOS simulator smoke lane is green.
Non-goals
- Strict concurrency (
SWIFT_STRICT_CONCURRENCY=complete), Swift 6 language mode, or@MainActoronRunnerTests, the transport, the command journal or the watchdog.Sendableconformance of the values that cross the hop is part of that step. - Off-main use of XCUI objects, including the
SnapshotCaptureTarget.appreference that the capture plan uses on the command queue.- The Xcode 26.2
XCUIAutomation.frameworkheaders markXCUIApplication(XCUIApplication.h:23),XCUIElement(XCUIElement.h:47, among others),XCUICoordinate(XCUICoordinate.h:23) andXCUIDevice(XCUIDevice.h:37) withXCUI_SWIFT_MAIN_ACTOR, which isswift_attr("@MainActor"). - They are imported as preconcurrency, so minimal checking does not diagnose their use from a nonisolated function or a Dispatch closure (
rev2/xcui.swift, zero diagnostics). That is the only reason these uses stay unchecked. -strict-concurrency=completewarns on each one (rev2/xcui.swift:7,12), and Swift 6 mode rejects them. Any later strict-concurrency or Swift 6 step will flag the capture plan's XCUI use.- The preconcurrency relief does not apply inside a runner-declared
@Sendableclosure, where minimal checking already warns (rev2/xcuisend.swift). The scan in item 7 therefore fails on XCUI use in the closures that item 3 makes@Sendable. That is intended. - Main-actor-annotated classes are
Sendable, soXCUIApplicationis not what blocks theT: Sendableform in item 2.
- The Xcode 26.2
- The unbounded
DispatchQueue.main.syncsites (RunnerAppScreenCapture.swift:97,RunnerTests+Lifecycle.swift:95). - Abandoned main-slice work that writes state after its slice ends, for example
snapshotElementsQueryinvalidating late. That work runs on main, so it is not a data race. Slice discipline belongs to fix(ios-runner): non-interactive query sweep outlives its 1s tier slice and causes RUNNER_BUSY #2783. pendingTargetActivation(Silent target re-activation lets a screenshot and the next snapshot describe different apps #2682). This scout found thatexecuteAcceptedreads and clears it on the command queue (RunnerTests+CommandDispatch.swift:9,13,17,20), whileactivateTargetwrites it on main (RunnerTests+Lifecycle.swift:364). The write can come from main work that the watchdog has abandoned. It can then land after the timed-out command cleared the field, and a later command can be stamped with the wrong activation fact. The field is command-level, not capture-path, and the fix needs a design decision: bind the fact to its command id under a lock. File it as a separate bug. Moving it intoRunnerMainOwnedStatelater will make the compiler reject the current read.- Other main-side state that the capture path does not touch (
textEntryTapWitness,firstInteractionReadyUptime,lastLogged*,activeRecording). These can move into the type later. - The warm-up exemption, which is already lock-owned.
- A CHANGELOG entry. This change is internal.
Dependencies / related
- Builds on fix(ios-runner): keep snapshot-plan target state on main and bound the query sweep by its slice #2836 (merged,
eabe119233), which addedSnapshotCaptureTarget,SnapshotProbePenaltyIdentityandapplyMainOwnedSnapshotState, and on fix(ios-runner): return work that finished at the timeout boundary and bound recorder frame capture #2837 (merged,9dd537975f), which owns completion versus abandonment inRunnerTests+MainThreadWork.swift. Keep both unchanged. - Origin of the bug class: fix(ios-runner): snapshot capture plan reads and writes main-owned target state off the main thread #2781 (closed), and fix(ios): keep the runner alive through hostile snapshots #2621, whose hop one site missed. fix(ios-runner): non-interactive query sweep outlives its 1s tier slice and causes RUNNER_BUSY #2783 (closed) is the sibling slice-deadline class and is not addressed here. Tracking: Apple platform + runner: simplify, shrink, and preserve correctness (tracking) #2803.
- ADR 0004 (capture-plan tiers) and ADR 0005 (runner occupancy and the busy gate) are unchanged.
- ADR 0019, ADR 0027, eager-closure budgets, R9 and fallow do not apply. The change touches Swift, one build script and one script test. It touches no TypeScript production module or package boundary.
- Toolchain floor: the project format needs Xcode 16 or later, and
ci.yml:48-65protects compatibility with compilers before Swift 6.1.- The design uses only
@MainActor(Swift 5.5),@Sendableclosure types (5.5) andMainActor.assumeIsolated(5.9;@_alwaysEmitIntoClient, so it back-deploys to iOS 15.6 and macOS 13). - Diagnostic wording can change between compilers. The scan keys on the stable phrases
actor-isolatedandloses global actor, and the gate-failure proofs re-verify them on the CI Xcode. - A future compiler that diagnoses preconcurrency XCUI use in minimal mode will fail the scan on the capture plan's XCUI use. That is the strict-concurrency follow-up arriving, not a false positive.
- The design uses only
- Cost (estimate):
- Effort: 2-3 agent-days. Most of it is compiler-driven iteration on two SDKs, the closure annotations and the recorder change, plus the local tvOS and visionOS builds.
- Net production lines: about +70 to +100. That is the type (about 25 lines), about 42 one-line
@MainActorattributes, the boundary helper (+10), the build-log scan (+15), and the recorder change (+5), minus the deleted fields and comments (-10). The closure annotations and renames are line-neutral. - Tests: about +160 lines for the TypeScript guard and scan fixtures, and about +20 to +40 lines of Swift test annotations and reference boxes.
- Gross diff: about 650-850 lines, mostly the 166 renamed reference lines. That fits the 1,000-line budget in one PR.
- Bug fixes: none at runtime. The value is that the next recurrence of a bug class that has already recurred three times fails the runner gates.
Activity
refactor(ios-runner): own main-thread capture state in RunnerMainOwne…
refactor(ios-runner): own main-thread capture state in RunnerMainOwne…
refactor(ios-runner): own main-thread capture state in RunnerMainOwne…
refactor(ios-runner): own main-thread capture state in RunnerMainOwne…
refactor(ios-runner): make off-main access to main-owned capture stat…
thymikee commentedon Sep 28, 2026
Fixed by #2911: off-main access to main-owned capture state now fails the runner gates (
RunnerMainOwnedState).