Skip to content

fix: keep XCTest tree snapshots on main#1144

Merged
thymikee merged 2 commits into
mainfrom
fix/ios-runner-main-thread-snapshot
Jul 7, 2026
Merged

fix: keep XCTest tree snapshots on main#1144
thymikee merged 2 commits into
mainfrom
fix/ios-runner-main-thread-snapshot

Conversation

@thymikee

@thymikee thymikee commented Jul 7, 2026

Copy link
Copy Markdown
Member

Summary

Keep iOS/macOS XCTest tree snapshot capture on the main queue while preserving bounded recovery.

  • route snapshot commands through a background orchestrator that dispatches XCTest work to bounded main-queue slices
  • mark timed-out tree captures as abandoned main work and skip XCTest-backed recovery tiers until they drain
  • keep private AX fallback responsive when XCTest capture is occupied
  • add Swift and TS guards so the old DispatchQueue.global tree snapshot path regresses visibly

Fixes #1122

Validation

  • pnpm format
  • pnpm check:quick
  • pnpm build
  • pnpm exec vitest run src/tests/apple-runner-package-source.test.ts
  • pnpm build:xcuitest
  • AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 pnpm build:xcuitest:ios
  • xcodebuild test-without-building -project apple-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -destination 'platform=iOS Simulator,id=C25DBB5B-9254-4293-A8D5-2785C78DE03A' -derivedDataPath /Users/thymikee/.agent-device/apple-runner/derived -only-testing:AgentDeviceRunnerUITests/RunnerTests/testRunMainThreadWorkExecutesOffMainCallerOnMainThread

Note: a focused macOS test-without-building attempt failed before reaching tests with an XCTest bootstrap crash; the same focused unit test executed successfully on iOS simulator.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.6 MB 1.6 MB 0 B
JS gzip 503.0 kB 503.0 kB 0 B
npm tarball 600.8 kB 601.6 kB +808 B
npm unpacked 2.1 MB 2.1 MB +7.0 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.9 ms 29.8 ms +0.9 ms
CLI --help 55.9 ms 58.6 ms +2.7 ms

Top changed chunks: no changes in the largest emitted chunks.

@thymikee

thymikee commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Review

Reviewed the full diff plus the surrounding runner internals (runMainThreadWork, the plan tier loop, lock usages). This is a well-structured fix for #1122: the tree-capture XPC moves from DispatchQueue.global onto a bounded main-queue slice, timeouts mark the capture abandoned, and the plan skips XCTest-backed tiers (kind != .privateAX && hasAbandonedTreeCapture()) so only the private-AX backend runs while the abandoned work drains. A few things I verified as sound:

  • Lock ordering is clean. onAbandoned runs under mainThreadWorkLock and reaches for treeCaptureLock / snapshotTreePenaltyLock, but neither of those ever reaches back for mainThreadWorkLock, so there's no inversion. onDrained runs after mainThreadWorkLock is released.
  • Drain accounting is correct. abandonedMainThreadWorkCount is incremented/decremented under the lock and only clears abandonedMainThreadWorkSince at 0 — no lost-wakeup from the earlier single-Date shape.
  • Backpressure holds together: abandoning a capture bumps both the general and tree counters, so a fresh command hits RUNNER_BUSY/RUNNER_WEDGED before re-touching main.

Findings

1. (medium) Snapshot preflight lost its ObjC-exception guard — asymmetric with every other command.
For taps/fills, prepareActiveCommandContext (activate/waitForExistence/.state/findScopeElement) runs inside executeOnMainSafely's RunnerObjCExceptionCatcher.catchException (RunnerTests+CommandExecution.swift:475). On the new dispatched-snapshot path it runs via executeSnapshotDispatchedOncerunMainThreadWork { prepareActiveCommandContext } (:580), and runMainThreadWork executes the closure with a bare try work() — no catcher. An ObjC NSException from activation/preflight during a snapshot command, which was previously converted to an error response (with the retry path), would now propagate uncaught on the main queue and take down the runner (recoverable via restart, but heavier than before). The deepest calls (captureSnapshotRootelement.snapshot(), per-node reads) keep their own internal catchException, so exposure is limited to preflight/scope-resolution — but the asymmetry is real. Worth either wrapping the prepare hop in the catcher or confirming those preflight ops can't raise NSException.

2. (minor, perf) setNeedsPostSnapshotInteractionDelay() predictably wastes ~1s after a private-AX recovery.
When the tree tier is abandoned but the plan recovers via private-AX, the capture is still grinding on main. executeSnapshotPrepared (:596) then calls setNeedsPostSnapshotInteractionDelay() (:625), which dispatches a 1s main hop that is guaranteed to sit behind the abandoned capture and time out — adding ~1s to an otherwise-successful fast response and double-incrementing the busy counter. privateAXSnapshotViewport already guards this exact situation with hasAbandonedTreeCapture(); consider the same guard here (or set the flag optimistically).

3. (coverage) The subtle concurrency invariants are untested.
testRunMainThreadWorkExecutesOffMainCallerOnMainThread only covers the happy path (work runs on main), and the TS test is a source-grep regression guard. The genuinely tricky logic — timeout → abandon → drain counting, tier-skip while abandoned, busy→wedged escalation — has no test. A Swift test that forces a slice timeout and asserts the counters/tier-skip would protect the core of this change.

4. (nit) extractSwiftFunction brace-matching is string/comment-naive.
The TS guard counts raw {/} and would miscount if captureSnapshotRootBounded ever gains a string literal, interpolation, or comment containing a brace. Fine for the current body; a one-line comment noting the assumption would save a future debugging session.

Nothing here is blocking — (1) is the one I'd most want a definitive answer on before merge.

@thymikee

thymikee commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Checks are green now. Earlier review covered the runner diff against #1122 and found no merge-blocking issues; the remaining notes are visible for maintainer judgment, especially the ObjC-exception guard asymmetry around snapshot preflight.

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

thymikee commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Addressed the review notes in 2a6827c:

  • Wrapped the dispatched snapshot preflight in the ObjC exception catcher so activation/scope resolution is symmetric with other commands.
  • Skipped the post-snapshot delay marker when an abandoned tree capture is still occupying XCTest, avoiding another bounded main hop behind the stuck work.
  • Added Swift coverage for main-work timeout abandonment/drain accounting, private-AX-only tier availability while tree capture is abandoned, and the post-snapshot-delay skip.
  • Documented the TS source guard brace-matching assumption.

Validation rerun:

  • pnpm exec vitest run src/tests/apple-runner-package-source.test.ts
  • pnpm format
  • AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 pnpm build:xcuitest:ios
  • focused iOS xcodebuild test-without-building for the 3 new Swift tests
  • pnpm check:quick
  • pnpm build
  • pnpm build:xcuitest
  • git diff --check

@thymikee thymikee merged commit bc7dcc8 into main Jul 7, 2026
21 checks passed
@thymikee thymikee deleted the fix/ios-runner-main-thread-snapshot branch July 7, 2026 14:59
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-07 14:59 UTC

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.

fix(ios-runner): keep XCTest tree capture on the main thread

1 participant