Skip to content

fix(android): honor boot --timeout as the emulator boot deadline - #3059

Merged
thymikee merged 2 commits into
mainfrom
fix/android-boot-timeout-parity
Sep 29, 2026
Merged

thymikee merged 2 commits into
mainfrom
fix/android-boot-timeout-parity

Conversation

@thymikee

@thymikee thymikee commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Summary

boot --timeout <ms> now bounds Android emulator boot, matching the iOS Simulator behavior from #3008. Expiry fails with COMMAND_FAILED, details.reason: boot_timeout, and a retry hint. The emulator keeps booting.

Follow-up to #3008 (#3004).

Design: the deadline stays one platform-neutral input, EnsureReadyInput.deadlineAtMs. Android's emulator-appearance wait and sys.boot_completed wait both read it; the default 120s applies when it is absent. prepareApplicationOpen now forwards the startup deadline. The iOS-only wording in the contract, help, and docs is now neutral. BOOT_TIMEOUT_REASON moved to contracts and is shared by Apple and Android. HarmonyOS, Vega, and Linux have no boot wait, so they need no deadline. Rejected: a new deadline wrapper or per-platform field (passing the existing value is enough).

#3055 touches adb failure/provider scope only. It shares no file with this PR, and a device-offline retry runs inside a single adb call, so this deadline still bounds the outer poll loops.

10 files changed.

Validation

  • SHA a0975db; pnpm format and pnpm check:affected --run pass (533 files, 3976 tests).
  • Live, cold-booted Pixel_9_Pro_XL_API_37 (port 5584, -no-window -no-snapshot-load):
    • boot --platform android --device Pixel_9_Pro_XL_API_37 --timeout 2000 --json failed in 3.7s: COMMAND_FAILED 'Android emulator did not appear in time', reason: boot_timeout.
    • With --timeout 240000 it succeeded in 42.5s (booted: true, id: emulator-5584).
    • The emulator was killed afterwards.
  • Unresolved risk: the sys.boot_completed timeout was covered by unit tests only, not live. Deadline checks run between polls, so one adb call can overrun by up to about 1s. classifyBootFailure still does not map these messages to ANDROID_BOOT_TIMEOUT; that gap predates this PR.

Review in cubic

The startup deadline EnsureReadyInput already carries now bounds both the
emulator-appearance wait and the sys.boot_completed wait, shared as one budget.
Expiry reports reason boot_timeout, the same reason iOS uses, and leaves the
emulator booting. open --timeout reaches the same wait. HarmonyOS, Vega, and
Linux have no boot wait.
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-29 19:27 UTC

@github-actions

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.88 MB 4.88 MB +222 B
Package (unpacked) 4.88 MB 4.88 MB +222 B
Package (download) 1.46 MB 1.46 MB +163 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.4 ms 27.6 ms +0.1 ms
CLI --help 83.8 ms 83.5 ms -0.4 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

5 issues found across 10 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-android/src/readiness/runtime.test.ts">

<violation number="1" location="packages/platform-android/src/readiness/runtime.test.ts:96">
P3: Tests 2 and 3 never assert that the emulator was launched, so a regression that removes the `launch` call in `ensureEmulatorReady` leaves both tests green (the discovery wait times out on the stopped AVD) and makes `expect(terminate).not.toHaveBeenCalled()` vacuous. Return the `launch` mock from `timedHost` and assert `expect(launch).toHaveBeenCalledWith('Pixel_9', true)` (and `false` for a headful case)</violation>
</file>

<file name="src/commands/management/device.ts">

<violation number="1" location="src/commands/management/device.ts:82">
P3: The new help text is ungrammatical: `or a cold emulator its boot` drops the verb and makes the Android behavior unclear. Say that the budget gives a cold emulator time to finish booting.</violation>
</file>

<file name="website/docs/docs/commands.md">

<violation number="1" location="website/docs/docs/commands.md:77">
P2: The Android `open` timeout documentation still says only the Simulator keeps booting. Say “the Simulator or emulator keeps booting” so Android users are told that retrying is safe and expected after `boot_timeout`.</violation>
</file>

<file name="packages/platform-android/src/readiness/runtime.ts">

<violation number="1" location="packages/platform-android/src/readiness/runtime.ts:100">
P2: The deadline is checked only before each awaited probe, so a probe that finishes after expiry can still return success. Recheck the clock after each probe and before returning the refreshed emulator result so an expired `--timeout` always yields `boot_timeout`.</violation>

<violation number="2" location="packages/platform-android/src/readiness/runtime.ts:138">
P3: `bootTimeoutError` also serves physical Android devices, so this hint incorrectly tells them that an emulator is booting. Use platform-neutral wording such as `The Android device keeps booting in the background.`</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

- Android: `shutdown --platform android --device <avd-name>` stops a running emulator.
- `open [app|url] [url]` already boots/activates the selected target when needed.
- `open <app> --timeout <ms>` is a startup budget for that boot. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator keeps booting, so a retry finds it further along.
- `open <app> --timeout <ms>` is a startup budget for that boot, on iOS Simulators and Android emulators. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator keeps booting, so a retry finds it further along.

@cubic-dev-ai cubic-dev-ai Bot Sep 29, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The Android open timeout documentation still says only the Simulator keeps booting. Say “the Simulator or emulator keeps booting” so Android users are told that retrying is safe and expected after boot_timeout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At website/docs/docs/commands.md, line 77:

<comment>The Android `open` timeout documentation still says only the Simulator keeps booting. Say “the Simulator or emulator keeps booting” so Android users are told that retrying is safe and expected after `boot_timeout`.</comment>

<file context>
@@ -69,12 +69,12 @@ agent-device fold open
 - Android: `shutdown --platform android --device <avd-name>` stops a running emulator.
 - `open [app|url] [url]` already boots/activates the selected target when needed.
-- `open <app> --timeout <ms>` is a startup budget for that boot. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator keeps booting, so a retry finds it further along.
+- `open <app> --timeout <ms>` is a startup budget for that boot, on iOS Simulators and Android emulators. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator keeps booting, so a retry finds it further along.
 - `open <app> --wait <ms>` waits up to that budget for a device another session is holding instead of failing at once. The open reports each poll, then either opens the device or fails with `DEVICE_IN_USE` naming the owning session and saying the budget was spent. A wait that finds the device taken again keeps waiting for the rest of its budget, so several opens can queue on one device and none of them is refused before its budget is spent. Only session contention is waited for: a device claim held by another workspace's daemon is never retriable and returns its recovery command immediately. The wait extends the command's timeout envelope, so a long budget does not need a longer `--timeout`.
 - `open <url>` deep links are supported on Android and iOS.
</file context>
Suggested change
- `open <app> --timeout <ms>` is a startup budget for that boot, on iOS Simulators and Android emulators. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator keeps booting, so a retry finds it further along.
- `open <app> --timeout <ms>` is a startup budget for that boot, on iOS Simulators and Android emulators. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator or emulator keeps booting, so a retry finds it further along.
Fix with cubic

avdName: string,
request: ReturnType<typeof inventoryRequest>,
serial: string | undefined,
deadline: number,

@cubic-dev-ai cubic-dev-ai Bot Sep 29, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The deadline is checked only before each awaited probe, so a probe that finishes after expiry can still return success. Recheck the clock after each probe and before returning the refreshed emulator result so an expired --timeout always yields boot_timeout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/readiness/runtime.ts, line 100:

<comment>The deadline is checked only before each awaited probe, so a probe that finishes after expiry can still return success. Recheck the clock after each probe and before returning the refreshed emulator result so an expired `--timeout` always yields `boot_timeout`.</comment>

<file context>
@@ -90,29 +97,24 @@ async function waitForDiscovery(
   avdName: string,
   request: ReturnType<typeof inventoryRequest>,
   serial: string | undefined,
+  deadline: number,
   signal: AbortSignal,
 ): Promise<DeviceInfo> {
</file context>
Fix with cubic

});

test('a startup deadline also bounds waiting for a launched emulator to appear', async () => {
const { host, elapsed, terminate } = timedHost({

@cubic-dev-ai cubic-dev-ai Bot Sep 29, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Tests 2 and 3 never assert that the emulator was launched, so a regression that removes the launch call in ensureEmulatorReady leaves both tests green (the discovery wait times out on the stopped AVD) and makes expect(terminate).not.toHaveBeenCalled() vacuous. Return the launch mock from timedHost and assert expect(launch).toHaveBeenCalledWith('Pixel_9', true) (and false for a headful case)

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/readiness/runtime.test.ts, line 96:

<comment>Tests 2 and 3 never assert that the emulator was launched, so a regression that removes the `launch` call in `ensureEmulatorReady` leaves both tests green (the discovery wait times out on the stopped AVD) and makes `expect(terminate).not.toHaveBeenCalled()` vacuous. Return the `launch` mock from `timedHost` and assert `expect(launch).toHaveBeenCalledWith('Pixel_9', true)` (and `false` for a headful case)</comment>

<file context>
@@ -48,6 +48,95 @@ test('cancellation interrupts Android boot polling and terminates the emulator l
+});
+
+test('a startup deadline also bounds waiting for a launched emulator to appear', async () => {
+  const { host, elapsed, terminate } = timedHost({
+    bootCompleted: '1',
+    discover: () => [stoppedAvd()],
</file context>
Fix with cubic

headless: booleanField('Boot without showing simulator UI when supported.'),
timeoutMs: integerField(
'Startup budget in milliseconds. Bounds the Simulator boot wait, so a never-booted Simulator can finish its first-boot migration; omit for the default startup behavior.',
'Startup budget in milliseconds. Bounds the boot wait, so a never-booted Simulator can finish its first-boot migration or a cold emulator its boot; omit for the default startup behavior.',

@cubic-dev-ai cubic-dev-ai Bot Sep 29, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The new help text is ungrammatical: or a cold emulator its boot drops the verb and makes the Android behavior unclear. Say that the budget gives a cold emulator time to finish booting.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/management/device.ts, line 82:

<comment>The new help text is ungrammatical: `or a cold emulator its boot` drops the verb and makes the Android behavior unclear. Say that the budget gives a cold emulator time to finish booting.</comment>

<file context>
@@ -79,7 +79,7 @@ const bootCommandMetadata = defineFieldCommandMetadata(
     headless: booleanField('Boot without showing simulator UI when supported.'),
     timeoutMs: integerField(
-      'Startup budget in milliseconds. Bounds the Simulator boot wait, so a never-booted Simulator can finish its first-boot migration; omit for the default startup behavior.',
+      'Startup budget in milliseconds. Bounds the boot wait, so a never-booted Simulator can finish its first-boot migration or a cold emulator its boot; omit for the default startup behavior.',
       { min: 1 },
     ),
</file context>
Suggested change
'Startup budget in milliseconds. Bounds the boot wait, so a never-booted Simulator can finish its first-boot migration or a cold emulator its boot; omit for the default startup behavior.',
'Startup budget in milliseconds. Bounds the boot wait, so a never-booted Simulator can finish its first-boot migration or a cold emulator can finish booting; omit for the default startup behavior.',
Fix with cubic

return new AppError('COMMAND_FAILED', message, {
...details,
reason: BOOT_TIMEOUT_REASON,
hint: 'The emulator keeps booting in the background. Retry once it is up, or pass a larger --timeout.',

@cubic-dev-ai cubic-dev-ai Bot Sep 29, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: bootTimeoutError also serves physical Android devices, so this hint incorrectly tells them that an emulator is booting. Use platform-neutral wording such as The Android device keeps booting in the background.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/readiness/runtime.ts, line 138:

<comment>`bootTimeoutError` also serves physical Android devices, so this hint incorrectly tells them that an emulator is booting. Use platform-neutral wording such as `The Android device keeps booting in the background.`</comment>

<file context>
@@ -126,13 +128,22 @@ async function waitForBoot(
+  return new AppError('COMMAND_FAILED', message, {
+    ...details,
+    reason: BOOT_TIMEOUT_REASON,
+    hint: 'The emulator keeps booting in the background. Retry once it is up, or pass a larger --timeout.',
   });
 }
</file context>
Suggested change
hint: 'The emulator keeps booting in the background. Retry once it is up, or pass a larger --timeout.',
hint: 'The Android device keeps booting in the background. Retry once it is up, or pass a larger --timeout.',
Fix with cubic

@thymikee

Copy link
Copy Markdown
Member Author

The PR is ready at a0975db, and all 21 checks pass. Not blocking, and you can take or leave these:

(1) Uthe shared deadline also changes the default, so with no --timeout a cold emulator now gets one 120s budget for both the appearance wait and sys.boot_completed instead of 120s each, which cuts install and reinstall from about 180s to 120s (readiness/runtime.ts#L45), so please state that in the PR body or keep the old per-wait default and share only a caller-supplied deadline.

(2) Uthe reason changed from ANDROID_BOOT_TIMEOUT with timeoutMs to boot_timeout with no timeoutMs, which released versions v0.20.9 and later reported, so a note in the PR body and release notes would help.

(3) Uno test covers forwarding execution.startupDeadlineAtMs into ensureAndroidReady (lifecycle.ts#L57), so a lifecycle test that expects prepareApplicationOpen to reject with boot_timeout inside the deadline would guard the new open --timeout docs.

(4) UbootTimeoutError also serves physical devices but its hint says "The emulator keeps booting" (readiness/runtime.ts#L138), the open line in website/docs/docs/commands.md#L77 still says only "Simulator", and the help text in device.ts#L82 is missing a verb.

(5) Uthe appearance-wait tests never assert that launch was called (readiness/runtime.test.ts#L96), so returning launch from timedHost and asserting it was called with ('Pixel_9', true) would keep them from passing vacuously.

For evidence, the live boot runs (--timeout 2000 gives boot_timeout in 3.7s, and --timeout 240000 boots in 42.5s) come from the PR body, and I did not run tests or see a transcript. Only the emulator-appearance expiry was checked live, so the sys.boot_completed expiry and open --timeout on a cold AVD have not been run. I also did not check whether #3055 touches any file in this PR. No conflicts. Before merge, please record the shared 120s default and the reason rename in the PR.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 29, 2026
@thymikee
thymikee merged commit e323ddf into main Sep 29, 2026
21 checks passed
@thymikee
thymikee deleted the fix/android-boot-timeout-parity branch September 29, 2026 19:27
thymikee added a commit to okwasniewski/agent-device that referenced this pull request Sep 30, 2026
* origin/main:
  0.21.17
  feat(daemon): report the host CPU architecture in /health (callstack#3048)
  feat: add daemon policy to confine devices, commands, and device shutdown (callstack#3064)
  test(web): wait for the killed fake daemon to be reaped before asserting it is gone (callstack#3066)
  fix(ios): write the simulator clipboard from the runner (callstack#3065)
  test(daemon-client): a restart probe that fails outright near the RPC deadline reports the daemon unavailable (callstack#3058)
  fix(daemon-client): a client whose daemon lost the start race adopts the winner (callstack#3057)
  fix(android): honor boot --timeout as the emulator boot deadline (callstack#3059)
  test(ios-smoke): wait once more when the runner is still starting behind a deep link (callstack#3063)
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.

1 participant