Skip to content

fix(android): bound the dumpsys reads behind dialog and foreground checks - #3936

Merged
thymikee merged 5 commits into
callstack:mainfrom
airowe:fix/android-observation-dump-timeout
Oct 24, 2026
Merged

thymikee merged 5 commits into
callstack:mainfrom
airowe:fix/android-observation-dump-timeout

Conversation

@airowe

@airowe airowe commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The blocking-dialog and foreground observations ran adb shell dumpsys window … and dumpsys activity … with no timeout around every Android interaction. A hung read waited for the client envelope, which resets the daemon. Now every variant one observation reads (createDumpReader, packages/platform-android/src/observation.ts) shares one 10 s budget, counted from its first dump read.

  • A timed-out read or a spent budget counts as unanswered (unknown / {}), like an empty dump. The command proceeds: "a failed probe has never been a refusal".
  • Other failures still propagate.
  • AndroidObservationHost.runAdb options gain timeoutMs. tap and wm size are unchanged.

3 files. This unblocks the #3326 ledger (#3937).

Validation

  • pnpm check:affected --run --head cc4dd4a2f: passed, exactCommit=true (base c48d27a81).
  • The new tests fail on base. The budget tests also fail against the per-read version, defd554a9.
  • Live at 8a582e78c (before the budget-start commit) on a Pixel 7 API 36 emulator (android-helper 0.21.32-dev, AGENT_DEVICE_EXEC_TRACE=1):
    • open --relaunch, press 'label="Network & internet"', back and longpress 540 1200 1500 passed 3 of 3 runs.
    • Every observation read carried the budget.
    • One press request also logged one adb exec without timeoutMs. It settled at 4.6 s after android_touch_helper_install, outside the observation reads. The trace has no args, so I could not identify it.
    • An earlier run failed press because Clock came to the foreground after boot. It passed on rerun.
  • I did not reproduce a hung dumpsys live.

🤖 Generated with Claude Code

…ecks

The blocking-dialog and foreground observations ran `adb shell dumpsys`
with no timeout before and after every Android interaction, so a hung
read waited for the client envelope and reset the daemon. Each dump read
now carries a 10 s bound. A timed-out read counts as unanswered, the same
`unknown` a read that printed nothing produces, so a slow probe still
never refuses the command; the reader then skips its remaining variants,
so one observation costs at most one bound.

This unblocks the longpress envelope ledger in callstack#3326, which cannot prove
an envelope while these reads are unbounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thymikee

Copy link
Copy Markdown
Member

This is ready at defd554: the dumpsys reads behind the dialog and foreground checks are now bounded, and a timeout maps to an unanswered result as intended. All 11 checks pass at this head, and there are no conflicts. I read the code and did not run the tests locally; the emulator run and its exec trace come from the PR body only. Not blocking, and you can take or leave these: readScreenSize in observation.ts still runs adb shell wm size without a timeout and is called on the touch route, so #3326 should bound it together with tap; the timedOutAdb fixture in observation.test.ts builds the timeout error by hand, so building it with the exec layer's own timeout constructor would prove the real error shape passes isCommandTimeoutError; and the bound is per observation, so waitForAndroidAppFocus and the foreground check can each add one on a device that stays hung, which the #3326 ledger should count per route.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 24, 2026
A per-read bound still let one observation walk every dumpsys variant
that answered without a focus line, each spending up to its own 10 s.
All reads of one observation now draw on a single 10 s budget, so an
observation costs at most one bound however many variants it reads;
a spent budget or a timed-out read leaves the question unanswered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@airowe

airowe commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. On the three non-blocking points:

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member

This PR is ready. At 8a582e7 the code looks correct to me, and the 11 checks are all green. I have no other concerns with the change since the earlier review. I did not run the tests or a device; the regression judgment comes from reading the diff, and the emulator run and exec trace come from the PR body. I also did not trace the timeout error shape of provider-backed adb executors, only the local path in exec.ts. Not blocking, and you can take or leave it: in observation.ts the dump deadline starts when the reader is created, so in readBlockingDialog up to 2 s of the helper focus call comes out of the 10 s budget, and a shared reader in readAppFocus pays for the second focus call the same way. You could create the reader after the focus call, or word the comment as "budget runs from when the observation starts". No conflicts.

The budget started when the reader was created, so the helper focus
call that readBlockingDialog (and readAppFocus, for a shared reader)
makes first spent up to 2 s of the 10 s dump budget. The deadline now
starts at the first dump read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@airowe

airowe commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Taken in 31676642a: the dump budget now starts at an observation's first dump read, not when the reader is created. So the helper focus call in readBlockingDialog, and both focus calls on a shared reader in readAppFocus, no longer spend it. A new test spends half the budget in a mocked focus call and checks that the first dump still gets the full bound. It fails at 8a582e78c (5000 vs 10000). check:affected --head 31676642a passed. The #3937 ledger still measures 289,989 ms.

🤖 Addressed by Claude Code

Matches the other platform-android modules that bound a sequence of
steps, instead of computing the deadline and remainder by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thymikee

Copy link
Copy Markdown
Member

This PR is ready at f02edea. The change since the earlier review only moves where the deadline starts, and I found one small edge case, described below. CI is green with 11 checks and none failing, and there are no conflicts. I did not run the tests. I judged the regression from the pre-change code path and the virtual-clock mechanics. The live emulator evidence in the PR body is from 8a582e7, not f02edea, and no hung dumpsys was reproduced live. The earlier review accepted that evidence. Not blocking, and you can take or leave it: observation.ts now calls deadline.isExpired() and then deadline.remainingMs(), which read the clock twice. If the millisecond rolls over between the two calls, remainingMs() returns 0, normalizeTimeoutMs in exec.ts maps that to no timeout, and that one dump read runs unbounded. Reading once avoids it: const remainingMs = deadline.remainingMs(); if (remainingMs <= 0) return Promise.resolve('');, then pass timeoutMs: remainingMs.

isExpired() and remainingMs() read the clock separately, so a
millisecond rollover between them passed timeoutMs 0, which the exec
layer treats as no timeout and runs the dump unbounded. The reader now
reads the remaining budget once and uses it for both the check and the
timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@airowe

airowe commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Good catch. Fixed in cc4dd4a2f. The reader now reads deadline.remainingMs() once and uses that value for both the expiry check and timeoutMs, so a rollover can no longer hand normalizeTimeoutMs a 0 that it turns into "no timeout". A new test uses a clock that moves 1 ms on every read, with the budget left at 1–2 ms. It fails at f02edea8e ("expected 0 to be greater than 0") and passes now. check:affected --head cc4dd4a2f passed. #3937 merged it in at 376fddd56, and its gate passed too.

🤖 Addressed by Claude Code

@thymikee
thymikee merged commit a9fc9a1 into callstack:main Oct 24, 2026
11 checks passed
thymikee added a commit that referenced this pull request Oct 24, 2026
* origin/main:
  fix(android): bound the dumpsys reads behind dialog and foreground checks (#3936)
  feat(android): detect Fire OS devices as Android TV targets and document Fire TV support (#3923)
  docs(macos): document Electron/Chromium app automation through the accessibility tree (#3932)
  test(capture-kit): encode solid test PNGs unfiltered to stay inside the unit budget (#3933)
  fix: guard bounded lifecycle and claim recovery (#3911)
  fix(ios): clear a long text value wherever the caret lands (#3908)
  fix(snapshot): stop iOS 27 floating-bar containers from covering content (#3912)
  ci: cache and bound the Linux desktop dependency install (#3919)
  fix: refuse resource operations in bounded composites (#3910)
  refactor(ios): decide bridge-failure scope and warning once (#3914)
  refactor(capture-kit): encode post-gesture stabilization baseline provenance (#3915)
  perf: expect the persistent session on Android snapshot-raw (#3913)
  test(ios): hold the fill read-count pin to the route it measures (#3909)
  perf(ios): admit tap text-input probes from fresh target evidence (#3881)
  feat(perf): time every Android command against the fixture app (#3902)
  chore: mark 0.21.31 as released
thymikee added a commit that referenced this pull request Oct 24, 2026
* origin/main:
  fix(android): bound the dumpsys reads behind dialog and foreground checks (#3936)
  feat(android): detect Fire OS devices as Android TV targets and document Fire TV support (#3923)
  docs(macos): document Electron/Chromium app automation through the accessibility tree (#3932)
  test(capture-kit): encode solid test PNGs unfiltered to stay inside the unit budget (#3933)
  fix: guard bounded lifecycle and claim recovery (#3911)
  fix(ios): clear a long text value wherever the caret lands (#3908)
  fix(snapshot): stop iOS 27 floating-bar containers from covering content (#3912)
  ci: cache and bound the Linux desktop dependency install (#3919)
thymikee added a commit that referenced this pull request Oct 24, 2026
* origin/main:
  fix(android): bound the dumpsys reads behind dialog and foreground checks (#3936)
  feat(android): detect Fire OS devices as Android TV targets and document Fire TV support (#3923)
  docs(macos): document Electron/Chromium app automation through the accessibility tree (#3932)
  test(capture-kit): encode solid test PNGs unfiltered to stay inside the unit budget (#3933)
  fix: guard bounded lifecycle and claim recovery (#3911)
  fix(ios): clear a long text value wherever the caret lands (#3908)
  fix(snapshot): stop iOS 27 floating-bar containers from covering content (#3912)
  ci: cache and bound the Linux desktop dependency install (#3919)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants