Skip to content

fix(describe): read the Android tree uncached so describe can't serve a stale screen - #591

Draft
latekvo wants to merge 5 commits into
mainfrom
fix/describe-android-stale-ax-cache
Draft

fix(describe): read the Android tree uncached so describe can't serve a stale screen#591
latekvo wants to merge 5 commits into
mainfrom
fix/describe-android-stale-ax-cache

Conversation

@latekvo

@latekvo latekvo commented Jul 30, 2026

Copy link
Copy Markdown
Member

Reported symptom

From QA on a physical Android phone (Bluesky): describe sometimes shows a stale screen state. After publishing a post with an image the app showed a "Your post was sent" toast and returned to the feed, but describe at that moment — and twice more in a row — returned exactly the same tree as before publishing. await-screen-idle did not help, because it draws on the same tree.

This PR fixes the staleness. It does not make the toast appear — that is a separate, narrower gap, scoped honestly at the bottom.

Root cause

describeAndroid called devtools.getHierarchy() with no options, and the blueprint defaulted clearCache to false (packages/tool-server/src/blueprints/android-devtools.ts). The device-side helper documents precisely this failure mode above clearAccessibilityCache:

The connection's AccessibilityNodeInfo cache can serve stale text — its event-driven invalidation has been observed to stop after the inspected app restarts under this long-lived connection. clearCache requests bypass it: cleared here on API 34+, per-node refresh() in the walk below.

The commit that introduced the option is even more specific: "nodes walked once keep returning their first-seen text to every later getHierarchy (breaking flow await/assert checks against a visibly-updated screen)."

The divergence is the story. packages/tool-server/src/tools/flows/flow-android-tree.ts:240 already passes clearCache: true, commented "await/assert polls must see text changes, not cached reads." The agent-facing describe path did not — which is exactly why the reporter's flow-driven paths behaved and describe did not.

Everything reading the Android tree through describeAndroid inherited it. I traced all seven call sites and each one is answering "what is on the screen right now", so all seven want coherence — no reader is left on the cached path:

caller why it needs a coherent read
tools/describe/index.ts:127 the agent-facing tree
tools/describe/platforms/tv.ts:155 Android TV fallback tree
tools/await-screen-idle/index.ts:110 polls for "stopped changing"
tools/await-ui-element/index.ts:241 polls for an element state
utils/ui-tree-match.ts:861 (fetchTree) shared selector matching
utils/match-element-frame.ts:168 turns a match into tap coordinates
preview.ts:726 Lens/preview describe route

Fixing it at describeAndroid fixes the class in one place rather than one call site.

Change

  1. describeAndroid requests a coherent capture: devtools.getHierarchy({ clearCache: true }).
  2. The blueprint's clearCache default is now true, so a caller opts out of coherence rather than into it — the silent failure mode should not be the one you get by forgetting a flag.
  3. The request payload build moves into an exported getHierarchyRequestParams(options), which makes the defaults testable without standing up the helper's adb/spawn plumbing, and keeps all four fields on the wire on every path.

On (3): that last part matters because the device-side helper reads params.optBoolean("clearCache", false). An options object that omits the key — { maxNodes: N }, as the flow path passes — must not fall through to that device-side false. The wrapper builds a fresh object with all four keys always set to a non-undefined value, and client.request writes JSON.stringify({ id, method, params }) through unfiltered, so the Java default is never consulted. Pinned by a test.

Both call sites keep their explicit clearCache: true. That is redundant against the new default by design: it states each agent-facing read's requirement where the read is made, independent of a default living in another module. Consistent across both — neither is left implicit.

Cost analysis — measured, not assumed

I expected API 34+ to be nearly free (one UiAutomation.clearCache()) and only pre-34 to be expensive (per-node refresh()). Measuring proved that wrong: both scale with the tree, because dropping the cache means the following walk re-fetches every node over binder. Device-side getHierarchy A/B on the same screen, back to back:

device raw nodes cached uncached delta per node
API 30 (Android 11, arm64) 124 4 ms 45 ms +41 ms ~0.3 ms
API 34 (Android 14, arm64) 163 3 ms 27 ms +24 ms ~0.13 ms

Confirmed linear by sweeping maxNodes on one screen (the walk stops at the budget, so a smaller cap means strictly fewer refreshes): API 30 gave 0.26–0.40 ms/node across caps of 10/25/50/100/5000; API 34 gave 0.10–0.13 ms/node at realistic sizes.

End-to-end describe on API 30, median of 12 calls: 76 ms → 121 ms (light screen), 71 ms → 106 ms (heavier screen).

Why unconditional, and why nothing else changed:

  • Not gated on API ≥ 34. That would leave pre-34 devices — the reporter's class of hardware — with the bug, which is the whole point. The measured pre-34 cost is tens of milliseconds, not seconds.
  • The blueprint default is flipped to true, so a caller opts out of coherence. With both existing callers opted in, a false default served zero production code while remaining the path you get by forgetting the flag — and that failure is silent: a stale tree raises no error, fails no test, and looks plausible enough to act on. For a tradeoff of 24-41 ms of device-side work against a wrong answer about the screen, the safe read should not depend on being remembered.
  • No lower maxNodes for the polled readers. That was the tempting knob, and it is the wrong one: it would make await-screen-idle / await-ui-element see a different tree from describe, reintroducing exactly the class of divergence this PR removes. The budget half of that reasoning holds — pollDescribeTree clamps every fetch to the remaining budget, and no overrun was ever observed (worst case 3003 ms against a 3000 ms budget; await-ui-element 5002 against 5000). But "it costs samples inside the budget instead" understated it, and a later sweep showed why: settling needs two samples that agree across minStableMs, so once a single read exceeds roughly timeoutMs/2 the loop can never reach a positive verdict at all. On a 4062-node page at API 34, await-screen-idle returned settled: false 5/5 times at ~3001 ms on a static screen, where the cached read settled 5/5 in 321-358 ms. That is corrected in dd3fe39 — see the follow-up section.

Correctness beats latency here regardless: a stale tree is a wrong answer that an agent acts on.

Verification

Commands, all from the worktree root:

  • npx tsc --build — clean
  • npx prettier --check . — "All matched files use Prettier code style!"
  • npx eslint . --max-warnings 0 — clean
  • npm test -w @argent/tool-server296 files, 3083 passed, 1 skipped
  • npm run typecheck:tests -w @argent/tool-server — clean (CI runs this separately)

Unit test

New packages/tool-server/test/describe-android-cache-coherence.test.ts stubs the devtools blueprint and asserts on the options each reader passes — describeAndroid directly, plus await-screen-idle, await-ui-element and the shared fetchTree, so a future reader that drops coherence fails here.

Four further tests pin the blueprint default itself, including the { maxNodes: N } case above and that an explicit clearCache: false opt-out still works.

Mutation-checked across the full matrix, because the two layers guard different things:

default describeAndroid passes outcome
true clearCache: true 8 pass (shipped state)
false clearCache: true 3 default tests fail; the 4 per-reader tests still pass — call sites protect themselves
true omitted 8 pass — this is what the flip buys: a caller that forgets still gets a coherent read
false omitted 7 of 8 fail — the original defect is caught

The per-reader tests assert the effective request (they run the observed options through getHierarchyRequestParams), so they hold whether a reader states clearCache or inherits it, and fail if either the reader opts out or the default stops being coherent.

E2E — the load-bearing proof

Real emulators, allocated through diplomat-device-allocator, driving the built tool-server over HTTP (POST /tools/:name).

A navigation-based test proves nothing here: a new activity gets new node ids, so it is a cache miss and reads correctly even when broken. The defect is a node whose text changes in place. Probe: the Clock app's stopwatch — one node, fixed window and activity, text advancing several times a second. Ground truth is offset-free (the stopwatch counts real time) and cross-checked against adb exec-out screencap, outside the helper connection. Following the Java comment's recipe, each phase is sampled before and after restart-app under the same long-lived connection.

API 34 (emulator-5560, Android 14) — matched pair, identical script:

BEFORE (cached read)                             AFTER (clearCache: true)
A #1 read=2   realTime +0s   read +0s   lag=0s   A #1 read=2   +0s   +0s   lag=0s
A #2 read=2   realTime +3s   read +0s   lag=3s   A #2 read=6   +4s   +4s   lag=0s
A #3 read=2   realTime +7s   read +0s   lag=7s   A #3 read=10  +7s   +8s   lag=-1s
A #4 read=2   realTime +11s  read +0s   lag=11s  A #4 read=14  +11s  +12s  lag=-1s
A #5 read=2   realTime +15s  read +0s   lag=15s  A #5 read=17  +15s  +15s  lag=0s
--- restart-app under the long-lived connection ---
B #1 read=36  realTime +0s   read +0s   lag=0s   B #1 read=29  +0s   +0s   lag=0s
B #2 read=36  realTime +4s   read +0s   lag=4s   B #2 read=33  +4s   +4s   lag=0s
B #3 read=36  realTime +9s   read +0s   lag=9s   B #3 read=36  +8s   +7s   lag=1s
B #4 read=36  realTime +13s  read +0s   lag=13s  B #4 read=40  +12s  +11s  lag=1s
B #5 read=36  realTime +18s  read +0s   lag=18s  B #5 read=44  +15s  +15s  lag=0s

await-screen-idle: {"settled":true, polls:2}     await-screen-idle: {"settled":false, polls:4}

The read is frozen for 15–18 s at a stretch while the screen advances. A screenshot taken during the BEFORE run shows the stopwatch at 59.11 s with a ⏸ fab (running) at a moment describe reported 36 — 23 s stale.

await-screen-idle reported settled: true over a visibly animating screen — success for something it never verified. With the coherent read the same call correctly returns settled: false. This is the concrete confirmation that the frozen-tree false-settle is real and not just theoretical.

API 30 (api30_verify, Android 11) — same probe: drift 0/3/6/0/3 s before (sawtooth: frozen between periodic invalidations) → 0/1 s after. An early screenshot there caught the screen at 49.83 s while describe reported 18 — 31 s stale.

One honesty note on the API 30 transcript: its stopwatch label aggregates a live-updating hundredths sibling, so the signature kept moving and await-screen-idle returned settled: false on that device even on the cached build. The false settled: true is demonstrated on API 34, where the label is a single value and the whole signature freezes.

What this PR does NOT fix

Both stated plainly because cache coherence does not touch either.

The toast — not fixed, and not fixable by this change. An Android toast is a WindowManager.LayoutParams.TYPE_TOAST window created FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCHABLE. AccessibilityWindowInfo has no TYPE_TOAST constant at all, and accessibility window enumeration only reports interactive windows, so a toast never appears in the automation.getWindows() list that appendInteractiveWindowRoots walks — the helper applies no filter of its own, so nothing on our side is discarding it. The windowCount == 0 fallback to getRootInActiveWindow() cannot rescue it either, since a non-focusable window is never the active one. Toast content reaches accessibility clients as a TYPE_NOTIFICATION_STATE_CHANGED event, and the helper registers no event listener. Surfacing toasts means capturing that event stream device-side — a separate change in argent-private, not a TypeScript one.

The collapsed bottom sheet — a distinct issue, not this cache bug. The Java applies no visibility or geometry filter (no isVisibleToUser, no empty-rect skip); it emits zero-area nodes verbatim. The pruning is on our side and deliberate: isVisibleRect in uiautomator-parser.ts rejects w <= 0 || h <= 0, and computeNodeOutput drops a node that is both invisible and childless. A collapsed sheet genuinely has nothing visible, so a near-empty tree is the intended output. The real complaint is that it is indistinguishable from a screen that never rendered — and await-screen-idle treats both the same way (tree.children.length === 0 ⇒ not settled). That is a reporting-ambiguity question worth its own issue, not a staleness fix.

Notes for review

  • Prose describes the state, not the change. The added comment carries the cost numbers and the reason, with no "now uses" / "no longer" / "previously"; change narration is confined to the commit message and this PR body.
  • await-screen-idle's treeSignature comment re-read and left as is. "An unchanged signature means the screen has genuinely stopped moving" was false on Android under the cached read and is true as written once the read is coherent. Its tool description did need editing, but for a different reason — the settled contract itself — covered in the follow-up section.
  • Interaction with fix(describe): stop ordering a simulator reboot on every externally-booted read #583 (fix/issue-521-degraded-hint): no file overlap — that PR touches describe/index.ts, describe/platforms/ios/index.ts and test/describe-tool.test.ts; this one touches describe/platforms/android/index.ts and a new test file. Conceptually complementary: fix(describe): stop ordering a simulator reboot on every externally-booted read #583 stops an untrustworthy iOS read being presented as a real observation (isBlindRead), while this makes the Android read trustworthy in the first place. Neither changes the other's behaviour.
  • packages/argent-private is not checked out in this worktree (git submodule status shows the - prefix), so the Java is read-only reference here and nothing in it changed.

Follow-up: dd3fe39

Review of this PR ran the cost analysis above out to realistic tree sizes on two API levels. Two things it asserts do not survive that, and one regression falls out of them.

The polled readers lose the verdict, not just samples

The measurement above stops at 124/163 nodes. Swept further, the per-node rate is not a constant and API 34 is not the cheaper platform:

nodes API 30 cached → coherent API 34 cached → coherent
~315-330 3 → 106 ms (0.31/node) 10 → 267 ms (0.82/node)
~1065-1080 6 → 314 ms (0.29/node) 30 → 535 ms (0.47/node)
~2065-2080 10 → 648 ms (0.31/node) 62 → 997 ms (0.45/node)
~3065-3080 14 → 889 ms (0.28/node) 77 → 1107 ms (0.34/node)

API 30 is linear at 0.28-0.31 ms/node from 330 to 4080 nodes. API 34 runs 0.34-0.82 ms/node and costs more than API 30 at every size from 315 nodes up, so the single UiAutomation.clearCache() call does not make it cheaper — the stated ~0.13 ms/node came from a 163-node screen and does not generalise.

That matters because settling takes two samples that agree across minStableMs inside timeoutMs (default 3000). Past roughly 1.4 s per read, the second fetch is bounded to less time than it needs, settleWithin cuts it off, and the loop exits having compared nothing:

port=3051 (branch)                        port=3052 (main)
s2000.html  1172ms  …  false/2/3001       s2000.html  80ms  …  true/2/311
big.html    1431ms  …  false/2/3002       big.html   104ms  …  true/2/346
                       false/2/3001                            true/2/346

settled: false is defined by the tool's own description as "the screen never went still before the timeout". These screens were static. maxNodes permits 5000, so this is inside the supported range, and await-ui-element shares pollDescribeTree and the same exposure — its note kept reporting a selector verdict built from an earlier sample without saying the last read was cut off.

Fix. pollDescribeTree now records whether the budget expired with a fetch still in flight, and both callers report which of the two happened rather than asserting a negative about the screen. await-screen-idle returns a note and its description documents it; await-ui-element qualifies its existing note. The coherent polling this PR introduced is unchanged — the reads stay correct, they just stop being misreported.

The stated trigger is not what triggers it

The root-cause section attributes the freeze to invalidation stopping "after the inspected app restarts under this long-lived connection". Reproduced on a fresh connection with no restart anywhere in the run — Google Clock stopwatch, API 34, 20 consecutive cached reads 1.5 s apart:

PHASE A - fresh helper connection, app NOT restarted
  2:09.32 x20  (all 20 identical, 30.6s wall)
  truth right now (coherent read): 2:40.32   -> 31s stale

The restart neither causes nor worsens it. The determinant is whether the app emits accessibility content-change events for that node: Chrome web content tracked perfectly under cached reads on both API levels (14 consecutive reads, 14 distinct values), while the stopwatch never invalidates. The defect is real and this fix is right; the cause in the comments was wrong, and is corrected in the source.

Tests

The four per-reader tests asserted the effective request by running observed options back through getHierarchyRequestParams. Because every reader states clearCache: true itself, that made them pass regardless — deleting clearCache from describeAndroid's call site left all four green. Verified by mutation:

mutation before after
drop clearCache from the describeAndroid call site 0 fail 4 fail
flip the blueprint default to false 3 fail 3 fail

Each reader now pins both its raw options and the resulting request, so the two guards are checked independently. Added alongside: coverage for queryAndroidFullHierarchy (the one reader that does not route through describeAndroid, so a regression there was invisible), an assertion that the fixture actually parses (it could be gutted to an empty <hierarchy> with all eight tests still green), and a corrected comment that had named the Lens route and match-element-frame as fetchTree consumers — both reach the helper through describeAndroid directly.

Checked and dropped

The helper install gate is probe.versionCode >= manifest.versionCode and manifest.json has been versionCode: 1 since its only commit, which looked like it would strand devices on a helper that ignores the new key. It does not: clearCache entered the blueprint in 551c781, well before this PR, and unzip-ing the bundled APK shows clearAccessibilityCache, clearCache and refresh in classes.dex. The shipped helper has always understood the key, so no bump is needed — and bumping would have caused a redundant adb install on every tool-server process, since the APK reports 1.

The pre-34 path in appendNode returns on !refresh() before nodeCount++ and without setting truncated, so a dropped subtree would be unmarked. It never fired: 6/6 cached-vs-coherent pairs at 1080 nodes matched exactly, truncated: false on both sides. Latent, left alone, comment corrected to say can rather than does.

Verification

  • npx tsc --build packages/tool-server — clean
  • npx tsc --noEmit -p packages/tool-server/tsconfig.test.json — clean
  • npx eslint packages/tool-server/src packages/tool-server/test — clean
  • npx prettier --check on tool-server src + test — clean
  • full tool-server suite — 296 files, 3083 passed, 1 skipped
  • every fix mutation-tested; the two new await-screen-idle tests and the two new await-ui-element tests each fail on the un-changed code

Emulators were allocated through diplomat-device-allocator and freed; all measurement servers and helper processes torn down.

latekvo added 5 commits July 30, 2026 15:43
… a stale screen

`describeAndroid` called `getHierarchy()` with no options, and the blueprint
defaults `clearCache` to false. The android-devtools helper's long-lived
UiAutomation connection caches AccessibilityNodeInfo per node, and its
event-driven invalidation has been observed to stop after the inspected app
restarts under that connection, so a node walked once keeps returning its
first-seen text. Every agent-facing Android tree read inherited that: the
`describe` tool, `await-screen-idle`, `await-ui-element`, the shared
`fetchTree` selector match (which turns a matched node into tap coordinates)
and the Lens/preview describe route.

`flow-android-tree.ts` already passed `clearCache: true` for exactly this
reason ("await/assert polls must see text changes, not cached reads"), which is
why flow-driven paths tracked the screen and `describe` did not.

Measured on arm64 emulators with a running stopwatch as the probe (one node
whose text changes in place while its window and activity stay fixed): on API
34 `describe` froze for 15-18 s at a time while the screen advanced -- a
screenshot read 59.11 s at a moment `describe` reported 36 -- and
`await-screen-idle` returned settled:true over a visibly animating screen.
With the uncached read the same sequence tracks real time to within 1 s and
`await-screen-idle` correctly returns settled:false.

Cost, measured device-side: API 30 124 nodes 4 ms -> 45 ms (~0.3 ms/node), API
34 163 nodes 3 ms -> 27 ms (~0.13 ms/node); end-to-end median `describe` on API
30 76 ms -> 121 ms. Both API levels scale with the tree because dropping the
cache makes the walk re-fetch each node over binder. The polled readers are
wall-clock bounded, so a slower read costs them samples inside the budget
rather than overrunning it (await-screen-idle: 5 polls -> 4 in a 3 s wait).
…ached capture

The `clearCache` guidance read "opt in when asserting on changing text", which
is narrower than the condition that actually matters: any capture standing for
the current screen needs it, including one whose text is incidental (a selector
match that becomes tap coordinates). A caller reading that could reasonably take
the cached default for a read that cannot tolerate a stale tree.
…not restart-only

The comment tied the stale read to the app restarting under the helper
connection. That is the trigger the device-side helper documents observing, but
a frozen read also reproduced on a connection that had seen no restart, so
naming the restart as the cause overstates what is known.
Both production callers opt in to `clearCache`, so the `false` default served no
code while still being the path a caller gets by forgetting the flag — and the
failure it allows is silent: a stale tree raises no error, fails no test, and
looks plausible enough for an agent to act on. For a tradeoff measured at 24-41 ms
of device-side work against a wrong answer about the screen, the safe read is the
one that should need no remembering, so a caller now opts out rather than in.

The payload build moves into an exported `getHierarchyRequestParams` so the
defaults are testable without standing up the helper's adb/spawn plumbing. It
still sends all four fields on every path, which matters because the device-side
helper reads `params.optBoolean("clearCache", false)`: an options object that
omits the key must not fall through to that device-side default.

Both call sites keep their explicit `clearCache: true`. It is redundant against
the new default by design — it keeps each agent-facing read's requirement stated
where the read is made, and independent of a default living in another module.

Mutation-checked across the matrix: flipping the default back fails the three
default tests while the four per-reader tests still pass (call sites protect
themselves); dropping a call site's flag with the new default in place keeps all
eight green (the point of the flip); doing both — the original defect — fails
seven of eight.
…settled screen

A coherent capture scales with the tree (0.28-0.31 ms/node on API 30,
0.34-0.82 ms/node on API 34), so a large hierarchy can take longer to read
than the wait tools' whole budget. Settling needs two samples that agree
across minStableMs, and neither tool could report that it never got the
second one: await-screen-idle returned settled:false, which its own
description defines as "the screen never went still", for screens measured
static; await-ui-element built its selector verdict from an earlier sample
without saying so. Measured on a 4062-node page, API 34: 5/5 calls returned
settled:false at 3001ms where a cached read settled 5/5 in 321-358ms.

pollDescribeTree now reports whether the budget expired with a fetch still
in flight, and both callers say which of the two happened.

Also corrects the measured claims on the Android describe path: the cache
freeze needs no app restart (reproduced on a fresh connection - 20 cached
reads over 30s held 2:09.32 against a timer at 2:40.32), it is driven by an
app that emits no content-change events for a node; and API 34+ is not the
cheaper platform - its coherent capture costs more than API 30's per-node
refresh() at every size from 315 nodes up.

The coherence tests asserted only the effective wire request, so deleting
clearCache from a call site left them green. Each reader now pins its own
options and the resulting request, and the flow full-hierarchy read - the one
reader that does not route through describeAndroid - is covered.
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