Skip to content

perf(android): keep the automation helper warm across fill and scroll - #1974

Open
thymikee wants to merge 3 commits into
mainfrom
tier2/android-helper-session
Open

perf(android): keep the automation helper warm across fill and scroll#1974
thymikee wants to merge 3 commits into
mainfrom
tier2/android-helper-session

Conversation

@thymikee

Copy link
Copy Markdown
Member

Closes the largest verified performance item from the 2026-08 architecture audit (Android hot path, ~10–20x on fill).

Summary

Fill ran 4 full instrumentation lifecycles per invocation (7 on retry) and scroll two one-shot runs, because fill's verification captures and scroll's viewport read never passed the daemon-session helperSessionScope that already existed for snapshots — every capture tore down the helper (graceful quit + am force-stop + forward --remove) and the next spawned a fresh am instrument with a up-to-10s UiAutomation wait.

  • Fill's before-read/verification samples (fill-verification.ts) and scroll's viewport read (touch-helper.ts) now share the session's scope; scope derivation has one owner (androidHelperSessionScope in the interactor) and capture options one builder, so snapshot/fill/scroll cannot derive divergent session identities.
  • Session teardown skips am force-stop only when release is confirmed (graceful.acknowledged && graceful.exited); forced/timeout/abort/failed-start paths still force-stop.
  • Content-failure retirement (a recovery path) always resets the runtime regardless of clean-release skipping — adversarial review caught this regression (F1) and it is fixed with a regression test.
  • Captured hierarchy is never shared across settling samples; only the session is. The helper drops its accessibility cache before each traversal.

Validation

  • Red-first: 7 tests observed failing against merge-base (spawn counts per sample; force-stop gate), plus a mutation check for capture-options identity drift.
  • Adversarial review (REVIEW.md): claims 1–6 PASS after fixes; F1 (retirement reset) confirmed empirically and fixed in 2febe31bf; F3 fenced; F2 documented at the gate.
  • Live emulator A/B (Pixel_7_review, backend proof via androidSnapshot.backend: android-helper, helper starts counted device-side via logcat): fill 2910→1673ms wall, helper starts 3→0; scroll persistent-session on every run, helper starts 2→0; force-stop round trip gone from close.
  • Gates: targeted suites + check:affected (272 files / 1821 tests), typecheck, lint, layering, fallow, production-exports all green.

Residual risk

API < 34 cache flush uses a setServiceInfo() re-apply rather than UiAutomation.clearCache() (declared residue of #1832) — shared-session settling observation relies on it; analysis in RESULT.md.

Android permits one UiAutomation owner, so a command-scoped helper call stops
the instrumentation session when it finishes and the next call pays a fresh
`am instrument` start plus the UiAutomation connect wait. `fill` reads the live
hierarchy four times per attempt (the pre-action target read plus the 0/150/350
ms settling samples) and every one of those reads was command-scoped, so a
single fill ran four instrumentation lifecycles — seven on retry — and left
`scroll` without a session for its viewport read.

Thread the existing `daemon-session` scope, which app-backed sessions already
select for snapshots and whose release session teardown already owns, through
fill's captures and the gesture viewport read. The viewport read may now warm
the session so it and the gesture that follows share one instrumentation
instead of starting two. Snapshot capture and the viewport read build their
capture options through one path, because the session identity is derived from
them: two builders would restart each other's session instead of sharing it.

Session teardown also stops force-stopping the runtime once the helper both
acknowledged `quit` and its process exit was observed — that pair is the
release evidence. Forced, timed-out, and aborted teardowns still force-stop, so
a daemon that dies without sending `quit` cannot leave the helper squatting
UiAutomation.

The settling samples still capture separately; only the session is shared.
REVIEW.md F1. Gating the teardown force-stop on confirmed release also
changed what `stopAndroidSnapshotHelperSession` returning `true` means to
its callers. `retireAndroidSnapshotHelperAfterContentFailure` reads that
return as "the session stop was the runtime reset" and skips
`resetAndroidSnapshotHelperRuntime` when it is true, so a helper whose
output failed content validation three times over a daemon session was
retired with no `am force-stop` at all, while the one-shot arm of the same
recovery still reset the runtime.

The session stop now takes `resetRuntime`, and the retirement passes it.
Content failure is a recovery path, not a clean release: the helper
answered with output we could not trust, so the next capture must meet a
runtime that was reset, and "it quit politely" is not a reason to leave a
suspect process owning it. Genuine session close still skips the round
trip, which is the optimization this branch exists for.

The pre-existing test "content failure retirement does not layer a second
reset over a persistent session stop" rested on a premise this branch had
already made false — stopping a live session was no longer the runtime
reset. It is renamed to "makes the session stop reset the runtime instead
of layering a second one" and asserts the requirement it now depends on,
so its title states what it enforces. The reviewer's probe returns as a
regression test in `snapshot.test.ts`.

REVIEW.md F2. `observeAndroidSnapshotHelperProcessExit` returns an
observation that knows whether the end it saw is release evidence: the
process must have been alive when the teardown started watching and must
then exit with code 0 and no terminating signal. A signal, a non-zero
code, and a host child that was already gone before `quit` was sent all
mean the transport died — and in the pre-died case the acknowledgement is
positive evidence that the device-side helper OUTLIVED its host through
the open forward. None of them confirm release now. What is left is
declared at the gate: host-side exit codes are only as strong as adb's
exit forwarding, and a device without shell protocol v2 can report 0 for
an instrumentation that did not finish.

REVIEW.md F3. `&& graceful.exited` survived deletion against all 423
tests. Two named tests now fail without it, both cheap because the
stricter evidence above makes acknowledged-but-not-released reachable
without waiting out the 11 s graceful-exit timeout.

Gates: `pnpm vitest run src/platforms/android` (44 files, 423 tests) and
with `src/core/interactors` (426 tests), `pnpm typecheck`, `pnpm lint`,
`pnpm format`, `pnpm check:fallow --base origin/main` (24 changed files,
clean). No emulator is attached in this worktree, so the teardown-path
force-stop counts were not re-observed live.
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.36 MB 2.36 MB +971 B
JS gzip 792.0 kB 792.3 kB +262 B
npm tarball 916.1 kB 913.6 kB -2.5 kB
npm unpacked 3.17 MB 3.16 MB -9.9 kB

npm unpacked components

Component Base Current Diff
JS / dist source 2.51 MB 2.51 MB +971 B
Apple runner source/project 564.2 kB 553.4 kB -10.8 kB
macOS helper source 54.5 kB 54.5 kB 0 B
Android helper artifacts 0 B 0 B 0 B
Other package files 44.4 kB 44.4 kB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.6 ms 28.5 ms +0.9 ms
CLI --help 80.0 ms 83.8 ms +3.8 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/dispatch.js +26.2 kB +8.8 kB
dist/src/registry.js -33 B -16 B

Top changed packed files

Packed file Base Current Diff
dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentation.swift 14.1 kB 10.6 kB -3.6 kB
dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift 13.3 kB 10.7 kB -2.6 kB
dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotVisibilityFold.swift 11.7 kB 9.8 kB -1.9 kB
dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotBackendCapabilities.swift 2.5 kB 1.5 kB -998 B
dist/src/snapshot-helper.js 23.4 kB 24.2 kB +871 B
dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift 39.1 kB 38.2 kB -864 B
dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift 31.7 kB 31.1 kB -650 B
dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentationModels.swift 3.3 kB 3.0 kB -314 B
dist/src/input-actions.js 17.1 kB 17.4 kB +266 B
dist/src/snapshot.js 26.2 kB 25.9 kB -217 B

This branch extracted the helper-session, retirement, and touch-helper cases
out of src/platforms/android/__tests__/snapshot.test.ts into sibling files,
taking it from 1,658 to 1,495 lines. The test-file size ratchet fails an
un-banked shrink ("lower its pin in this PR so the ratchet keeps the gain"),
so bank it. The pin only moves down; nothing grew into it.
@thymikee

Copy link
Copy Markdown
Member Author

The lifecycle and regression coverage look sound, but this is not ready at 9e047ca0 under the repository extraction rule. This PR adds behavior to snapshot-helper-session.ts (507→563 lines) and input-actions.ts (501→509), both already past the hard 500-line threshold. Extract the warm-session acquire/ownership/retirement logic into a sibling session-lifecycle module, and move the changed fill behavior out of the input-actions aggregation, keeping focused tests with their owners. The route and safety semantics can stay unchanged. Exact-head completed checks are green; iOS Smoke is still pending, not a confirmed failure.

@thymikee

Copy link
Copy Markdown
Member Author

Two blockers remain at this head:

  1. This adds behavior to snapshot-helper-session.ts (507→563 lines) and input-actions.ts (502→509), despite the repository rule requiring extraction before adding behavior past 500 lines. Please move the warm-session ownership/acquisition/retirement concept and the changed fill owner into focused modules, with their tests following those owners.

  2. The new runtimeReleaseConfirmed path deliberately skips am force-stop based on adb host exit code 0, while its own comment acknowledges that older adb without shell protocol v2 can report 0 even when device instrumentation did not finish. That makes the optimization capable of leaving UiAutomation owned and poisoning the next command. A paragraph-long residual-risk comment is not a sufficient invariant. Only skip the device-side stop when the transport can positively prove a trustworthy device exit status, or keep the stop on unsupported/unknown transports.

Current main also overlaps the fill files, so please rebase and revalidate after restructuring. The lifecycle tests, device evidence, and exact-head CI are otherwise strong.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant