callstack / callstack/agent-device

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

Open
#2,381 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
4.6k
Forks
299
Avg merge
10h 17m
Merged PRs (30d)
515

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`](https://github.com/callstack/agent-device/blob/main/src/daemon-client/daemon-launch-spec.ts#L44): `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`](https://github.com/callstack/agent-device/blob/main/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift#L61)) is then consumed unconditionally in `applyInteractionStabilizationIfNeeded` ([`RunnerTests+Lifecycle.swift:323`](https://github.com/callstack/agent-device/blob/main/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests%2BLifecycle.swift#L323)). 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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.