fix(android): honor boot --timeout as the emulator boot deadline - #3059
Conversation
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.
|
Size Report
Startup median (7 runs, lower is better):
|
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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>
| - `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. |
| avdName: string, | ||
| request: ReturnType<typeof inventoryRequest>, | ||
| serial: string | undefined, | ||
| deadline: number, |
There was a problem hiding this comment.
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>
| }); | ||
|
|
||
| test('a startup deadline also bounds waiting for a launched emulator to appear', async () => { | ||
| const { host, elapsed, terminate } = timedHost({ |
There was a problem hiding this comment.
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>
| 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.', |
There was a problem hiding this comment.
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>
| '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.', |
| 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.', |
There was a problem hiding this comment.
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>
| 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.', |
|
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. |
* 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)
Summary
boot --timeout <ms>now bounds Android emulator boot, matching the iOS Simulator behavior from #3008. Expiry fails withCOMMAND_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 andsys.boot_completedwait both read it; the default 120s applies when it is absent.prepareApplicationOpennow forwards the startup deadline. The iOS-only wording in the contract, help, and docs is now neutral.BOOT_TIMEOUT_REASONmoved 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
pnpm formatandpnpm check:affected --runpass (533 files, 3976 tests).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 --jsonfailed in 3.7s:COMMAND_FAILED 'Android emulator did not appear in time',reason: boot_timeout.--timeout 240000it succeeded in 42.5s (booted: true,id: emulator-5584).sys.boot_completedtimeout was covered by unit tests only, not live. Deadline checks run between polls, so one adb call can overrun by up to about 1s.classifyBootFailurestill does not map these messages toANDROID_BOOT_TIMEOUT; that gap predates this PR.