Skip to content

feat: fingerprint the tree while the simulator boots - #275

Merged
janicduplessis merged 1 commit into
fix/skip-redundant-sim-listfrom
feat/fingerprint-during-boot
Sep 3, 2026
Merged

feat: fingerprint the tree while the simulator boots#275
janicduplessis merged 1 commit into
fix/skip-redundant-sim-listfrom
feat/fingerprint-during-boot

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Description

Stacked on #272 (fix/skip-redundant-sim-list); this PR's base is that branch, and its diff is only the second commit. Read #272 first.

A cache-hit run in a fresh worktree spends ~30 s in the created simulator's first simctl bootstatus -b and then another 3-11 s walking the tree for the fingerprint, one after the other. The fingerprint never touches the simulator and the boot never touches the tree. Worse, the two fight when they do overlap by accident: the same walk measures 1.4-1.6 s on a quiet machine and 5.6-12.6 s while a sim boots (investigations/2026-09-02-cache-hit-run-time.md), which is where the "17-20 s first fingerprint" in the gate transcripts comes from -- it is contention, not a cold tree (@expo/fingerprint 0.20.10 keeps no on-disk cache).

Two things kept them serial, and both had to go:

  • commands/ios.ts awaited bootPromise before the fingerprint on the device.created path only (the reuse path already skipped that await).
  • bootIosSim waited on bootstatus through the synchronous exec.run, so even with the await deleted the boot would have blocked the event loop and the fingerprint could not have progressed. The reuse path's "overlap" was therefore never real either.

Solution

bootIosSim is now async and waits on bootstatus through the executor's spawn (the same wrapper the collector and SimSlim use) instead of execSync. The argument list is byte-for-byte what it was -- xcrun simctl bootstatus <udid> -b -- only the invocation is a child process now, so the run's own thread is free while the simulator boots. The per-attempt bound that #128 added is preserved: a hung attempt is killed and retried against the deadline, with the same ETIMEDOUT -> re-list -> retry loop. The bound is now an attemptMs option so tests can exercise the retry in milliseconds instead of four minutes.

ensureOwnedIosDevice no longer waits the boot out. It creates (or resolves) the sim, issues simctl boot, and hands back booting: { udid, done } -- the promise that covers the bootstatus wait and the SimSlim reconcile that has to follow it, since a profile change can reboot the sim. ensureBooted joins that promise where #272 trusted its result, so the fingerprint and the tiered cache resolve run against a booting simulator and the run joins before it installs. A sim that was already booted is untouched: no promise, no deferral, configureOwnedIosSim still runs and still throws inline.

Invariant 10 is unaffected -- nothing about the keys moved. The initial lookup and the single-flight lock still use the pre-mutation key, prebuild/pod install still re-fingerprint, and the artifact is still stored only under the post-mutation key. Invariant 11 is unaffected: the launch happens after the join, and launched is untouched.

What a boot failure now costs. A dead simulator on the created path used to fail inside ensureOwnedDevice, before the build. It now fails where the reuse path has always failed it: at the join in finishIosRun, before the install but after a cache miss would have compiled. That is deliberate -- ios-command.test.ts has pinned "a device that will not boot is refused at install, after the build has been stored" since 612a447, and the artifact is worth storing even when the device is gone. I tried joining the boot before buildIos instead and dropped it: it contradicts that contract for the sake of a failure mode that only bites on a miss. On a hit -- the case this PR is about -- nothing is installed or launched either way.

Blast radius: every iOS run, plus every caller of bootIosSim. Both callers are in engine/device.ts and both now await it. Nothing else in the codebase boots a simulator.

Test plan

  • Real tool (invariant 9, the bootstatus invocation changed): built this branch's CLI, then from trailhead-bench worktree create qa-269 --carry-ignored, start --json in the new worktree, and three ios --json runs. Cleaned up after: stop, worktree remove --force, no stim-* simulator of mine left (stim-issue-245-measure is another session's and was left alone), / back to 16 GB free. No --device, no physical device.

    1. Fresh worktree, created sim (the baseline case). durationMs: 61914.

      device      stim-qa-269 (16BE..) created (1.3s)
      fingerprint ab703f.. hit (11.1s)
      device      stim-qa-269 (16BE..) booted (34.2s)
      install     Trailhead.app -> stim-qa-269 (16BE..) (12.5s)
      install     dev client prepared (2.2s)
      launch      com.appandflow.trailhead (529ms)
      verify      bundle loaded, process alive, stable for 3s (11s total)
    

    device ... created is 1.3 s where the investigation's three baselines measured 24.8 / 28.3 / 54.2 s: the create and simctl boot are all that is left in it. The 11.1 s fingerprint ran inside the 34.2 s boot wait rather than after it. 61.9 s against the 72.6 s baseline mean (range 61.2-83.8 s) on a machine with load average 5.5 and an install that came in at 12.5 s, itself twice the 4.9-7.1 s the baselines measured -- so the phase this PR targets improved while the run's noise floor was worse than the baseline's.

    2. Same worktree, warm (sim booted, app installed). durationMs: 8245.

      fingerprint ab703f.. hit (2.8s)
      device      stim-qa-269 (16BE..) booted (83ms)
      install     unchanged (stim-qa-269 already has this build) (373ms)
      install     dev client prepared (706ms)
      launch      com.appandflow.trailhead (330ms)
      verify      bundle loaded, process alive, stable for 3s (3.5s total)
    

    8.2 s against the investigation's 7.97 s warm reference -- unchanged, as expected: an already-booted sim takes neither the deferral nor fix: skip the redundant simulator list after a boot this run performed #272's skip.

    3. After stop, reuse of a shut-down sim (the other path that now defers). durationMs: 29035, fingerprint hit (15.4s), device ... booted (7.2s): the boot finished 7.2 s in while the fingerprint was still walking, and the run joined it before installing. Comparable to the investigation's parked-shut-down runs (20.3 s / 32.5 s).

  • sim-ios.test.ts: the bootstatus argv is asserted exactly (xcrun simctl bootstatus UDID-A -b), plus the retry after a hung attempt, the timed-out-but-Booted case, the deadline message, the vanished sim, the failing device list, and a non-timeout failure surfaced from the child's exit code and stderr.

  • engine-device.test.ts: ensureBooted joins a pending booting.done without issuing any command, and turns its rejection into the refusal (no listing). ensureOwnedDevice registers the created sim and hands the boot back before it is awaited (spawn of bootstatus only happens once the caller awaits done), keeps the SimSlim reconcile on that promise, and hands back a boot on the reuse-after-shutdown path but not on the already-booted one.

  • ios-command.test.ts: fake clock -- the boot and the fingerprint start at the same instant, interleave in the order boot start, fingerprint start, fingerprint end, boot end, and each phase line reports its own elapsed time. A created sim that fails to boot refuses with STIM_NO_DEVICE and never installs or launches; an uncomputable fingerprint still refuses with STIM_NO_FINGERPRINT before any of it. Ablation: restoring the device.created await fails the overlap test.

  • guide lifecycle now says the iOS device step hands the boot back and that the device ... booted and fingerprint lines overlap, so their durations cannot be summed.

  • pnpm run format:check, lint, build, typecheck, pnpm test (83 files, 3341 tests), pnpm run knip all pass.

Fixes #269

@janicduplessis
janicduplessis force-pushed the feat/fingerprint-during-boot branch from f61d949 to 7a4a214 Compare September 3, 2026 01:02
@janicduplessis
janicduplessis marked this pull request as ready for review September 3, 2026 06:18
@janicduplessis
janicduplessis force-pushed the feat/fingerprint-during-boot branch from 7a4a214 to 9067d8d Compare September 3, 2026 06:24
@janicduplessis
janicduplessis merged commit 9dbba71 into main Sep 3, 2026
5 checks passed
@janicduplessis
janicduplessis deleted the feat/fingerprint-during-boot branch September 3, 2026 06:44
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.

Fingerprint the tree while the simulator boots instead of after

1 participant