fix(describe): read the Android tree uncached so describe can't serve a stale screen - #591
Draft
latekvo wants to merge 5 commits into
Draft
fix(describe): read the Android tree uncached so describe can't serve a stale screen#591latekvo wants to merge 5 commits into
latekvo wants to merge 5 commits into
Conversation
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reported symptom
From QA on a physical Android phone (Bluesky):
describesometimes 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, butdescribeat that moment — and twice more in a row — returned exactly the same tree as before publishing.await-screen-idledid 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
describeAndroidcalleddevtools.getHierarchy()with no options, and the blueprint defaultedclearCachetofalse(packages/tool-server/src/blueprints/android-devtools.ts). The device-side helper documents precisely this failure mode aboveclearAccessibilityCache: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:240already passesclearCache: true, commented "await/assert polls must see text changes, not cached reads." The agent-facingdescribepath did not — which is exactly why the reporter's flow-driven paths behaved anddescribedid not.Everything reading the Android tree through
describeAndroidinherited 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:tools/describe/index.ts:127tools/describe/platforms/tv.ts:155tools/await-screen-idle/index.ts:110tools/await-ui-element/index.ts:241utils/ui-tree-match.ts:861(fetchTree)utils/match-element-frame.ts:168preview.ts:726Fixing it at
describeAndroidfixes the class in one place rather than one call site.Change
describeAndroidrequests a coherent capture:devtools.getHierarchy({ clearCache: true }).clearCachedefault is nowtrue, 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.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-sidefalse. The wrapper builds a fresh object with all four keys always set to a non-undefinedvalue, andclient.requestwritesJSON.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-noderefresh()). Measuring proved that wrong: both scale with the tree, because dropping the cache means the following walk re-fetches every node over binder. Device-sidegetHierarchyA/B on the same screen, back to back:Confirmed linear by sweeping
maxNodeson 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
describeon 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:
true, so a caller opts out of coherence. With both existing callers opted in, afalsedefault 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.maxNodesfor the polled readers. That was the tempting knob, and it is the wrong one: it would makeawait-screen-idle/await-ui-elementsee a different tree fromdescribe, reintroducing exactly the class of divergence this PR removes. The budget half of that reasoning holds —pollDescribeTreeclamps every fetch to the remaining budget, and no overrun was ever observed (worst case 3003 ms against a 3000 ms budget;await-ui-element5002 against 5000). But "it costs samples inside the budget instead" understated it, and a later sweep showed why: settling needs two samples that agree acrossminStableMs, so once a single read exceeds roughlytimeoutMs/2the loop can never reach a positive verdict at all. On a 4062-node page at API 34,await-screen-idlereturnedsettled: false5/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— cleannpx prettier --check .— "All matched files use Prettier code style!"npx eslint . --max-warnings 0— cleannpm test -w @argent/tool-server— 296 files, 3083 passed, 1 skippednpm run typecheck:tests -w @argent/tool-server— clean (CI runs this separately)Unit test
New
packages/tool-server/test/describe-android-cache-coherence.test.tsstubs the devtools blueprint and asserts on the options each reader passes —describeAndroiddirectly, plusawait-screen-idle,await-ui-elementand the sharedfetchTree, 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 explicitclearCache: falseopt-out still works.Mutation-checked across the full matrix, because the two layers guard different things:
describeAndroidpassestrueclearCache: truefalseclearCache: truetruefalseThe per-reader tests assert the effective request (they run the observed options through
getHierarchyRequestParams), so they hold whether a reader statesclearCacheor 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 afterrestart-appunder the same long-lived connection.API 34 (
emulator-5560, Android 14) — matched pair, identical script: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
describereported 36 — 23 s stale.await-screen-idlereportedsettled: trueover a visibly animating screen — success for something it never verified. With the coherent read the same call correctly returnssettled: 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 whiledescribereported 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-idlereturnedsettled: falseon that device even on the cached build. The falsesettled: trueis 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_TOASTwindow createdFLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCHABLE.AccessibilityWindowInfohas noTYPE_TOASTconstant at all, and accessibility window enumeration only reports interactive windows, so a toast never appears in theautomation.getWindows()list thatappendInteractiveWindowRootswalks — the helper applies no filter of its own, so nothing on our side is discarding it. ThewindowCount == 0fallback togetRootInActiveWindow()cannot rescue it either, since a non-focusable window is never the active one. Toast content reaches accessibility clients as aTYPE_NOTIFICATION_STATE_CHANGEDevent, and the helper registers no event listener. Surfacing toasts means capturing that event stream device-side — a separate change inargent-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:isVisibleRectinuiautomator-parser.tsrejectsw <= 0 || h <= 0, andcomputeNodeOutputdrops 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 — andawait-screen-idletreats 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
await-screen-idle'streeSignaturecomment 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 — thesettledcontract itself — covered in the follow-up section.fix/issue-521-degraded-hint): no file overlap — that PR touchesdescribe/index.ts,describe/platforms/ios/index.tsandtest/describe-tool.test.ts; this one touchesdescribe/platforms/android/index.tsand 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-privateis not checked out in this worktree (git submodule statusshows 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:
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
minStableMsinsidetimeoutMs(default 3000). Past roughly 1.4 s per read, the second fetch is bounded to less time than it needs,settleWithincuts it off, and the loop exits having compared nothing:settled: falseis defined by the tool's own description as "the screen never went still before the timeout". These screens were static.maxNodespermits 5000, so this is inside the supported range, andawait-ui-elementsharespollDescribeTreeand the same exposure — its note kept reporting a selector verdict built from an earlier sample without saying the last read was cut off.Fix.
pollDescribeTreenow 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-idlereturns anoteand its description documents it;await-ui-elementqualifies 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:
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 statesclearCache: trueitself, that made them pass regardless — deletingclearCachefromdescribeAndroid's call site left all four green. Verified by mutation:clearCachefrom thedescribeAndroidcall sitefalseEach 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 throughdescribeAndroid, 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 andmatch-element-frameasfetchTreeconsumers — both reach the helper throughdescribeAndroiddirectly.Checked and dropped
The helper install gate is
probe.versionCode >= manifest.versionCodeandmanifest.jsonhas beenversionCode: 1since its only commit, which looked like it would strand devices on a helper that ignores the new key. It does not:clearCacheentered the blueprint in 551c781, well before this PR, andunzip-ing the bundled APK showsclearAccessibilityCache,clearCacheandrefreshinclasses.dex. The shipped helper has always understood the key, so no bump is needed — and bumping would have caused a redundantadb installon every tool-server process, since the APK reports 1.The pre-34 path in
appendNodereturns on!refresh()beforenodeCount++and without settingtruncated, so a dropped subtree would be unmarked. It never fired: 6/6 cached-vs-coherent pairs at 1080 nodes matched exactly,truncated: falseon both sides. Latent, left alone, comment corrected to say can rather than does.Verification
npx tsc --build packages/tool-server— cleannpx tsc --noEmit -p packages/tool-server/tsconfig.test.json— cleannpx eslint packages/tool-server/src packages/tool-server/test— cleannpx prettier --checkon tool-server src + test — cleanawait-screen-idletests and the two newawait-ui-elementtests each fail on the un-changed codeEmulators were allocated through
diplomat-device-allocatorand freed; all measurement servers and helper processes torn down.