fix(ios): honor the startup budget through a cold Simulator boot

Merged
PR
2mo ago
+436-64

Summary

Closes #2324.

Supersedes the original implementation by @PrinceD96 (head 8bdb85b7a3ea749ad9c631a8acb3fe573c831124), who found the bug, the stale close --shutdown shortcut, and the cold-Simulator validation recipe; the commit carries a Co-authored-by trailer for them.

A never-booted iOS Simulator runs Apple's first-boot data migration, which can take several minutes. The boot wait in ensureAppleReady was capped at a fixed 120 seconds that neither prepare --timeout nor open could reach, so the cap preempted the caller's budget.

This head replaces the earlier implementation with the smallest change that makes the budget reach the boot wait:

  • The boot wait takes an absolute deadline. simctl boot and simctl bootstatus -b share it; expiry fails with error.details.reason: boot_timeout and a hint. The Simulator is left booting (a deadline is not a cancellation), so a retry finds it further along or ready. Ctrl+C keeps its existing shutdown-on-cancel behaviour.
  • prepare --timeout is one budget for the boot and the runner. The runner preparation gets what the boot left instead of the full budget again.
  • open --timeout is new and bounds the boot for a fresh session. The device claim is held from open onward, so a competing workspace gets DEVICE_IN_USE throughout the cold start without any extra claim mechanism.
  • Client envelope. open and prepare use the existing budget + 30s margin, never below the base envelope arithmetic (envelope: 'margin', the same shape wait already uses), so the daemon's structured timeout wins the race against the client-side daemon reset. prepare --timeout 240000 now yields a 270s envelope instead of 240s.
  • close --shutdown always asks simctl. The session device carries booted: false from selection time for its whole life, so the old shortcut reported a successful shutdown on a Simulator that stayed Booted. Reproduced live before the fix, verified fixed after.

Dropped from the earlier head, on purpose: the daemon-side timer that cancelled the whole request (the budget is scoped to the boot, so --foreground's snapshot is never cut by it), the typed cleanup-failure error and claim abandonment path, forcing runner readiness inside open, the waitForSimulatorBoot on an already-Booted device, and the MAX_STARTUP_TIMEOUT_MS plumbing. Timer overflow for absurd --timeout values is pre-existing for every --timeout command and is out of scope here.

Validation

Unit: readiness (deadline shared by boot + bootstatus, expiry → boot_timeout, no shutdown; default 120s kept), lifecycle (prepare shares the budget; open forwards its deadline), timeout policy (envelope arithmetic + reviewed sets), open CLI/structured input, shutdown runtime. pnpm check:affected --run green.

Live, local Xcode 26 / iOS 26.2 runtime, three freshly created never-booted iPhone 17 Simulators, isolated --state-dir:

  • open com.apple.Preferences --timeout 5000 on a never-booted Simulator: failed after 8s total with boot_timeout + hint; request log shows simctl boot 1.8s then bootstatus cut at 3.1s (one shared budget). Simulator left Booted.
  • open com.apple.Preferences --timeout 600000 on a never-booted Simulator: success in 77s (boot 4s, bootstatus 52s). This Mac finishes the first boot under the old cap, so the unit test carries the cap-removal proof; the live run proves the budget propagates and the flow completes.
  • prepare ios-runner --timeout 600000 on a never-booted Simulator: success in 104s (bootstatus 40s, then runner connect + health check).
  • close --shutdown after opening a Simulator selected while Shutdown: before the fix it returned shutdown.success: true with empty stdout/stderr and the Simulator stayed Booted; after the fix simctl is called and the Simulator reaches Shutdown.