Skip to content

Apple runner: make off-main access to main-owned capture state fail the runner gates (RunnerMainOwnedState) #2882

Description

@thymikee

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:

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:

  1. Compiler errors for the direct shapes.
  2. 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.
  3. 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 needsPostSnapshotInteractionDelay through 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}.swift and rev2/*.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 @MainActor class member from a nonisolated function body
  • a call to a @MainActor method or a @MainActor closure parameter from a nonisolated function body (closcall.swift:6,9)
  • a main member read inside a closure passed to a runner-declared @Sendable parameter, including one formed in a @MainActor function (sendcall.swift:11, the fix(ios-runner): keep snapshot-plan target state on main and bound the query sweep by its slice #2836 onAbandoned shape; rev2/rulee.swift:34-35)
  • a @MainActor closure parameter called directly inside a runner-declared @Sendable closure (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.stateUpdateHandler from a @MainActor function (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.async closure (sendcall.swift:8, rev2/timer.swift:13,16). Dispatch closures are imported as @preconcurrency @Sendable. That covers every commandExecutionQueue.async and transportQueue closure.
  • a main member read inside NWConnection.receive, .contentProcessed or AVAssetWriter.finishWriting completions (rev2/sdk.swift:11,14,16)
  • a @MainActor closure parameter called inside a Dispatch closure (sendcall.swift:7, [#ActorIsolatedCall])
  • a @MainActor function value converted to a nonisolated or @Sendable function type in a nonisolated context: "loses global actor 'MainActor'" (rev2/rulee.swift:23,32,33,37)
  • XCUI use inside a runner-declared @Sendable closure, for example target.state (rev2/xcuisend.swift:7-8,11)

Not diagnosed at all. These are the holes the source guard closes:

  • A non-Sendable escaping closure formed in a @MainActor function silently takes main isolation, and a nonisolated callee can then run it off main (hole.swift, hole 1). Real carriers after the cascade:
    • idleKeepaliveTimer.setEventHandler at RunnerTests.swift:319, formed in @MainActor testCommand (:285) and run on transportQueue (:314)
    • the record-start capture closure (RunnerTests+CommandDispatch.swift:361-364), formed in executeOnMainPrepared and passed to startRecording(capture:) and ScreenRecorder.start(bootstrap:frame:) (RunnerTests+ScreenRecorder.swift:38-41,302-315)
    • the Transport completion and afterSend parameters (RunnerTests+Transport.swift:51,92,190,205)
  • A non-Sendable closure stored in a property from a @MainActor context (hole.swift, hole 2). The existing stored closures are runnerMarkerWriter (RunnerTests.swift:93), inFlightCommandWaiters (:106) and the unit-test hooks (:207-213).
  • A @MainActor closure converted to a plain function type inside a @MainActor function, through an annotated local or an as cast, and then captured by a Dispatch or @Sendable closure (hole.swift hole 3, rev2/block.swift:16-17).
  • A closure literal bound to an unannotated local let in a @MainActor function and then called from a Dispatch or @Sendable closure (rev2/infer.swift).
  • DispatchSource setEventHandler and setCancelHandler closures formed in a @MainActor function (rev2/timer.swift:11, rev2/sdk.swift:22). These handler parameters are not imported as @Sendable.

Compiled clean, as intended:

  • @MainActor work closures passed through a generic hop
  • MainActor.assumeIsolated with a captured Result
  • closures handed to an ObjC-exception-catcher-style function from inside a @MainActor method
  • DispatchQueue.main.async and asyncAfter closures formed in a @MainActor function. They run on main.
  • runner-declared @Sendable closures that capture self or an NWConnection. Minimal checking reports no capture warning for these (rev2/rulee.swift:28-29).
  • @MainActor @convention(block) () -> Void local 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/.

  1. Ownership type. Add RunnerMainOwnedState.swift. The project uses file-system-synchronized groups (objectVersion = 77), so no project.pbxproj edit 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() {}
    }

    RunnerTests declares let 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.

  2. 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.isMainThread shortcut and the DispatchQueue.main.async block both run work through one private helper. The helper calls MainActor.assumeIsolated { result = Result { try work() } } and returns the captured Result.
    • Do not use the T: Sendable form (return try MainActor.assumeIsolated { try work() }). That form would add a T: Sendable requirement to the generic hop and so to every caller's return type. Minimal checking never verifies that requirement. rev2/xcui.swift passes a non-Sendable class through assumeIsolated and through a T: Sendable parameter with no diagnostic. The requirement would promise something no gate checks. Making the values that cross the hop honestly Sendable belongs to the strict-concurrency step (non-goal). This is not about XCUIApplication: XCUI classes are main-actor annotated and therefore Sendable (see non-goals).
    • Do not rely on implicit @MainActor inference for DispatchQueue.main.async closures.
    • Leave the lock order and the finished/abandoned logic (:110-192, fix(ios-runner): return work that finished at the timeout boundary and bound recorder frame capture #2837) unchanged.
    • onAbandoned runs on the command queue. @Sendable makes it nonisolated even when a @MainActor context 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).
    • timeoutError becomes @Sendable. Call sites pass a closure literal or a static function. A bound instance-method reference such as self.mainThreadExecutionTimeoutError converted to @Sendable warns "converting non-Sendable function value" (rev2/sdk.swift:24). Make mainThreadExecutionTimeoutError() static, or wrap it.
  3. Closure isolation. Every escaping function type in production runner Swift carries @MainActor or @Sendable (guard rules e to g).

    • Use @MainActor for a closure that runs only on main.
    • Use @Sendable for a closure that runs off main.
    • Never add @MainActor to a closure that the compiler shows running off main.

    The sites at 6debef0634:

    • @Sendable: the Transport afterSend and completion parameters (Transport.swift:51,92,190,205), inFlightCommandWaiters (RunnerTests.swift:106), the local waiters (Transport.swift:229), timeoutError and onAbandoned (MainThreadWork.swift:60-61,88,150-151), mainThreadWorkTimedOutForTesting (RunnerTests.swift:213, runs on the waiting thread), and SnapshotPhaseTimer.now (SnapshotTiming.swift:27,31).
    • @MainActor: work and write (item 2), probe (Snapshot.swift:332, runs inside the probe's main work), systemModalProbeOverrideForTesting (RunnerTests.swift:207), and runnerMarkerWriter (RunnerTests.swift:93, called from main-only marker writers).
    • Let the compiler decide for alertResolutionOverrideForTesting and alertButtonHittabilityProbeOverrideForTesting (:209-210). If they run on main, they become @MainActor.
    • performWithQuiescenceSkippedIfSupported (Lifecycle.swift:412-451) joins the cascade. Its local block becomes @MainActor @convention(block) () -> Void.
    • Recorder.
      • startRecording becomes @MainActor, and its capture parameter becomes @escaping @MainActor.
      • ScreenRecorder.start stays nonisolated. Its bootstrap parameter becomes non-escaping, because it runs only inside start's bootstrap loop on the caller's thread. Its frame parameter becomes @escaping @Sendable.
      • The frame tick keeps calling capture only inside the runMainThreadWorkIfIdle work closure. rev2/rec.swift type-checks this shape.
    • DispatchSource handlers. The handlers at RunnerTests.swift:319 and ScreenRecorder.swift:135 open with @Sendable ({ @Sendable in and { @Sendable [weak self] in).
    • Captured vars. A @Sendable closure that reads or mutates a captured local var warns [#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.
  4. Cascade. Let the compiler list the sites. Add @MainActor to each function it reports that runs only on main. The expected set is about 42 functions:

    • Lifecycle: ensureRunnerHostAppActive, invalidateCachedTarget, resetTargetAfterExternalRelaunch, refreshCachedTargetIfProcessChanged, canUseFastForegroundAppGuard, activateTarget, applyInteractionStabilizationIfNeeded, performWithQuiescenceSkippedIfSupported
    • CommandDispatch: executeOnMainSafely, executeOnMain, prepareActiveCommandContext, shouldSkipAppActivationPreflight, hasCachedTargetForActivationSkip, resolveAppWithoutActivation
    • executeOnMainPrepared, executeTypeCommand, tapInAppBackControl, tapTopLeadingNavigationFallback, startRecording
    • rememberTextEntryTap, rememberedTextEntryTarget, focusTextInputForTextEntry, typeTextReliably, runSynthesizedReplacementRoute
    • synthesizedCoordinateContext, synthesizedSequenceCoordinateContext, synthesizedTapAt, synthesizedDragAt, axFreeSynthesizedDragPlan
    • executeDragGesture, executeScrollDragGesture, executeSynthesizedDragGesture, executeSequence, performSequenceStep
    • takeSnapshotCaptureTarget, prepareActiveCommandContextSafely
    • the main-slice capture helpers snapshotElementsQuery (RunnerTests+SnapshotAcquisition.swift:522-535), recursiveTreeSnapshotAcquisition and querySweepSnapshotAcquisition, which run only inside runMainThreadWork blocks (RunnerTests+SnapshotCapturePlan.swift:459-481)
  5. Command-queue functions stay nonisolated. Never add @MainActor, nonisolated(unsafe) or MainActor.assumeIsolated to 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 Transport function, every MainThreadWork function, and ScreenRecorder.start

    If one of them fails the build or the scan, it contains a real off-main access. Fix it by reading from SnapshotCaptureTarget or by writing through applyMainOwnedSnapshotState. If neither fits, stop and report the site. Do not widen isolation.

    The same applies when a closure made @Sendable in 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 @MainActor unless it runs on main.

  6. 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.assumeIsolated is allowed under UnitTests/. Do not rename or move tests. Keep the #if AGENT_DEVICE_RUNNER_UNIT_TESTS guards unchanged.

  7. Isolation diagnostics fail the runner gates. Both swift-runner-ios and swift-runner-macos run scripts/build-xcuitest-apple.sh (checks.ts:75-76).

    • The script captures the xcodebuild build-for-testing output and keeps xcodebuild's exit status. After a successful build it fails when any Swift diagnostic line, warning or error, contains actor-isolated or loses 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/** and scripts/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.
  8. Source guard. Add scripts/__tests__/runner-main-actor-boundary.test.ts to the explicit unit-core include list in vitest.config.ts. It reads the tracked apple/runner/**/*.swift files with comments removed by scripts/strip-swift-comments.mjs. It fails when any of these is true:

    • (a) the RunnerMainOwnedState declaration is not @MainActor.
    • (b) a member of that class is nonisolated, other than nonisolated init().
    • (c) nonisolated(unsafe) appears in production runner Swift (outside UnitTests/).
    • (d) MainActor.assumeIsolated appears in production runner Swift outside RunnerTests+MainThreadWork.swift and RunnerTests+SnapshotCaptureTarget.swift.
    • (e) in production runner Swift, an escaping function-typed parameter is not declared @MainActor or @Sendable. This covers @escaping parameters and optional function-typed parameters, which are escaping without the keyword (for example onAbandoned: (() -> Void)?). The same applies to a stored property whose type contains a function type, including inside Optional, Array or Dictionary (for example inFlightCommandWaiters).
    • (f) in production runner Swift, a function type in a local binding annotation or in an as, as? or as! cast lacks @MainActor or @Sendable, or a local let/var is 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 at 6debef0634.
    • (g) in production runner Swift, a closure literal passed to a DispatchSource handler 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 @Sendable and 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-Sendable escaping handler and runs it off main. Rule (g) names today's setters. A PR that adds such a call in a @MainActor function must extend rule (g).

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/runner finds no code, only prose that names the retired fields, if any. needsPostSnapshotInteractionDelay appears only as a member of RunnerMainOwnedState and through mainOwned..
  • 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 the swift-runner-ios gate runs, with AGENT_DEVICE_IOS_CLEAN_DERIVED=1 or on a file that recompiles. Each run must exit non-zero. Paste the diagnostic line, and for (d) also the scan's failure line.
  • 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-isolated warning line and a loses global actor warning line fails the scan. A log with only the four baseline warnings passes.
  • The swift-runner-ios gate (ios.yml:118) and the swift-runner-macos gate (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 in Navigation, TextEntryFocus and SynthesizedInteraction touch this state. Run pnpm build:xcuitest:tvos and pnpm build:xcuitest:visionos locally, 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_TESTS passes, including MainThreadWorkTests, SnapshotCaptureTargetTests, SnapshotCapturePlanOccupancyTests, SnapshotExecutionTests, SnapshotTests, LifecycleCacheTests, LifecycleTests and RecordingTests. Signing notes are in docs/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 typecheck and pnpm check:affected --run pass. The iOS simulator smoke lane is green.

Non-goals

  • Strict concurrency (SWIFT_STRICT_CONCURRENCY=complete), Swift 6 language mode, or @MainActor on RunnerTests, the transport, the command journal or the watchdog. Sendable conformance of the values that cross the hop is part of that step.
  • Off-main use of XCUI objects, including the SnapshotCaptureTarget.app reference that the capture plan uses on the command queue.
    • The Xcode 26.2 XCUIAutomation.framework headers mark XCUIApplication (XCUIApplication.h:23), XCUIElement (XCUIElement.h:47, among others), XCUICoordinate (XCUICoordinate.h:23) and XCUIDevice (XCUIDevice.h:37) with XCUI_SWIFT_MAIN_ACTOR, which is swift_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=complete warns 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 @Sendable closure, 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, so XCUIApplication is not what blocks the T: Sendable form in item 2.
  • The unbounded DispatchQueue.main.sync sites (RunnerAppScreenCapture.swift:97, RunnerTests+Lifecycle.swift:95).
  • Abandoned main-slice work that writes state after its slice ends, for example snapshotElementsQuery invalidating 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 that executeAccepted reads and clears it on the command queue (RunnerTests+CommandDispatch.swift:9,13,17,20), while activateTarget writes 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 into RunnerMainOwnedState later 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 added SnapshotCaptureTarget, SnapshotProbePenaltyIdentity and applyMainOwnedSnapshotState, 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 in RunnerTests+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: simplification and correctness audit (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-65 protects compatibility with compilers before Swift 6.1.
    • The design uses only @MainActor (Swift 5.5), @Sendable closure types (5.5) and MainActor.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-isolated and loses 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.
  • 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 @MainActor attributes, 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions