fix(apple): key the toolchain fingerprint memo on the resolved xcode-select dir

TH
1h ago
+973-84

Summary

toolchainFingerprintCacheKey keyed the toolchain fingerprint memo on DEVELOPER_DIR alone, so every default xcode-select selection shared one entry. A daemon that answered a cache decision before someone ran sudo xcode-select -s kept answering with the Xcode the host had left. Fixes #3300

  • The key is now the resolved developer dir plus an identity for the bundle standing in it. The dir comes from /var/db/xcode_select_link (or the client's DEVELOPER_DIR) resolved through realpath, so a version manager swapping the bundle behind a stable path, or a client naming a symlinked Xcode, lands on the right entry. xcode-select -p (trimmed) is the fallback when the link cannot be read; a selection neither can name leaves the read with no key, so it skips the memo rather than sharing one.
  • The bundle half is the ino and integer mtimeMs of <bundle>/Contents/version.plist, the file xcodebuild -version reads. An update that replaces the bundle in place keeps the path, and a hit renews its entry, so without this the replaced build would be served for as long as the daemon stayed busy.
  • The TTL renews on a hit, as it did before this PR, which is what #3300 asked to keep. It is safe now because the key covers both ways the selection can change: a moved path and a replaced bundle. An entry nothing reads still expires.
  • The failure report reads only the memo. It reuses the key halves the last real read built, so it never resolves, states, or spawns anything — it has no budget and no deadline. Client-named answers are retained per name in a TTL memo, because a daemon serves request-scoped DEVELOPER_DIRs concurrently and one global record would let the last read erase what an earlier client needs to report on. Cancellation is observed after the key's two filesystem reads, so a warm hit cannot hide an abort.

5 files (2 production, 3 test). Related: #3449, whose Xcode-too-old reports should name the toolchain a decision actually read; not implemented here.

Cost of the new key resolution

Measured at b3f7e6562 here over 20 reads each, warm memo:

median
realpath of /var/db/xcode_select_link0.021 ms
statSync of the bundle version file0.003 ms
key resolution, both reads0.024 ms
xcode-select -p spawn (the fallback only)3.88 ms
computeRunnerSourceFingerprint (same call, already unbounded sync fs)1.35 ms
whole resolveExpectedRunnerCacheMetadata, warm1.27 ms

A warm read spawns nothing, which is also what makes renewal affordable: the key costs 1.9% of the call it belongs to, and the xcode-select -p it replaces on that path costs two orders of magnitude more than the two reads that replaced it.

Validation

pnpm check:affected --run --head d33b8049e → passed, exactCommit: true, checkoutChanged: false, receipt .tmp/validation/run-O0GjyD/receipt.json. Coverage, swift-runner and replay lanes are GitHub-authoritative and not claimed here.

Rebased onto 4c1c78696; git range-diff 40b16020b..b3f7e6562 4c1c78696..d33b8049e shows all seven commits unchanged, so the evidence below keeps its original SHA attribution. Upstream touched none of the five files this PR changes.

pnpm exec vitest run --project apple-runner <probe+metadata suites>:

base 4c1c78696head d33b8049e
21 failed / 54 passed75 passed

Mutations, each planted in the source at b3f7e6562 (content-identical here) and measured against that suite in this checkout: keying on the dir as named instead of its realpath (20 failures), dropping the selection-link read (5), probing xcode-select before reading the link (5), dropping the bundle identity from the key (2), removing TTL renewal (1), removing the cancellation check after the key reads (1), sending the failure report back through realpath (1), collapsing client answers into one slot (1), and keying a client's DEVELOPER_DIR unresolved (1).

All Smoke Tests lanes pass at d33b8049e — iOS run, plus Linux, Android and macOS, and every other check at the head is green. This is the lane that reaches the changed route on real toolchain state: the job's own prepare ios-runner returned "cache": "exact" with "buildMs": 0 (the toolchain fingerprint resolved and matched a cached build), then "connectMs": 3387 and "healthCheckMs": 20064 against a live runner reporting "currentUptimeMs": 1653725 — so a runner process was up in the simulator, and 01-settings.ad replayed open/appstate/click with a runner session prewarmed ("runnerPrewarmKind":"session", 29.3 s). The key came from the host's default selection: DEVELOPER_DIR appears in none of the step env dumps in that log, so the read goes through /var/db/xcode_select_link (falling back to xcode-select -p) exactly as in the fix, and the builds resolve to /Applications/Xcode_26.6.app. The earlier smoke failures were upstream — e24dc4568 called linux.gap after 4db1b7d67 removed the helper, which crashed the coverage declarations at import on every PR, and #3740 fixed it.

Live run

This host has one Xcode (/Applications/Xcode-27.1.0.app) and no passwordless sudo, so sudo xcode-select -s is not available here — the two-Xcode switch is still open with @thymikee. Everything below runs the production route (resolveExpectedRunnerCacheMetadata → requireRunnerToolchainFingerprint) against the real root-owned /var/db/xcode_select_link and real xcodebuild/xcrun output. A second toolchain is an APFS clone of the Xcode with ProductBuildVersion edited, so the substitution is only in how the selection is moved, never in what the probes read. All three "after" transcripts were re-captured at b3f7e6562; each "before" is the same script at the head that lacked the fix.

Warm, and unchanged on a repeated read (real host selection, no substitution at all):

xcode-select -p / readlink / realpath   /Applications/Xcode-27.1.0.app/Contents/Developer  (all three agree)
cold read   xcodeBuildVersion 27A9275  sdkBuildVersion 24A94232  …/derived/ios-simulator/cache-2d8626f9b665c837  132.8 ms
repeat read xcodeBuildVersion unchanged: true   derived path unchanged: true   median of 20: 1.58 ms
one Xcode by two paths (DEVELOPER_DIR alias → same real dir): memo hit, 1.53 ms, derived path unchanged

The reviewer's xcodes case — DEVELOPER_DIR names one stable path and the symlink behind it is swapped for another working Xcode. "Before" is head 3e61534, which you reviewed. The DEVELOPER_DIR string is identical before and after in both columns; only what the key uses differs.

                              head b3f7e6562              head 3e61534 (before the fix)
  before swap  build          27A9275                     27A9275
  after swap   key follows    /private/tmp/…Xcode-Clone   the raw DEVELOPER_DIR string, unchanged
               build          99B999   ← moved            27A9275   ← stale
               derived path   …cache-7cdf9ec1ec611bc8     …cache-2d8626f9b665c837  (unchanged)
               read cost      249 ms (re-probes)          2.39 ms (memo hit)
               stale answer   no                          YES

An in-place bundle replacement at an unchanged path, steady hits across the swap — this is what the bundle identity buys, and "before" is head 2953a77, which keyed on the dir alone:

                                head b3f7e6562                                  head 2953a77 (before the fix)
  read 3 (hit, renews)   27A9275  ino=153118599  1.7 ms                 read 3 (hit, renews)   27A9275  ino=152595979
  >> bundle replaced in place, DEVELOPER_DIR and realpath unchanged
  read 4 (after)         88C888   ino=153119192  92.8 ms  ← re-probes   read 4 (after)         27A9275  ino=152597930  2.2 ms (hit)
  read 5 (hit, renews)   88C888   …cache-78dc058b17eac9ba               read 5 (hit, renews)   27A9275  …cache-2d8626f9b665c837
  stale answer served    no                                             stale answer served    YES

Each substitution is an honest limit: they move the selection through a symlink or replace the bundle marker, not through sudo xcode-select -s, so the root-owned link itself is exercised only by unit tests against a scripted link plus the fact that the read is the same realpath. A two-Xcode host with sudo would close that end to end.

Review threads

All seven Cubic threads are resolved and answered: the report pointer on a hit, the "throw or skip" and two overstated comments, renewal against an in-place bundle replacement (r4297381441), the fs-bound reasoning on r4297381453, the missing-dir wording, and per-client retention of the reported answer (r4297748734).

🤖 Generated with Claude Code