Skip to content

perf(ios): first-interaction stabilization over-sleeps; the 790 ms daemon cold load is a source-mode artifact #2381

Closed

Description

@thymikee

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:

  1. 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.
  2. resolveTarget sets the flag even when activate() 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 -j and 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-91 passes no readiness). XCTest quiescence is already suppressed via skipPreEventQuiescence, and waitForExistence is 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

  1. thymikee commented on Sep 7, 2026

    @thymikee
    MemberAuthor

    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) calls readRemoteDaemonHealth on each sendToDaemon, so the wire conversation for one proxied snapshot -i is:

    GET  /health
    POST /rpc
    

    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 createTtlMemo used 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).

  2. thymikee commented on Sep 16, 2026

    @thymikee
    MemberAuthor

    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 /health probe on persistent remote clients, is now its own issue so it does not sit under a title about iOS stabilization: see the linked issue.

  3. thymikee commented on Sep 16, 2026

    @thymikee
    MemberAuthor

    Follow-up for the health-probe finding: #2650

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions