Skip to content

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

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 → … → interactormechanics) 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

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