Skip to content

Hand off physical iOS runner sessions across daemon restarts #2681

Description

@thymikee

Why

Daemon-to-daemon runner handoff exists, but both halves are simulator-gated:

  • detachIosSimulatorRunnerSessionsForShutdown skips any session whose device.kind !== 'simulator' (packages/platform-apple/src/runner/runner-session.ts:663).
  • tryAdoptRunnerSessionFromLease returns null for the same condition (packages/platform-apple/src/runner/runner-adoption.ts:64).

The lease is not the blocker: buildRunnerLease/writeRunnerLease run for every kind (runner-session.ts:263, :299) and already carry the identity facts adoption needs (RunnerLease, runner-lease.ts:41-63).

So a healthy physical runner is killed and rebuilt whenever the daemon restarts. Beyond the wasted xcodebuild boot, this closes the only door an embedding consumer has: a caller that cannot guarantee one long-lived daemon (parallel worktrees, short-lived agent invocations, an SDK host that recycles its daemon) cannot reuse a live runner, so it builds private runner contact instead. An external report describes driving a live LISTENER_READY runner over usbmux precisely to avoid taking our lease. That report is secondhand; the gate above is not.

Task

Extend the existing detach + adoption pair to physical Apple devices. Do not add a second lease or a second adoption path.

  1. Gate on the real target. device.kind === 'device' is not "physical iOS": macOS hosts are kind: 'device', and physical tvOS is too. Decide explicitly whether the change covers tvOS and the xctest backend (core/physical-device-control.ts:114) or gate on physical iOS / Apple device-family with isMacOs excluded, and say so in the code.
  2. Detach only a runner that reached ready. canWorkWithRunnerSession accepts 'starting' (runner-session-types.ts:124-126), and physical startup runs tens of seconds, so a graceful shutdown mid-boot would currently hand off a runner that never reached LISTENER_READY. Detach requires a state that proves it served requests.
  3. Settle process lifetime before claiming handoff. The runner is spawned detached: true while the daemon owns its stdout/stderr pipes (runner-process-launch.ts:53-59). After the daemon exits, a later write by xcodebuild — device lock, replug, testmanagerd chatter — gets EPIPE/SIGPIPE, and post-handoff runner output no longer reaches runner.log. Prove the child survives, or restructure the handoff so it does. A live lease pid proves the host process only; it proves nothing about the on-device runner after a replug, reboot, or DDI remount.
  4. Give the probe a route that can answer at cold start. Adoption's uptime probe already goes through the command route resolver (probeRunnerAnswersUptime → sendRunnerCommandOnce → createRunnerCommandRouteResolver, runner-transport.ts:21), but the tunnel-IP cache is process-global (runner-command-route.ts:23) and a new daemon starts with it empty. For a CoreDevice device usbmuxd does not list, the probe spends RUNNER_ADOPTION_PROBE_TIMEOUT_MS = 500 ms (runner-adoption.ts:38) including a devicectl tunnel fallback it cannot finish in time. The xctest backend is usbmux-only with no tunnel route (:54), so only coredevice needs a cold-tunnel budget; measure both and do not inherit 500 ms unexamined.
  5. Rename honestly. If the function also detaches physical sessions, detachIosSimulatorRunnerSessionsForShutdown is wrong. It is part of the AppleRunnerClient surface (runner/client.ts:20,45,76, core/runner-client.ts:36, runner-operations-facade.ts:3, src/platform-runtime-apple-application-tools.ts:116), it is named in two .fallowrc.json baseline entries (:96, :162) that move with the rename, and it is named in a comment at src/daemon/server/daemon-idle-reap.ts:17. Rename across all of it in the same PR.
  6. Keep AGENT_DEVICE_IOS_RUNNER_DETACH (runner-adoption.ts:46-49) as the switch for both kinds, and keep simulator-set redirects excluded (runner-session.ts:665-669) after confirming that path is unreachable for physical devices rather than assuming it.

Acceptance criteria

  • Physical evidence, per backend that stays in scope (coredevice, and xctest if covered), recorded with the device, iOS version, and commit SHA: start a session, restart the daemon, next command is served by the adopted runner. Proof is the adoption diagnostic plus startupTimings: adoption is already timed as adopt_detached_runner and the adopted session inherits the map (runner-session.ts:168-178), so the assertion is that step present and ensure_xctestrun / launch_xcodebuild (runner-session.ts:208, :246) absent.
  • Mock-only coverage does not count for the physical lanes: copied simulator tests with kind changed do not exercise the usbmux route and do not satisfy this issue.
  • Refusal matrix, one test each: lease absent, lease not stale, pid dead, pid recycled, artifact fingerprint mismatch, session_identity_mismatch, owner alive but owner-state dir gone, probe exceeds budget on the cold tunnel route.
  • Shutdown during runner startup tears down instead of detaching; a wedged/in-flight runner is decided by a named detector, not by state alone (runnerMainThreadBusy is the only occupancy fact available today).
  • Retained-after-close keep-hot behavior (Keep iOS simulator runner hot across session close #1013, AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS, runner-session.ts:517-522) is unchanged or deliberately changed with a stated reason — detaching a retained runner may be the intended payoff, so "close never leaves an adoptable lease" is not the requirement.
  • Simulator detach/adoption timings comparable to main on the simulator lane, with a stated threshold.
  • Physical verification follows docs/agents/device-verification.md. pnpm check:affected --run, pnpm check:layering, pnpm check:production-exports green.

Non-goals

A general runner-ownership protocol, remote leases (ADR 0007, #2598), or a runner-status CLI surface. The session-state enum and response-decoder consolidation are #2662.

Related: ADR 0005, #1403 (usbmux primary), #1013 (keep-hot), #2662.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions