Repository navigation
perf(ios): first-interaction stabilization over-sleeps; the 790 ms daemon cold load is a source-mode artifact #2381
Description
Follow-up from #2198. Traced open → first readable snapshot → first successful interaction on a local Simulator to find where the time goes, with the explicit constraint that a "win" must reduce the total, not move work into the next command.
The ~790 ms daemon cold module load is refuted as stated
It is not the shipped path. On the bundled dist the daemon's process-start → listening-socket is 182–366 ms (median ~250), of which module read + compile + resolve is ~87 ms; the whole eager closure is 134 chunks / 1.25 MB and evaluates in 51–70 ms, and the lazy Apple tail (request-providers → … → interactor → mechanics) adds 36–42 ms.
The ~790 ms matches a different arm: booting from TypeScript source under --experimental-strip-types, measured at 671–934 ms (median ~740). CPU-profiled it is amaro/SWC WASM type-stripping (~324 ms self) plus module-resolution stat storms (~202 ms self) over ~1,472 source modules.
Which arm runs is decided at src/daemon-client/daemon-launch-spec.ts:44: useSrc is true only when the client itself runs under --experimental-strip-types, or when no dist exists. A published npm install never pays it. The existing comment on resolveLocalDaemonCodeSignature records the same ratio independently ("a dist entry … walks in ~5ms"; "a source checkout's graph — ~1,500 modules — costs tens of milliseconds").
So this is a dev-tooling cost on pnpm test:replay:ios and friends, not architecture, and not something users see. ADR 0019 laziness is genuinely in force: packages/platform-apple/src/index.ts compiles to a 661-byte chunk that eagerly evaluates exactly one module.
Caveat: absolute milliseconds were measured while two full test suites shared the host, so they are biased high. The ratios and the A/B comparisons are the trustworthy part.
The first-interaction cost is confirmed, and 250 ms of it is a literal sleep
open deliberately leaves the runner un-warmed: it proves liveness with uptime, which is isLifecycle: true and therefore skips the app-activation preflight entirely; a relaunch's targetReset actively de-warms it.
firstInteractionAfterActivateDelay = 0.25 (RunnerTests.swift:61) is then consumed unconditionally in applyInteractionStabilizationIfNeeded (RunnerTests+Lifecycle.swift:323). It is real wall clock — sleepFor pumps the runloop in 20 ms slices — and it is roughly 36 % of the ~690 ms first-tap cost, and ~18 % of a ~1,360 ms first-interaction cell.
The flag is set at three points: resolveTarget (after activate), resetTargetAfterExternalRelaunch, and refreshCachedTargetIfProcessChanged. Two details make the charge larger than the invariant requires:
- It is a sleep, not a deadline. The invariant is "at least 250 ms between activation and the first interaction". The code implements "sleep 250 ms at the first interaction", so it over-sleeps by however much already elapsed — and in any agent-driven flow the client round trip alone is 190–260 ms, so almost all of it has.
resolveTargetsets the flag even whenactivate()was skipped because the app was already foreground (AGENT_DEVICE_RUNNER_ACTIVATE_SKIPPED). A resolve that activated nothing still charges the full stabilization.
Proposed change (bounded, one file)
Record the activation instant and sleep max(0, 0.25 − elapsedSinceActivation). This preserves the invariant exactly — a tight loop still gets the full guard — while a normal agent flow pays ~0. It is a genuine saving rather than a relocation, because nothing downstream re-pays it.
The rest of the ~690 ms is the first XCUIApplication construction and AX attach; that was read off the code, not measured, and is not proposed for change here.
Explicitly not worth doing
- Trimming the eager closure — the whole thing is 51–70 ms.
- Splitting
registry.js— 170 KB but only 7–12 ms of work. - Pre-importing the Apple chunks — 36–42 ms, and pure relocation into
open. NODE_COMPILE_CACHE— A/B tested: client −19 ms, daemon boot worse.- Taking the synchronous
ps(readProcessStartTime) off the boot path — measured 26.7 ms, ~0.5 % of a cold open, and it touches daemon pid identity. Not worth the risk. simctl list devices -jand the 5 s readiness memos: they look like a perfect ~700 ms suspect but are not on the tap route (src/daemon/touch-runtime.ts:83-91passes noreadiness). XCTest quiescence is already suppressed viaskipPreEventQuiescence, andwaitForExistenceis already skipped on the first tap.
Side finding
A warm client costs 190–260 ms per command, so a three-command flow pays ~600 ms of client tax regardless of any of the above. The repo already documents this (code-signature-cache.ts:39, "a ~245ms invocation"). If first-interaction latency matters more, that is the larger lever.
Activity
thymikee commentedon Sep 7, 2026
Additional finding, from asserting #2198's round-trip line in #2387 rather than deriving it.
The remote client re-runs its ADR 0006 health probe on every command.
ensureRemoteDaemon(src/daemon-client/daemon-client-lifecycle.ts:175) callsreadRemoteDaemonHealthon eachsendToDaemon, so the wire conversation for one proxiedsnapshot -iis:I found this by expecting one forwarded request and getting two — it is not the bridge, and it predates the bridge, so #2198's claim is unaffected.
For a fresh-process CLI that is one probe per process and unavoidable. For a persistent client (the MCP server, and the benchmark's own persistent-client mode) it is a full extra round trip on every command. That matches the ~2.5 round trips per snapshot the #2198 slice B benchmark measured (persistent-client medians 36 / 93 / 233 ms at 0 / 20 / 80 ms RTT), and at 80 ms RTT it is roughly 40 % of the proxied persistent-client latency.
This is a better-shaped lever than anything in the original report: it is per-command rather than per-boot, it is pure overhead rather than relocatable work, and unlike the 250 ms runner sleep it needs no device to measure.
Not fixed yet, and it should not be fixed carelessly — the probe is what makes version skew fail at health instead of at the RPC (#2351), which is itself an acceptance item. The obvious shape is a short-TTL memo keyed on base URL and token, in the spirit of the existing
createTtlMemoused for the launch spec, so a daemon restarted underneath a long-lived client is still noticed. It needs its own measurement and its own review; filing it here so it is not lost.Ranking against the original candidates: this now sits above the 250 ms first-interaction sleep for remote/proxied flows, and below it for local ones (where there is no RTT to save).
perf(ios): settle the first interaction on a deadline, not a fixed sl…
thymikee commentedon Sep 16, 2026
Closing: both headline claims are settled. The 790 ms daemon cold load is a source-mode (
--experimental-strip-types) artifact a published install never pays, as measured above; the one over-sleep candidate worth landing shipped in #2395 (Swift only: the 250 ms first-interaction floor is charged from activation, not from the interaction).The additional finding, the per-command
/healthprobe on persistent remote clients, is now its own issue so it does not sit under a title about iOS stabilization: see the linked issue.thymikee commentedon Sep 16, 2026
Follow-up for the health-probe finding: #2650