Skip to content

fix(android): return from an app open only after the launched app is readable - #2895

Merged
thymikee merged 4 commits into
mainfrom
fix/android-open-mount-readiness-1571
Sep 24, 2026
Merged

thymikee merged 4 commits into
mainfrom
fix/android-open-mount-readiness-1571

Conversation

@thymikee

@thymikee thymikee commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #1571 (iOS half: #2838). am start -W returns at the first frame, while a React Native app may still show an empty root. So the first snapshot, wait or replay step after a cold open --relaunch read an app with no content.

A local Android app open now takes one transient snapshot of the launched app with { settleBy: now + 6 s }. It re-captures up to 3 times, but starts none after settleBy. The window never cancels helper work; only the caller's signal does.

A transient capture:

  • keeps a helper session it found and stops one it started;
  • never installs the helper; a missing helper fails fast without a reset;
  • never retires the helper after a content verdict.

open_timing.postOpenObservation is a contract union shared with Apple:

  • observable
  • unobservable
  • probe-failed, with a typed failure
  • app-unidentified
  • unset for URL opens

The open always succeeds, and cancellation still rejects. Limrun and WebDriver are unchanged.

Validation

At 78214d2ba8, rebased on dc9ab863ad:

  • Named mutations each fail a snapshotAndroid test. Examples: the window aborts the capture; a transient read installs, retires or resets the helper.
  • Live, own AVD, 20 cold opens per row:
    • Idle: 0/20 unmounted first captures (pre-fix 1–3/20).
    • 16 emulator CPU burners: 3 first snapshots re-captured after an observable open, and all recovered.
    • No helper retirement or start failure.
    • New device: probe-failed in 0.5 s.
  • pnpm check:affected --run: exit 0, 3046 tests.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.81 MB 4.81 MB +216 B
Package (unpacked) 4.81 MB 4.81 MB +216 B
Package (download) 1.44 MB 1.44 MB -34 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.8 ms 27.7 ms -0.2 ms
CLI --help 82.1 ms 80.2 ms -1.9 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 0693754. The code looks good. The change makes local Android open return only after the launched app's snapshot content is readable, which fixes the class of failures where callers acted on an app that looked open but wasn't yet.

Smoke Tests is still running with no failure yet, and this diff sits right on the route it exercises: every local Android open now runs a helper snapshot before returning, and smoke opens apps. If it fails, treat the failure as related to this PR until shown otherwise.

Not blocking: does the CHANGELOG entry need to spell out the worst-case cost for a content-poor app (up to 3 capture attempts, then a helper runtime reset, plus a first-use helper install) or state that the open budget already covers it, and could unreadableLaunchContentError in packages/platform-android/src/lifecycle.test.ts#L117 be built from the snapshot package's own rejection helper (or gain a provider-scenario case with content-poor helper output) so the unobservable/cancel tests exercise the real re-capture route — either is optional and can be taken or left.

I did not reproduce the live Android runs or the claimed mutations myself, so I'm trusting the PR body's counts and reading the tests and lifecycle.ts/snapshot.ts/snapshot-helper-runtime.ts by inspection rather than execution; I also did not verify that the open command's timeout budget covers the worst case of three 5 s captures plus a 30 s helper install.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thermo-nuclear structural pass. The idea and the reuse are right: reusing the capture's own content verdict + bounded re-capture (rather than reimplementing a separate readiness wait) is the correct call, and the observeAndroidLaunch doc comment is honest about intent. But the wrapper around that one call collapses distinct failure modes into a single benign-looking value — the exact silent-fallback class AGENTS.md says to turn into an explicit boundary — and one of those modes means "our probe is broken", not "the app has no content." One blocker + one follow-on inline.

Two more, non-inline:

  • Name the cross-platform union. This PR is the moment postOpenObservation became cross-platform, but the value set is now spelled three ways: inline here, as LaunchObservation in platform-apple/src/snapshot-observability.ts, and re-derived as NonNullable<OpenApplicationOutcome['timing']['postOpenObservation']> in the android lifecycle. Two hand-written copies of a wire value drift. Export PostOpenObservation from contracts/application-lifecycle-runtime.ts, state what each value means for every owner (the new doc "see each platform owner" delegates a contract field's meaning to platform source), and give the "probe could not run" outcome from finding #1 its typed home here — not in error text.
  • The probe is inline, unbounded, and not treated as optional. The iOS half put it behind a named, injected LaunchObservationPort with its own bounded window (OBSERVATION_POLL_MS, <=5s, never extended); the Android call inlines it with no seam and inherits the whole request signal, so a busy app that blocks accessibility can now time out the open itself — the budget-burnage this PR set out to remove, just relocated. On top, the unobservable route runs retireAndroidSnapshotHelperAfterContentFailure -> resetRuntime + a device-side force-stop, so open returns success having force-stopped the helper the very next read needs, and the tree that proved readability is thrown away. Scope it with its own bounded child signal, reuse it behind a port, and don't discard the evidence you just captured.

try {
await interactor.snapshot({ appBundleId, signal: binding.signal });
return 'observable';
} catch {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bare catch {} (no error binding) files three very different outcomes under one value, unobservable: (a) a genuine content verdict — the 3-attempt re-capture still saw a content-poor window; (b) the helper APK missing / unbuilt (androidSnapshotHelperUnavailableError); (c) a mechanism failure — adb died / helper install rejected / accessibility-timeout (androidSnapshotHelperCaptureError) — or a programming error inside the capture path. After signal.throwIfAborted(), everything in (b) / (c) / bugs becomes unobservable, i.e. "the app has no content yet", and open reports success. The repo already owns the discriminator for exactly this split — isUnreadableCaptureContentError from @agent-device/contracts/android-snapshot-quality, whose documented invariant is the opposite ("helper timeouts, adb failures, and missing artifacts remain fail-fast"), and which wait / replay honor but this open ignores (it isn't even imported here). Can you make the boundary explicit — catch (e) { binding.signal.throwIfAborted(); if (isUnreadableCaptureContentError(e)) return 'unobservable'; throw e; } — or return a distinct typed value carrying why the probe couldn't run? Right now a caller can't tell "app unreadable" from "probe broken" except by opening the daemon log with --debug. And add a test where the capture rejects with a mechanism failure (no content reason, or androidCaptureFailureReason: 'accessibility-timeout') — today it would assert the same 'unobservable' as a real content verdict, which is why none exists.

input: OpenApplicationInput,
appBundleId: string | undefined,
): Promise<NonNullable<OpenApplicationOutcome['timing']['postOpenObservation']>> {
if (!appBundleId) return 'not-eligible';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not-eligible now means two things, and one of them silently cancels the whole guarantee this PR adds: a URL / deep-link open (correct), and whenever inferOpenedAppBundleId yields nothing — and that port is explicitly best-effort (platform-runtime-android-application-tools.ts catches and falls back to currentAppBundleId), so a failure to read the foreground package reports the same benign value as "this was never an app". It also diverges from the iOS half already merged: settleAppleOpen leaves postOpenObservation unset when there is no appBundleId and reserves not-eligible for "the device has no bridge to ask". Same shared enum, same missing-identity situation -> undefined on iOS but not-eligible on Android. Can you split "nothing to observe" (URL target -> leave unset, matching iOS) from "the launched package could not be identified" (a real coverage loss that deserves its own value or typed reason), and pin the distinction in a test rather than in prose?

@thymikee

Copy link
Copy Markdown
Member Author

Addressed at 31cd88bbdc:

  • Bare catch. The port now uses isUnreadableCaptureContentError. Only a content verdict, a system surface, or the expired window gives unobservable. Any other failure gives probe-failed, with a typed postOpenObservationFailure (code, reason). The open does not fail on it. On a new device the helper is not installed yet, so failing the open there would break the first open. There are tests for accessibility-timeout and for a helper that is not current.
  • Retire on a verdict. A borrowed (transient) capture no longer retires or resets the helper. The test goes through snapshotAndroid.
  • Budget and seam. AndroidLaunchObservationPort is injected from runtime.ts. It has a fixed 6 s window that is separate from the caller's cancellation. It installs no helper (current-only policy), so an upgrade install cannot land inside open.
  • Helper scope. The capture borrows a running helper session and stops only a session it started. An open-only session therefore leaves nothing holding UiAutomation. The next read pays the helper start, as it did before this PR.
  • not-eligible. URL and deep-link opens leave the field unset. An app open whose package cannot be read reports app-unidentified. Both have tests.
  • Union. PostOpenObservation is exported from the contract with each value documented, and Apple uses it.
  • Test double. Built with the production androidHelperContentUnavailableError.
  • Geometry test. Stale geometry is keyed to the first capture after am start. The test fails when the tap refresh is removed. Each Android world now resets the remembered helper install, so capture counts do not depend on test order.
  • Reusing the probe tree: not done. It would need a lifecycle-to-daemon channel for snapshot state, refs generation and freshness. That is not a small change.

@thymikee
thymikee force-pushed the fix/android-open-mount-readiness-1571 branch 2 times, most recently from aabf4e4 to f11a928 Compare September 24, 2026 14:04
@thymikee

Copy link
Copy Markdown
Member Author

Delta review addressed at acb373189b (on top of f11a928511):

  • Window reached the helper as an abort. The capture now runs under the caller's signal only. The window is now transient.settleBy, a deadline the re-capture loop checks before each new attempt. It never cancels a helper start, capture or teardown. snapshotAndroid tests let the window pass during a delayed helper start and during a delayed warm capture. The helper stays alive in both, with no force-stop. Both tests fail if the window is turned back into an abort.
  • Simplified. The port, the borrow scope and the interactor install mapping are gone. observeAndroidLaunch is a plain function. transient implies both no install (current-only) and releasing only a session the capture started.
  • Reuse-only vs start-and-release. Across 80 earlier cold opens, the helper was live at open 0 times. Reuse-only would never observe anything, so I kept start-and-release.
  • Notes. The failure reason now uses readAndroidCaptureFailureReason. The not-installed error in tests comes from ensureAndroidSnapshotHelper({ installPolicy: 'current-only' }).
  • Live. 0/20 unmounted first captures idle, with 6 CPU burners and with 16. There is no retirement_pending, session_start_failed or session fallback on any open or post-open snapshot.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at acb3731 and the code looks correct: the transient settleBy design replaces the old injected observation port, and the fix logic checks out against the tests.

Android, Linux, and macOS Smoke passed at acb3731. The Coverage failure traces to this PR: scripts/__tests__/test-file-size-ratchet.test.ts reports that packages/platform-android/src/__tests__/snapshot.test.ts grew from 1432 to 1604 lines, past the size tripwire, and the delta commits added those lines with the new transient-capture tests. iOS Smoke is pending, but the only Apple-side change in the delta is a type-only Extract in snapshot-observability.ts, so it shouldn't touch that job's path.

Move the six new transient-capture tests into a separate test file named after the snapshot module they cover, so snapshot.test.ts drops back under the tripwire, then re-run Coverage.

A few limits on what this covers: the 20-cold-open live rows referenced in the PR are dated to 31cd88b, before acb3731 changed how the window ends a capture, so they don't speak to the current behavior. The only live evidence at head is the passing Android Smoke job, and I didn't check whether it asserts postOpenObservation. I didn't run the tests or the 17 claimed mutations myself; the regression reasoning here comes from reading the tests against the pre-delta code. I also didn't check that the open command's timeout covers the worst case of a helper start plus one capture past the 6 s window.

Not blocking: the PR body still describes the removed injected port and the old "bounded by its own 6 s window" wording (worth updating to the transient settleBy design and naming the validated commit), one line in the CHANGELOG.md:45 entry runs longer than the rest of the entry's wrap width, and packages/platform-android/src/snapshot.ts:597 routes a current-only refusal (new device without the helper) through rejectAndroidHelperCaptureFailure, which logs an error-level android_snapshot_helper_failed diagnostic and force-stops the helper runtime on every new device's first open — could that refusal be routed out before the failure handler runs, since it can all be taken or left.

@thymikee

Copy link
Copy Markdown
Member Author

The review of acb3731 is still clean, but the branch now conflicts with main. I removed ready-for-human until the conflict is resolved. After the rebase, please run the changed Android open path again so the evidence covers the rebased head.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
@thymikee
thymikee force-pushed the fix/android-open-mount-readiness-1571 branch from acb3731 to a0c4c84 Compare September 24, 2026 16:32
…readable

am start -W returns when the activity draws its first frame, which can be a
splash or an empty root while a React Native app still mounts. The first
capture after a cold open --relaunch then saw a content-poor tree and spent
its own budget on re-captures. The open now captures the launched app through
the interactor snapshot path, whose content verdict and bounded re-capture
decide readiness, and reports postOpenObservation. An app that stays
unreadable, or a failed capture, still opens as unobservable.

Refs #1571 (iOS half: #2838).
The open's launch capture now runs behind an injected launch observation
port with a fixed 6 s window of its own, separate from the caller's
cancellation. Only a content verdict, a system surface over the app, or
the window running out reads as unobservable. Any other capture failure
is a typed probe-failed result that the open reports and survives.

The capture is transient: it borrows a running helper session and stops
only a session it started, installs no helper, and does not retire the
helper after a content verdict. A URL open reports no observation, and
an app open whose package cannot be read reports app-unidentified.
PostOpenObservation is one documented union in the lifecycle contract,
shared with the Apple owner.

Refs #1571
…per work

The open's 6 s window reached the snapshot helper as an abort, so a
window that closed during a cold helper start or a borrowed capture tore
the helper down and left the next read to recover it. The window is now a
settle deadline on the transient capture: the content re-capture loop
starts no attempt after it, while helper start, capture and teardown keep
their own budgets and only the caller's signal cancels them.

The transient read replaces the injected port, the borrow session scope
and the interactor-side install mapping: the capture itself keeps a session
it found, releases one it started, installs no helper and does not retire
it after a content verdict.

Refs #1571
…covery

A transient capture on a device without the current helper refused at the
install check, and that refusal went through the capture failure handler,
which logged an error and force-stopped the helper runtime on every new
device's first open. The refusal now reaches the caller directly.

The transient-capture tests move to their own file, so snapshot.test.ts
stays under the test-file size ratchet.

Refs #1571
@thymikee
thymikee force-pushed the fix/android-open-mount-readiness-1571 branch from a0c4c84 to 78214d2 Compare September 24, 2026 17:17
@thymikee

Copy link
Copy Markdown
Member Author

Rebased on dc9ab863ad (after #2922 removed CHANGELOG.md). Head is 78214d2ba8.

  • Coverage. The only failure was the test-file size ratchet. The six transient-capture tests now live in __tests__/snapshot-transient-capture.test.ts. snapshot.test.ts is 1421 lines, under the 1432 base. The shared XML fixture moved to snapshot-helper-session.fixtures.ts.
  • iOS Smoke. Unrelated. The one failure is the Swift runner test testAlertActivationDoesNotWaitOutANotificationBanner (ALERT_DEADLINE_EXCEEDED), and this PR changes no Swift. The rebased push reruns it.
  • Current-only refusal. Done. A missing helper now reaches the caller directly, with no android_snapshot_helper_failed error and no runtime force-stop. isAndroidSnapshotHelperNotCurrentError sits beside its producer, and the test checks that there is no install, no instrument and no reset.
  • CHANGELOG. Dropped with chore: remove CHANGELOG.md in favor of generated release notes #2922. The user-facing wording is in the PR body.
  • Live on the rebased head (own AVD, 20 cold opens per row):
    • Idle: 20/20 observable, 0 unmounted first captures.
    • 16 in-emulator CPU burners: 19/20 observable. 3 first snapshots still re-captured after an observable open, and all recovered on attempt 2 or 3.
    • No helper retirement or start failure anywhere.
    • New device: probe-failed in 0.5 s.
  • Gate. pnpm check:affected --run exit 0 (3046 tests). An earlier run at host load around 500 hit two 5 s timeouts, in daemon-entrypoint and request-router-replay-scope. Both pass alone on this head and on origin/main.

@thymikee

Copy link
Copy Markdown
Member Author

This is ready to merge at 78214d2. The acb3731 conflict is resolved, and I found no blocking issues in this pass. Not blocking: in packages/platform-android/src/snapshot-helper-install.ts#L32, the new constant and predicate landed between the @internal Test isolation hook JSDoc and resetAndroidSnapshotHelperInstallCache, so the doc now describes ANDROID_SNAPSHOT_HELPER_NOT_CURRENT and the reset hook lost its @internal marker — worth moving the constant and isAndroidSnapshotHelperNotCurrentError above the JSDoc block or below the reset function, but take it or leave it.

The live Android evidence at 78214d2 (idle, 16-burner, and new-device rows) is author-reported with no run artifact, and I could not reproduce it. I also could not confirm the new-device 0.5s row went through the new rethrow rather than the old force-stop route, though the timing fits the new path. I did not run the unit suite or the named mutations, so the regression judgement here comes from reading the old handler route, not from execution. I did not check whether Android Smoke asserts postOpenObservation, so a green Android Smoke run would show no regression but would not by itself prove the readiness route is exercised. I also did not re-verify that the open command's timeout covers the worst case of a helper start plus one capture after the 6s window; that code is unchanged since the acb3731 review.

Smoke Tests, Coverage, and Repo Guards at 78214d2 are still pending. Android Smoke opens apps and now exercises the transient readiness capture on every local open, so a failure there should be treated as related until shown otherwise; Coverage runs the moved and new snapshot-transient-capture tests plus the size ratchet this change addresses, and Repo Guards covers the new export and file moves. No failure has been captured yet. Once those three runs finish green, please restore ready-for-human.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
@thymikee
thymikee merged commit 4260aa7 into main Sep 24, 2026
18 checks passed
@thymikee
thymikee deleted the fix/android-open-mount-readiness-1571 branch September 24, 2026 17:41
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.

App-mount race on fast/cold opens: first capture can see an unreadable or empty tree (cross-platform)

1 participant