Skip to content

fix(ios): refuse the XCTestDevices redirect while an Xcode shim's first-launch hook is armed - #2947

Closed
thymikee wants to merge 8 commits into
mainfrom
fix/2935-scoped-set-redirect-guard
Closed

thymikee wants to merge 8 commits into
mainfrom
fix/2935-scoped-set-redirect-guard

Conversation

@thymikee

@thymikee thymikee commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

acquireXcodebuildSimulatorSetRedirect now refuses to redirect ~/Library/Developer/XCTestDevices
to a scoped simulator set when an xcrun shim (simctl/devicectl) has an armed first-launch hook —
its EXPECTED_VERSION differs from the installed CoreSimulator/CoreDevice version. An armed shim
runs xcodebuild -runFirstLaunch on every call, deleting every device in XCTestDevices; with the
redirect installed that wipes the user's scoped set. The probe reads shim text and plist paths
(never hard-coded), failing closed on any unreadable value or a 2 s timeout. The refusal is
COMMAND_FAILED with details.reason: 'xctest_device_set_cleanup_armed', a static hint, and
details.xcrunShims; both the build and session acquire paths refuse before any xcodebuild phase.
Default-set sims and a set already equal to XCTestDevices never probe.

Closes #2935

17 files touched (7 production, 8 test, 2 docs); net production +247 lines. Gross diff is 1,045
lines, 45 over budget — almost entirely the new fail-closed test matrix (318 lines) the issue
requires.

Validation

Tested at 9573ece558 (base 34ab80353c).

  • pnpm check:affected --run: pass, first run — "all runnable checks passed" (510 vitest files /
    3899 tests, command-docs 12/12). pnpm check:fallow --base origin/main: no issues in 22 changed
    files.
  • Review follow-up (4 points, addressed on 34ab803): the xcrun shim probe now caps its budget by
    the owning phase's remaining deadline (min(30s cold cap, phase remaining)), not just the 30 s
    cold budget; a fake that missed an already-fired abort signal now waits on a real abort listener
    and asserts the probe started; a new test covers a request canceled while a shim is mid-read
    (previously only pre-aborted was covered); probe_canceled is now unrepresentable at the probe
    boundary ({ canceled: true } | { canceled: false; xcrunShims }) instead of a dead armedBy
    branch.
  • Follow-on found while fixing the above: a phase that runs out during the probe was still
    misreported as an armed shim (probe_out_of_budget); fixed so a phase-owned stop reports
    runner_phase_budget_exhausted and only the 30 s cold cap can produce probe_out_of_budget.
  • Live Leg A (armed host, prior head) unchanged by this delta — still refuses with
    xctest_device_set_cleanup_armed, probe ~50 ms, device survives every -runFirstLaunch cleanup.
  • Leg B (non-armed host, live cold scoped-set open) still not run — remains the maintainer's merge
    condition.
  • Passed a 3-lens adversarial review, a final ship review, and this review round earlier in this
    pipeline.

Review in cubic

…st-launch hook is armed

Xcode's simctl and devicectl shims run `xcodebuild -runFirstLaunch` before
every call when their EXPECTED_VERSION differs from the installed
CoreSimulator or CoreDevice CFBundleVersion. That cleanup deletes every
device in ~/Library/Developer/XCTestDevices, which the runner redirect points
at a scoped --ios-simulator-device-set set, so the user's devices were wiped.

acquireXcodebuildSimulatorSetRedirect now probes every tool declared in
XCRUN_TOOL_NAMES (one shared 2 s budget, fail closed) before installing the
redirect, and refuses with reason xctest_device_set_cleanup_armed, the rule
row's static hint, and details.xcrunShims. Both callers (the build and the
session start) go through it, so an armed host refuses before
build-for-testing and before test-without-building.

Refs #2935
@github-actions

github-actions Bot commented Sep 24, 2026 •

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

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.81 MB 4.82 MB +6.0 kB
Package (unpacked) 4.81 MB 4.82 MB +6.0 kB
Package (download) 1.44 MB 1.44 MB +2.4 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.6 ms 28.0 ms -1.6 ms
CLI --help 82.7 ms 82.9 ms +0.2 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 17 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-apple/src/core/xcrun-shim-first-launch.ts">

<violation number="1" location="packages/platform-apple/src/core/xcrun-shim-first-launch.ts:52">
P3: When the shared 2 s budget expires, every unanswered probe reads as armed with `shimPath: null`, so `describeArmedShim` reports "could not be located" and the refusal's static hint sends the user to change the Xcode selection — even though nothing was actually missing. On any host where the four `xcrun --find` spawns plus shim reads plus plutil reads do not all fit in 2 s (cold CoreSimulator, loaded machine), all redirected iOS builds are hard-refused with a diagnosis that points nowhere. Track whether the armed record came from a real locate failure versus a budget expiry (e.g. set a `timedOut` flag on `evidence` in the abort handler), and let the message/hint distinguish the two.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/commands/schema/cli-help.ts Outdated
Comment thread website/docs/docs/installation.md Outdated
Comment thread website/docs/docs/commands.md Outdated
Comment thread packages/platform-apple/src/runner/runner-device-set.ts Outdated
Comment thread packages/platform-apple/src/runner/__tests__/xcrun-shim-fixtures.ts Outdated
Comment thread packages/platform-apple/src/runner/runner-error-classification.ts Outdated
Comment thread packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts Outdated
export async function probeXcrunShimFirstLaunchHooks(
options: XcrunShimProbeOptions = {},
): Promise<XctestDeviceSetCleanupArming> {
const signal = AbortSignal.timeout(XCRUN_SHIM_PROBE_BUDGET_MS);

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: When the shared 2 s budget expires, every unanswered probe reads as armed with shimPath: null, so describeArmedShim reports "could not be located" and the refusal's static hint sends the user to change the Xcode selection — even though nothing was actually missing. On any host where the four xcrun --find spawns plus shim reads plus plutil reads do not all fit in 2 s (cold CoreSimulator, loaded machine), all redirected iOS builds are hard-refused with a diagnosis that points nowhere. Track whether the armed record came from a real locate failure versus a budget expiry (e.g. set a timedOut flag on evidence in the abort handler), and let the message/hint distinguish the two.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/core/xcrun-shim-first-launch.ts, line 52:

<comment>When the shared 2 s budget expires, every unanswered probe reads as armed with `shimPath: null`, so `describeArmedShim` reports "could not be located" and the refusal's static hint sends the user to change the Xcode selection — even though nothing was actually missing. On any host where the four `xcrun --find` spawns plus shim reads plus plutil reads do not all fit in 2 s (cold CoreSimulator, loaded machine), all redirected iOS builds are hard-refused with a diagnosis that points nowhere. Track whether the armed record came from a real locate failure versus a budget expiry (e.g. set a `timedOut` flag on `evidence` in the abort handler), and let the message/hint distinguish the two.</comment>

<file context>
@@ -0,0 +1,174 @@
+export async function probeXcrunShimFirstLaunchHooks(
+  options: XcrunShimProbeOptions = {},
+): Promise<XctestDeviceSetCleanupArming> {
+  const signal = AbortSignal.timeout(XCRUN_SHIM_PROBE_BUDGET_MS);
+  return await Promise.all(
+    XCRUN_TOOL_NAMES.map(async (tool) => await probeWithinBudget(tool, options, signal)),
</file context>
Fix with cubic

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.

Addressed or already resolved as of 34ab803. The per-point disposition is in #2947 (comment).

…s and type why a shim is armed

- The probe spends COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, the budget sized for the first-exec
  xcrun stall, instead of its own 2 s, and it follows the request's abort signal, so a
  canceled build or session gives the device-set lock back as a cancellation.
- XCRUN_TOOLS carries a firstLaunchShim trait per tool; only simctl and devicectl are probed.
- Each armed entry in details.xcrunShims carries armedBy (version_mismatch, version_unreadable,
  shim_unreadable, shim_not_located, probe_out_of_budget, probe_canceled), and the message
  follows it.
- One shared fake-shim fixture, built on the captured Xcode 26.2 simctl shim, serves the core
  and runner tests and the startup-failure fixture table.
@thymikee

Copy link
Copy Markdown
Member Author

Addressed the review at 957bb81. New head 8f14569.

Worked from the five quality notes (no maintainer review is on record for this PR yet — only cubic-dev-ai[bot] COMMENTED on 957bb81).

1. 2 s budget → shared cold-toolchain budget + abort signal
The probe now uses COLD_TOOLCHAIN_PROBE_TIMEOUT_MS (30 000 ms), the same constant the other Apple toolchain probers already share, combined via AbortSignal.any with the caller's request signal. Both production call sites (session open, artifact build) pass their request signal through. A cancellation during the probe now returns a request-canceled error and gives the lock back, instead of surfacing as a shim refusal. New tests cover pre-abort short-circuit, mid-probe cancellation, and budget-vs-cancel distinction.

2. Probe only tools that can carry the hook
XCRUN_TOOLS now carries a firstLaunchShim trait per tool. Only simctl and devicectl are probed; xcdevice/xctrace are skipped. Confirmed on this host with a read-only file check: xcdevice/xctrace are Mach-O, simctl/devicectl are shell scripts.

3. One shared fake-shim fixture
xcrun-shim-fixtures.ts moved next to core/xcrun-shim-first-launch.ts; runner-level duplicate fixtures and hookless helpers removed. git grep -n 'runFirstLaunch >&2' now has exactly one hit.

4. Tests
10 new/changed tests, one per mutation the note listed; all 15 named mutations in the script kill at least one target test (script re-verified, tree stayed clean).

5. Typed armedBy
ArmedXcrunShimFirstLaunchHook.armedBy is a closed union (version_mismatch | version_unreadable | shim_unreadable | shim_not_located | probe_out_of_budget | probe_canceled); DESCRIBE_ARMED_SHIM is a compiler-checked record replacing the old switch. Help and docs updated.

Gross diff: before 17 files +1039/-6 → after 21 files +1145/-16 (net dedupe ~120 lines against the new coverage).

Gate on 8f14569: pnpm check:affected --run → exit 0 first run, "all runnable checks passed" (vitest 510 files / 3892 tests, command-docs 12/12). pnpm check:production-exports → 68 issues, unchanged from base, none in touched files.

Leg A re-run (armed host, 8f14569): press on the armed host now fails with details.reason: xctest_device_set_cleanup_armed and details.xcrunShims carrying only simctl/devicectl, each with a typed armedBy: version_mismatch. Probe took ~50 ms, far under the 30 s budget. Watcher: 84/84 samples show XCTestDevices as a real dir, device present, zero build-for-testing spawns. Simulator and XCTestDevices untouched after cleanup.

Leg B (non-armed host) is still not runnable here and remains the maintainer's merge condition.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 18 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Fix all with cubic | Re-trigger cubic

Comment thread packages/platform-apple/src/runner/runner-artifact.ts Outdated
Comment thread packages/platform-apple/src/runner/runner-device-set.ts Outdated
… back and cover every armedBy reason in docs

The XCTest device-set redirect's shim-armed refusal handed the lock back
without checking whether that give-back could restore the host's own
device set, unlike the no-redirect path beside it; a failed restore was
silently dropped in favor of the shim refusal. It now throws the restore
failure first, with a test that forces the give-back's own reconcile to
fail after the redirect-in reconcile already succeeded.

The armed-shim hint, and the CLI help/commands.md/installation.md prose,
described the refusal as only a version mismatch. All three now cover
every armedBy case the probe can fail closed on (unreadable or missing
shim, unreadable version data, a probe that ran out of its budget) and
stop promising xcrunShims always carries readable versions.

Also: the startup-failure fixture's xcodeVersion/output field comments
now record the xctest-device-set-redirect entry's version.plist/shim-text
exception instead of only stating the xcodebuild -version shape, and the
positive-case test for the redirect's typed classification now passes a
realistic armed shim instead of an empty xcrunShims array the redirect
never actually publishes.
@thymikee

Copy link
Copy Markdown
Member Author

At 8f14569, the refusal logic looks sound, but the PR is not ready yet because the one test that proves it works on a real host hasn't been run.

CI is green (21 checks), but that only exercises the Apple runner acquire path through fakes; no simulator lane opens a scoped set on an armed or disarmed host, so it can't confirm the refusal behaves correctly on live devices.

What's missing is Leg B: a live run on a non-armed host, where xcrun --find simctl and --find devicectl return shims whose EXPECTED_VERSION matches the installed CoreSimulator and CoreDevice CFBundleVersion. Run a cold open <app> --ios-simulator-device-set <throwaway set> --udid <udid> at 8f14569 and show: no xctest_device_set_cleanup_armed error, a request log with simulator_set_redirect followed by build-for-testing then a successful test-without-building, and the device still present in the scoped set afterward. Please also report the probe duration from the startup timings.

Not blocking: handBackDeviceSet's restoreFailure/releaseFailure are dropped on the refusal branch in packages/platform-apple/src/runner/runner-device-set.ts#L137 while the other exits surface restoreFailure — worth mirroring that; the static hint/help text at packages/platform-apple/src/runner/runner-error-classification.ts#L447 blames an Xcode/framework mismatch even for version_unreadable, shim_unreadable, shim_not_located, and probe_out_of_budget, where switching Xcode may not help; and the test-only xcrunShimPaths seam on the production option types (packages/platform-apple/src/runner/runner-device-set.ts#L42) means arming tests bypass the real xcrun --find route — these can be taken or left.

Is there a smaller shape here? The probe touches a new host-port entry, the device-set gate, and a classification row, and the issue calls for a per-tool list, so a single-module probe seems like the real size. The one place that looks reducible is the test-only xcrunShimPaths option, which could be replaced by faking xcrun --find through the existing tool-provider fake. Production code is 311 lines, under the usual 700 threshold, but the gross diff is 1145, mostly tests — does that ratio look right, or is there test duplication worth trimming?

A few things I couldn't check: whether each test actually kills a mutation, since the mutation script wasn't run; which Xcode versions are supported, which affects how much devicectl's absence on Xcode 14 or earlier matters; whether the shim text format holds for Xcode 26.x/27 beyond the captured 26.2 fixture (only Leg B or a second capture would confirm this doesn't fail closed on a different phrasing); and whether options.budget.signal in runner-artifact carries the phase deadline for the 30s probe.

Once Leg B runs clean and the device survives, this looks ready for human review.

…plexity gate

acquireXcodebuildSimulatorSetRedirect repeated the same
hand-back-then-check-restoreFailure shape at three exits; the third
occurrence, added to fail-close the shim-refusal path, tipped the
function over fallow's cyclomatic/cognitive complexity threshold.
Extract handBackOrThrowRestoreFailure so all three exits share one
implementation instead of one more inlined branch.
…a production option

xcrunShimPaths on XcrunShimProbeOptions let tests answer `xcrun --find`
without going through the tool-provider, the seam testing.md already
requires (no production exports or test-only dependency injection).
Production call sites never passed it. withFakeXcrunHost already fakes
`xcrun --find` through the real tool-provider seam, so every caller
that passed xcrunShimPaths was already running inside that fake and
can rely on it instead.
@thymikee

Copy link
Copy Markdown
Member Author

Addressed the review at 8f14569. New head 34ab80353c, a fast-forward.

Leg B is still open. This Mac is armed: simctl 1051.17.7 against CoreSimulator 1155.4, and devicectl 506.6 against CoreDevice 629.3. It can't produce a disarmed run, so Leg B needs a host whose shims match the installed frameworks, or the macOS CI image. On this host, the armed Leg A has run twice (957bb81, 8f14569). A runner-backed command is refused with the typed reason in about 50 ms, before any build-for-testing. XCTestDevices never becomes a symlink, and the scoped device survives the -runFirstLaunch cleanups.

Non-blocking notes, all three taken:

  • Restore failure on the refusal branch (f41d372db1, dee055527c). The refusal branch now surfaces restoreFailure, like the other exits. All three exits go through one handBackOrThrowRestoreFailure helper, which also clears the fallow complexity finding. A test forces the give-back reconcile to fail.
  • Hint and help (f41d372db1). The static hint, help, commands.md and installation.md now name every fail-closed armedBy cause, not just a version mismatch: not located, unreadable shim or version, out of budget, cancelled. They no longer promise readable versions in xcrunShims.
  • Test-only xcrunShimPaths option (34ab80353c). Removed from the production option types. Arming tests fake xcrun --find through the existing tool-provider fake, so they go through the real lookup route.

Your questions:

  • Mutations. The mutation script ran on 8f14569: 15 mutations, each killed by at least one test. Two are killed by the repo's "tests must not wait real time" guard, because the mutant waits on the real 30 s budget. The script and results are in the PR's evidence.
  • options.budget.signal in runner-artifact. The probe merges the request signal with the 30 s cold-toolchain budget (AbortSignal.any), and both callers pass it. A cancelled request gives the device set and the lock back, and it surfaces as cancelled, not as an armed refusal.
  • Test/production ratio. Gross is +1218 / −26 with about 311 production lines. The fixture copies are merged into one shared owner (core/__tests__/xcrun-shim-fixtures.ts). What's left is mostly one test per fail-closed armedBy cause, plus the cancellation and restore cases, and each test catches a mutation.
  • Shim format beyond 26.2 and Xcode ≤ 14. Not verified beyond the captured 26.2 shim. A different phrasing or a missing devicectl fails closed as version_unreadable or shim_not_located, with the matching hint. It never fails open. A second capture, or Leg B on another Xcode, would confirm this.

Gate on 34ab80353c: build and typecheck are clean. Tests pass: apple-runner 61 files / 633 tests, plus the probe and tool-provider tests. check:fallow --base origin/main reports no issues in the 21 changed files, and check:affected --run is green on dee055527c (the last commit only changes the test seam). The branch merges cleanly into main.

@thymikee

Copy link
Copy Markdown
Member Author

This follow-up to the review on 8f14569 fixes the refusal path itself; what's still missing is proof it works on a real toolchain, not fakes.

The route that now runs on every scoped-set simulator build (https://github.com/callstack/agent-device/blob/34ab803/packages/platform-apple/src/runner/runner-device-set.ts#L122) has no live proof behind it. A cold open with --ios-simulator-device-set calls probeXcrunShimFirstLaunchHooks, then installDeviceSetRedirect, then build-for-testing and test-without-building. The probe fails closed, so a false "armed" reading — for example shim_not_located or probe_out_of_budget on a real toolchain — would refuse every scoped-set build, and fakes can't rule that out. You confirmed this leg hasn't run on 34ab803 or any earlier head. A false positive here would stop every user of a scoped simulator set from building a runner, and CI wouldn't catch it. Can you run a cold open <app> --ios-simulator-device-set <throwaway set> --udid <udid> on a host where xcrun --find simctl and xcrun --find devicectl shims report an EXPECTED_VERSION matching the installed CoreSimulator and CoreDevice CFBundleVersion, and show: no xctest_device_set_cleanup_armed error; a request log with simulator_set_redirect, then build-for-testing, then a successful test-without-building; the device still listed by xcrun simctl --set <set> list devices afterward; and the probe duration from the startup timings?

Smoke Tests was still queued when this was reviewed, so there's no failure to attribute either way. That job builds the simulator runner through acquireXcodebuildSimulatorSetRedirect; the default set has needsRedirect false, so the new probe doesn't run there, though the changed hand-back helper does run on that !needsRedirect exit, so the job does touch part of this diff.

Not blocking: the new error hint (https://github.com/callstack/agent-device/blob/34ab803/packages/platform-apple/src/runner/runner-error-classification.ts#L447) covers a version mismatch and a timed-out probe but gives no remediation for shim_not_located, where retrying under less load won't help, and cli-help.ts:704 no longer lists the armedBy literals that commands.md still lists — worth a pass, but neither has to hold up this PR.

I didn't run the new restore-failure test; I judged it would have failed on the old code by reading 8f14569's refusal branch against the test's EACCES predicate, so that's a read, not a run. You mentioned four comments on 34ab803 are still being worked — the phase-deadline cap in runner-artifact.ts, a fake abort listener and a pre-aborted-only cancellation test in runner-artifact.test.ts, and a probe_canceled description that can't render — those sit outside this delta and I haven't reviewed them here.

Before this can merge, we need that live cold scoped-set open on a host that isn't armed, showing simulator_set_redirect, then build-for-testing, then a successful test-without-building, with the device preserved afterward and the probe duration shown.

… the phase budget, not an armed shim

Only the cold-toolchain budget now reads a stopped shim as probe_out_of_budget. A stop by the
owning phase's clock, or a phase already spent when the probe starts, fails the redirect with
runner_phase_budget_exhausted at the one gate both callers share. The session-side redirect
spends the startup time read before the build, like the launch after it.
@thymikee

Copy link
Copy Markdown
Member Author

Addressed the review at 34ab803. New head 9573ece.

1. Probe ignores the phase deadline (runner-artifact.ts:475)
The probe now takes an optional deadline alongside signal; its budget is min(30s cold cap, phase remaining). runner-artifact.ts and runner-session.ts now pass the full RunnerPhaseBudget (deadline + signal) through to the probe, not just the signal. Commit a8e807d27c.

2. Fake runCommand misses an already-fired abort (runner-artifact.test.ts:86)
The fake now returns immediately when the signal is missing or already aborted, and otherwise waits on a real abort listener, with an assertion that the probe actually started. Commit a8e807d27c.

3. No real mid-probe cancel coverage (runner-device-set-cleanup-arming.test.ts:251)
Split the pre-aborted case from a new case where the probe is canceled while a shim read is in flight (new onPlistRead fixture hook), asserting a cancellation error, lock given back, and no XCTestDevices mutation. Commit a8e807d27c.

4. probe_canceled can never render (runner-device-set.ts:199)
Made it unrepresentable: the probe now returns { canceled: true } or { canceled: false, xcrunShims }, the refusal branches on that instead of re-reading the signal, and probe_canceled is gone from the armedBy union. Commit a8e807d27c.

Follow-on found while fixing #1: once the probe was phase-capped, a phase that ran out during the probe was still misreported as an armed shim (probe_out_of_budget) instead of the phase budget running out. Fixed so only the 30s cold cap can produce probe_out_of_budget; a phase-owned stop is runner_phase_budget_exhausted. Docs/help updated. Commit 9573ece558.

Gate on 9573ece: pnpm check:affected --run → exit 0 on the first run: format, lint, typecheck, layering, fallow (✓ No issues in 22 changed files), build, vitest-related (510 files / 3899 tests), command-docs (12/12). Final line: check:affected: all runnable checks passed.

Live evidence:

  • pnpm build → ✔ Build complete in 5572ms
  • pnpm exec vitest run --project apple-runner → Test Files 61 passed (61), Tests 635 passed (635)
  • pnpm exec vitest run packages/platform-apple/src/core/__tests__/xcrun-shim-first-launch.test.ts → Test Files 1 passed (1), Tests 12 passed (12)
  • git grep -n probe_canceled → no output (0 hits)
  • Mutation check (5 mutants targeting these 4 points) → all 5 killed by the new/changed tests.

Not invalidated: the armed-host Leg A run from the prior head is unchanged by this delta (same read outcome on a full-budget host).

Leg B (cold scoped-set open on a non-armed host) is still open and remains the maintainer's merge condition.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

4 issues found across 13 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-apple/src/core/__tests__/xcrun-shim-fixtures.ts">

<violation number="1" location="packages/platform-apple/src/core/__tests__/xcrun-shim-fixtures.ts:122">
P3: After `abortOf` resolves, the fake `runCommand` returns a successful `--find` result, so the losing branch of `probeWithinBudget`'s `Promise.race` keeps running and reads shim text and plists after the budget abort, mutating `host.plistReads` past the stall. The real executor rejects a timed-out/aborted `xcrun --find`, and `locateShim` swallows that into a `null` shim path. Return a failed result once the stall aborts so the ghost continuation stops at the stalled `--find`, matching the real executor and keeping `plistReads` empty for the stalled-probe test.</violation>

<violation number="2" location="packages/platform-apple/src/core/__tests__/xcrun-shim-fixtures.ts:122">
P3: `findStalls` does not guarantee a stall: with no `options.signal`, `abortOf` returns immediately and the fixture answers `xcrun --find` as if nothing was stalled; with a signal that never aborts, it waits forever and the suite hangs until the framework timeout. Bound the wait by the `timeoutMs` the probe already passes, using the abort signal only as an early exit.</violation>
</file>

<file name="packages/platform-apple/src/runner/runner-artifact.ts">

<violation number="1" location="packages/platform-apple/src/runner/runner-artifact.ts:475">
P2: This activates shim probing for every scoped-set runner build, but the artifact tests only exercise fake `xcrun` and plist providers. Add a macOS/Xcode-gated integration test covering a real non-armed shim and probe cancellation before relying on this safety path.</violation>
</file>

<file name="packages/platform-apple/src/runner/__tests__/runner-session-lifecycle.test.ts">

<violation number="1" location="packages/platform-apple/src/runner/__tests__/runner-session-lifecycle.test.ts:443">
P3: `vi.mocked(Date.now).mockRestore()` in this `finally` throws `TypeError: Date.now.mockRestore is not a function` whenever `mockEnsureXctestrunArtifact` was never invoked, because `vi.spyOn(Date, 'now')` lives inside that mock's implementation and no spy then exists. Today the build always runs before the acquire/refusal so it happens to be safe, but the PR's own goal is to refuse before any xcodebuild phase; the first reorder that achieves it turns a clean assertion failure into an unreadable crash. Guard the restore (or hoist the spy creation).</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

const performanceBuildSettings = resolveRunnerPerformanceBuildSettings();
const sandboxBuildArgs = resolveRunnerSandboxBuildArgs();
await withXcodebuildSimulatorSetRedirect(device, async () => {
await withXcodebuildSimulatorSetRedirect(device, options.budget ?? {}, async () => {

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This activates shim probing for every scoped-set runner build, but the artifact tests only exercise fake xcrun and plist providers. Add a macOS/Xcode-gated integration test covering a real non-armed shim and probe cancellation before relying on this safety path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/runner/runner-artifact.ts, line 475:

<comment>This activates shim probing for every scoped-set runner build, but the artifact tests only exercise fake `xcrun` and plist providers. Add a macOS/Xcode-gated integration test covering a real non-armed shim and probe cancellation before relying on this safety path.</comment>

<file context>
@@ -472,8 +472,7 @@ async function buildRunnerXctestrun(
   const sandboxBuildArgs = resolveRunnerSandboxBuildArgs();
-  const redirectOptions = { signal: options.budget?.signal };
-  await withXcodebuildSimulatorSetRedirect(device, redirectOptions, async () => {
+  await withXcodebuildSimulatorSetRedirect(device, options.budget ?? {}, async () => {
     try {
       await runCmdStreaming(
</file context>
Fix with cubic

runCommand: async (cmd, args, options): Promise<ExecResult> => {
const tool = cmd === 'xcrun' && args[0] === '--find' ? args[1] : undefined;
if (tool !== undefined) host.finds.push(tool);
if (tool !== undefined && host.findStalls) await abortOf(options?.signal);

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: After abortOf resolves, the fake runCommand returns a successful --find result, so the losing branch of probeWithinBudget's Promise.race keeps running and reads shim text and plists after the budget abort, mutating host.plistReads past the stall. The real executor rejects a timed-out/aborted xcrun --find, and locateShim swallows that into a null shim path. Return a failed result once the stall aborts so the ghost continuation stops at the stalled --find, matching the real executor and keeping plistReads empty for the stalled-probe test.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/core/__tests__/xcrun-shim-fixtures.ts, line 122:

<comment>After `abortOf` resolves, the fake `runCommand` returns a successful `--find` result, so the losing branch of `probeWithinBudget`'s `Promise.race` keeps running and reads shim text and plists after the budget abort, mutating `host.plistReads` past the stall. The real executor rejects a timed-out/aborted `xcrun --find`, and `locateShim` swallows that into a `null` shim path. Return a failed result once the stall aborts so the ghost continuation stops at the stalled `--find`, matching the real executor and keeping `plistReads` empty for the stalled-probe test.</comment>

<file context>
@@ -112,9 +116,10 @@ export async function withFakeXcrunHost<T>(
+    runCommand: async (cmd, args, options): Promise<ExecResult> => {
       const tool = cmd === 'xcrun' && args[0] === '--find' ? args[1] : undefined;
       if (tool !== undefined) host.finds.push(tool);
+      if (tool !== undefined && host.findStalls) await abortOf(options?.signal);
       const found = tool === undefined ? undefined : host.xcrunShimPaths[tool as XcrunShimToolName];
       return found
</file context>
Suggested change
if (tool !== undefined && host.findStalls) await abortOf(options?.signal);
if (tool !== undefined && host.findStalls) {
await abortOf(options?.signal);
return { exitCode: 1, stdout: '', stderr: 'xcrun: error: aborted' };
}
Fix with cubic

error instanceof AppError && error.details?.reason === 'xctest_device_set_cleanup_armed',
);
} finally {
vi.mocked(Date.now).mockRestore();

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: vi.mocked(Date.now).mockRestore() in this finally throws TypeError: Date.now.mockRestore is not a function whenever mockEnsureXctestrunArtifact was never invoked, because vi.spyOn(Date, 'now') lives inside that mock's implementation and no spy then exists. Today the build always runs before the acquire/refusal so it happens to be safe, but the PR's own goal is to refuse before any xcodebuild phase; the first reorder that achieves it turns a clean assertion failure into an unreadable crash. Guard the restore (or hoist the spy creation).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/runner/__tests__/runner-session-lifecycle.test.ts, line 443:

<comment>`vi.mocked(Date.now).mockRestore()` in this `finally` throws `TypeError: Date.now.mockRestore is not a function` whenever `mockEnsureXctestrunArtifact` was never invoked, because `vi.spyOn(Date, 'now')` lives inside that mock's implementation and no spy then exists. Today the build always runs before the acquire/refusal so it happens to be safe, but the PR's own goal is to refuse before any xcodebuild phase; the first reorder that achieves it turns a clean assertion failure into an unreadable crash. Guard the restore (or hoist the spy creation).</comment>

<file context>
@@ -403,33 +403,52 @@ test('an armed xcrun shim refuses a scoped-set session before the runner launche
+        error instanceof AppError && error.details?.reason === 'xctest_device_set_cleanup_armed',
+    );
+  } finally {
+    vi.mocked(Date.now).mockRestore();
+  }
 
</file context>
Suggested change
vi.mocked(Date.now).mockRestore();
if (vi.isMockFunction(Date.now)) vi.mocked(Date.now).mockRestore();
Fix with cubic

runCommand: async (cmd, args, options): Promise<ExecResult> => {
const tool = cmd === 'xcrun' && args[0] === '--find' ? args[1] : undefined;
if (tool !== undefined) host.finds.push(tool);
if (tool !== undefined && host.findStalls) await abortOf(options?.signal);

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: findStalls does not guarantee a stall: with no options.signal, abortOf returns immediately and the fixture answers xcrun --find as if nothing was stalled; with a signal that never aborts, it waits forever and the suite hangs until the framework timeout. Bound the wait by the timeoutMs the probe already passes, using the abort signal only as an early exit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/core/__tests__/xcrun-shim-fixtures.ts, line 122:

<comment>`findStalls` does not guarantee a stall: with no `options.signal`, `abortOf` returns immediately and the fixture answers `xcrun --find` as if nothing was stalled; with a signal that never aborts, it waits forever and the suite hangs until the framework timeout. Bound the wait by the `timeoutMs` the probe already passes, using the abort signal only as an early exit.</comment>

<file context>
@@ -112,9 +116,10 @@ export async function withFakeXcrunHost<T>(
+    runCommand: async (cmd, args, options): Promise<ExecResult> => {
       const tool = cmd === 'xcrun' && args[0] === '--find' ? args[1] : undefined;
       if (tool !== undefined) host.finds.push(tool);
+      if (tool !== undefined && host.findStalls) await abortOf(options?.signal);
       const found = tool === undefined ? undefined : host.xcrunShimPaths[tool as XcrunShimToolName];
       return found
</file context>
Fix with cubic

@thymikee

Copy link
Copy Markdown
Member Author

This follows up on the review at 34ab803. The refusal logic looks right, but the live-validation gap it flagged is still open at 9573ece.

Every cold scoped-set simulator build now runs probeXcrunShimFirstLaunchHooks before installDeviceSetRedirect, and the probe fails closed (https://github.com/callstack/agent-device/blob/9573ece/packages/platform-apple/src/runner/runner-device-set.ts#L126). If the probe misreads a healthy toolchain as armed, it refuses every scoped-set runner build, through shim_not_located, version_unreadable on a shim phrasing other than the captured 26.2 text, or probe_out_of_budget. No CI lane opens a scoped set, and the author confirms Leg B has not run on 9573ece or any earlier head, so a false positive here would take out every --ios-simulator-device-set user with nothing in CI to catch it. What this needs to satisfy: a cold open <app> --ios-simulator-device-set <throwaway set> --udid <udid> on a host whose xcrun --find simctl and xcrun --find devicectl shims report an EXPECTED_VERSION matching the installed CoreSimulator and CoreDevice CFBundleVersion must reach simulator_set_redirect, then build-for-testing, then a successful test-without-building, with no xctest_device_set_cleanup_armed or runner_phase_budget_exhausted error, the device still listed by xcrun simctl --set <set> list devices afterward, and the simulator_set_redirect duration visible in the startup timings.

Not blocking: runnerPhaseBudgetExhaustedError's message no longer fits both call sites now that the phase clock can also stop the probe mid-run, so it could use a message true for both, or let the probe path pass its own — take it or leave it.

CI is green across all 21 checks, but no lane opens a --ios-simulator-device-set scoped set, so the probe and refusal route this PR changes has no CI coverage. The default-set smoke job exits through the !needsRedirect branch before the probe ever runs.

I did not run the tests, the mutation script, or check:affected, so the author's gate output and the 5-of-5 mutant kills are taken as reported. I did not trace the new mid-probe cancel case in runner-device-set-cleanup-arming.test.ts (the onPlistRead hook) line by line, so its correctness rests on the author's description and the probe code I read. The shim text format beyond the captured Xcode 26.2 fixture is still unverified; only a live run on another toolchain would show it fails closed there too.

No conflicts. What's left before this can merge is that live Leg B run at 9573ece, on a host that isn't armed: a cold scoped-set open that reaches simulator_set_redirect, then build-for-testing, then a successful test-without-building, with the device kept in the set afterward.

@thymikee

Copy link
Copy Markdown
Member Author

Superseded by #2963, which passes -DVTSimulatorSetLocation=<set> to xcodebuild and deletes the XCTestDevices redirect, so there's nothing left to probe or refuse. The evidence is in the #2935 redesign comment. I'll close this PR once #2963's CI is green.

@thymikee

Copy link
Copy Markdown
Member Author

Closing in favor of #2963 (CI green), which removes the XCTestDevices redirect instead of guarding it.

@thymikee thymikee closed this Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refuse the XCTestDevices redirect when an Xcode xcrun shim (simctl or devicectl) has an armed first-launch hook: scoped simulator set deletion

1 participant