Skip to content

fix(ios): restore a legacy XCTestDevices redirect before the daemon serves requests - #2980

Open
thymikee wants to merge 1 commit into
mainfrom
fix/2963-restore-before-serving
Open

thymikee wants to merge 1 commit into
mainfrom
fix/2963-restore-before-serving

Conversation

@thymikee

@thymikee thymikee commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Follow-up to #2963 (its last review round landed after merge). Refs #2935.

configureForDaemonLock now calls restoreLegacyXctestDeviceSetRedirectRuntime after the daemon
lock and before the servers open or daemon.json is written, so a stale XCTestDevices redirect
is repaired before the daemon starts serving requests, not after. PlatformOwnerLifecycle.restoreLegacyXctestDeviceSetRedirect
and restoreLegacyXctestDeviceSetForDaemonStartup are deleted; restore diagnostics go through the
single startupDiagnostics buffer and flushDaemonStartupDiagnostics writes them to daemon.log.

Also closes the Option C gap from the r2 findings: an XCTestDevices symlink is now unconditionally
removed at daemon startup regardless of what it points at (no ownership check, no refusal path), and
the toolchain-version probe on the diagnostic report path is replaced by a memoized read
(memoizedRunnerXcodeVersion) so producing the "version unreadable" message costs no xcrun probes.

12 files touched, net production lines (excluding tests/docs): +78 / −81.

Validation

Tested SHA: 785f61bd76 (branch = origin/main 78c0ca4b84 + this commit).

  • pnpm check:affected --run -> EXIT=0: format, lint, typecheck, layering, fallow (--base origin/main),
    build, vitest (432 files / 3057 tests), command-docs (1 file / 12 tests) all passed. 4 tests ran
    over the 2.5s budget but inside the 2x load band; no failures, no reruns needed.
  • pnpm check:production-exports -> EXIT=0: 68 pre-existing unused exports (unchanged from the prior
    round); the only touched-package entry (runner-operations-facade.ts) is pre-existing, not a
    symbol this branch changed.
  • Live: cold open com.apple.Preferences on a throwaway iPhone 16 / iOS 26.2 simulator (own daemon
    instance, daemon.json codeOrigin=checkout, version 0.21.15) succeeded in 67s; snapshot -i
    returned 17 nodes over the XCTest path; close succeeded. daemon.log carried no
    legacy_xctest/restore diagnostics, as expected since ~/Library/Developer/XCTestDevices was a
    real directory (not a symlink) before and after — the restore is a no-op on this host. The scoped
    symlink-removal and toolchain-probe-avoidance paths are covered by
    daemon-runtime-xctest-device-set.test.ts, runner-device-set.test.ts, and
    runner-early-exit-diagnosis.test.ts, including a mutation check that removing the daemon-startup
    restore call site fails the pinning test.

Remaining risk: the symlink-removal live path (the actual data-loss scenario from #2935) was not
exercised against a live symlinked XCTestDevices in this round — verified by test only. A
pre-existing unrelated fixture path (runner-startup-failure-fixtures.ts:194, from #2680) still
embeds /Users/thymikee/.t3/... and is out of scope here.

Review in cubic

…the daemon serves requests

The restore now runs inside the configureForDaemonLock startup phase, before
the servers open and before daemon.json is published. Its diagnostics are
buffered with the other startup diagnostics and written to daemon.log after
publication. The migration-specific lifecycle method and its daemon wrapper
are deleted. A restore failure is reported as a diagnostic, not thrown.

A simulator_set_destination_not_found error now names the Xcode version that
this process's runner cache decision already read, and never probes the
toolchain; without that value it says "(version unreadable)".

Docs say that a symlinked XCTestDevices is not supported and is removed at
daemon startup, and that the Xcode version can be unreadable. Tests pin exact
failure messages per fixture and cover the real daemon startup path. Captured
fixtures use placeholder home, checkout and temp paths.
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-2980/

Built to branch gh-pages at 2026-09-25 15:35 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.85 MB 4.85 MB +47 B
Package (unpacked) 4.85 MB 4.85 MB +47 B
Package (download) 1.45 MB 1.45 MB +15 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.9 ms 27.8 ms -0.0 ms
CLI --help 80.0 ms 80.7 ms +0.6 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 12 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/runner/runner-device-set.ts">

<violation number="1" location="packages/platform-apple/src/runner/runner-device-set.ts:124">
P3: With `memoizedRunnerXcodeVersion` never probing, the `(version unreadable)` fallback now fires whenever the fingerprint hasn't been memoized in this process (adopted session, expired memo TTL), not only when the toolchain is genuinely unreadable. That message misleads a user into investigating a toolchain problem when the version was simply never read. Since the diagnostic knows the reason is 'not memoized', distinguish it (e.g. omit the Xcode clause or say 'version not read yet') instead of reporting the version as unreadable.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

const simulatorSetPath = runnerSimulatorSetPath(device);
if (simulatorSetPath === undefined) return {};
const xcodeVersion = readRunnerXcodeVersion(device);
const xcodeVersion = memoizedRunnerXcodeVersion(device);

@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: With memoizedRunnerXcodeVersion never probing, the (version unreadable) fallback now fires whenever the fingerprint hasn't been memoized in this process (adopted session, expired memo TTL), not only when the toolchain is genuinely unreadable. That message misleads a user into investigating a toolchain problem when the version was simply never read. Since the diagnostic knows the reason is 'not memoized', distinguish it (e.g. omit the Xcode clause or say 'version not read yet') instead of reporting the version as unreadable.

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-device-set.ts, line 124:

<comment>With `memoizedRunnerXcodeVersion` never probing, the `(version unreadable)` fallback now fires whenever the fingerprint hasn't been memoized in this process (adopted session, expired memo TTL), not only when the toolchain is genuinely unreadable. That message misleads a user into investigating a toolchain problem when the version was simply never read. Since the diagnostic knows the reason is 'not memoized', distinguish it (e.g. omit the Xcode clause or say 'version not read yet') instead of reporting the version as unreadable.</comment>

<file context>
@@ -94,14 +114,14 @@ type RunnerSimulatorSetFailureDetails = { simulatorSetPath?: string; xcodeVersio
   const simulatorSetPath = runnerSimulatorSetPath(device);
   if (simulatorSetPath === undefined) return {};
-  const xcodeVersion = readRunnerXcodeVersion(device);
+  const xcodeVersion = memoizedRunnerXcodeVersion(device);
   return xcodeVersion === undefined ? { simulatorSetPath } : { simulatorSetPath, xcodeVersion };
 }
</file context>
Fix with cubic

@thymikee

Copy link
Copy Markdown
Member Author

At 785f61b the fix moves the legacy XCTestDevices redirect restore into configureForDaemonLock, so it runs before the daemon starts serving instead of after publication, and that's a real result: the daemon no longer serves requests while an old redirect could still be in place. The live run only proved the no-op branch (XCTestDevices was already a real directory); the symlink-restore branch at its new position is covered by the startDaemonRuntime integration test with real fs symlinks, not by a device run in this PR. I don't see confirmation of the daemon boot cost on darwin now that the dynamic import of platform-apple's runner operations sits ahead of readiness — is that worth a number before merge, or is it small enough to not matter?

Not blocking: the three near-duplicate diagnostic shapes (DaemonStartupDiagnostic in https://github.com/callstack/agent-device/blob/785f61b/src/daemon/platform-owner-lifecycle.ts#L18, DurableCaptureRecoveryDiagnostic/AppLogRecoveryDiagnostic, and LegacyXctestDeviceSetRestoreDiagnostic in https://github.com/callstack/agent-device/blob/785f61b/packages/platform-apple/src/runner/runner-device-set.ts#L33) could collapse to one owner, the daemon-level test that a failed restore doesn't stop the daemon (deleted at https://github.com/callstack/agent-device/blob/785f61b/src/daemon/server/daemon-runtime-xctest-device-set.test.ts#L1) is worth restoring now that the restore sits on the fatal startup path, the "(version unreadable)" message at https://github.com/callstack/agent-device/blob/785f61b/packages/platform-apple/src/runner/runner-device-set.ts#L134 also fires when the version was simply never read, and the toolchain-probe removal in runner-cache-metadata.ts is already noted in the PR body as a carry-over — all take-it-or-leave-it.

Is reusing one startup-diagnostic record type for both the daemon buffer and the Apple restore the smaller design here, rather than the two new types this PR adds? Folding the restore into configureForDaemonLock looks like the right owner for it either way.

The PR doesn't say how a concurrent daemon on an older version, in a different state dir, would leave its own in-flight redirect if this daemon's restore runs first — that behavior is unconditional on main today and this PR only moves it earlier, so it's an existing gap, not a new one, but it's worth a line in the body since the timing changed.

No conflicts. CI is green across 21 checks.

This is evidence-pending: the fix itself reads correct, but I'd want the darwin startup-cost question answered, or a note that it's negligible, before calling it ready. Restoring the daemon-level "failed restore doesn't stop the daemon" test would close the remaining gap; everything else here can be taken or left at merge.

This branch has not been deployed

No deployments
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.

1 participant