-
-
Notifications
You must be signed in to change notification settings - Fork 316
fix(android): honor boot --timeout as the emulator boot deadline #3059
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,4 +1,7 @@ | ||||||
| import type { EnsureReadyInput } from '@agent-device/contracts/device-readiness-runtime'; | ||||||
| import { | ||||||
| BOOT_TIMEOUT_REASON, | ||||||
| type EnsureReadyInput, | ||||||
| } from '@agent-device/contracts/device-readiness-runtime'; | ||||||
| import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; | ||||||
|
|
||||||
| /** Readiness reads exactly these host ports; the lifecycle binding composes the same subset. */ | ||||||
|
|
@@ -25,7 +28,9 @@ export async function ensureAndroidReady( | |||||
| if (device.kind === 'emulator' && (device.booted !== true || !isRunningEmulator(device))) { | ||||||
| return await ensureEmulatorReady(host, device, input, signal); | ||||||
| } | ||||||
| if (device.booted !== true) await waitForBoot(host, device.id, BOOT_TIMEOUT_MS, signal); | ||||||
| if (device.booted !== true) { | ||||||
| await waitForBoot(host, device.id, bootDeadlineAtMs(host, input), signal); | ||||||
| } | ||||||
| return { ...device, booted: true }; | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -37,6 +42,7 @@ async function ensureEmulatorReady( | |||||
| ): Promise<DeviceInfo> { | ||||||
| await prepareAndroidEmulatorToolchain(host); | ||||||
| const request = inventoryRequest(input); | ||||||
| const deadlineAtMs = bootDeadlineAtMs(host, input); | ||||||
| const available = await host.deviceReadiness.androidEmulator.discover(request, signal); | ||||||
| const selected = requireAvailableAvd(available, device.name, input.serial); | ||||||
|
|
||||||
|
|
@@ -46,8 +52,9 @@ async function ensureEmulatorReady( | |||||
| : host.deviceReadiness.androidEmulator.launch(selected.name, input.headless); | ||||||
| try { | ||||||
| const discovered = | ||||||
| existing ?? (await waitForDiscovery(host, selected.name, request, input.serial, signal)); | ||||||
| await waitForBoot(host, discovered.id, BOOT_TIMEOUT_MS, signal); | ||||||
| existing ?? | ||||||
| (await waitForDiscovery(host, selected.name, request, input.serial, deadlineAtMs, signal)); | ||||||
| await waitForBoot(host, discovered.id, deadlineAtMs, signal); | ||||||
| const refreshed = (await host.deviceReadiness.androidEmulator.discover(request, signal)).find( | ||||||
| (candidate) => candidate.id === discovered.id, | ||||||
| ); | ||||||
|
|
@@ -90,29 +97,24 @@ async function waitForDiscovery( | |||||
| avdName: string, | ||||||
| request: ReturnType<typeof inventoryRequest>, | ||||||
| serial: string | undefined, | ||||||
| deadline: number, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||||||
| signal: AbortSignal, | ||||||
| ): Promise<DeviceInfo> { | ||||||
| const deadline = host.clock.now() + BOOT_TIMEOUT_MS; | ||||||
| while (host.clock.now() < deadline) { | ||||||
| const devices = await host.deviceReadiness.androidEmulator.discover(request, signal); | ||||||
| const device = findByAvdName(devices, avdName, serial); | ||||||
| if (device && isRunningEmulator(device)) return device; | ||||||
| await host.clock.sleep(POLL_MS, signal); | ||||||
| } | ||||||
| throw new AppError('COMMAND_FAILED', 'Android emulator did not appear in time', { | ||||||
| avdName, | ||||||
| serial, | ||||||
| timeoutMs: BOOT_TIMEOUT_MS, | ||||||
| }); | ||||||
| throw bootTimeoutError('Android emulator did not appear in time', { avdName, serial }); | ||||||
| } | ||||||
|
|
||||||
| async function waitForBoot( | ||||||
| host: AndroidReadinessHost, | ||||||
| serial: string, | ||||||
| timeoutMs: number, | ||||||
| deadline: number, | ||||||
| signal: AbortSignal, | ||||||
| ): Promise<void> { | ||||||
| const deadline = host.clock.now() + timeoutMs; | ||||||
| while (host.clock.now() < deadline) { | ||||||
| const result = await host.commands.run( | ||||||
| { | ||||||
|
|
@@ -126,13 +128,22 @@ async function waitForBoot( | |||||
| if (result.stdout.trim() === '1') return; | ||||||
| await host.clock.sleep(POLL_MS, signal); | ||||||
| } | ||||||
| throw new AppError('COMMAND_FAILED', 'Android device failed to finish booting', { | ||||||
| serial, | ||||||
| timeoutMs, | ||||||
| reason: 'ANDROID_BOOT_TIMEOUT', | ||||||
| throw bootTimeoutError('Android device failed to finish booting', { serial }); | ||||||
| } | ||||||
|
|
||||||
| function bootTimeoutError(message: string, details: Record<string, unknown>): AppError { | ||||||
| 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Prompt for AI agents
Suggested change
|
||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| /** The caller's `--timeout` deadline when stated, else the default boot wait from now. */ | ||||||
| function bootDeadlineAtMs(host: AndroidReadinessHost, input: EnsureReadyInput): number { | ||||||
| return input.deadlineAtMs ?? host.clock.now() + BOOT_TIMEOUT_MS; | ||||||
| } | ||||||
|
|
||||||
| function inventoryRequest(input: EnsureReadyInput) { | ||||||
| return { | ||||||
| platform: 'android' as const, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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.', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The new help text is ungrammatical: Prompt for AI agents
Suggested change
|
||||||
| { min: 1 }, | ||||||
| ), | ||||||
| }, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -69,12 +69,12 @@ agent-device fold open | |||||
| - `--platform apple` is an alias for the Apple automation backend (`ios`, `tvOS`, `macOS` selection). | ||||||
| - Use `--target mobile|tv|desktop` with `--platform` (required) to select phone/tablet vs TV-class vs desktop-class targets. | ||||||
| - `boot` is mainly needed when starting a new session and `open` fails because no booted simulator/emulator is available. | ||||||
| - `boot --timeout <ms>` is a startup budget for the Simulator boot, same as `open`'s. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the command's 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. | ||||||
| - `boot --timeout <ms>` is a startup budget for the Simulator or Android emulator boot, same as `open`'s. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the command's 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. | ||||||
| - Android: `boot --platform android --device <avd-name>` launches that emulator in GUI mode when needed. | ||||||
| - Android: add `--headless` to launch without opening a GUI window. | ||||||
| - 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The Android Prompt for AI agents
Suggested change
|
||||||
| - `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. | ||||||
| - `open <app> <url>` opens a deep link on iOS. | ||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
launchcall inensureEmulatorReadyleaves both tests green (the discovery wait times out on the stopped AVD) and makesexpect(terminate).not.toHaveBeenCalled()vacuous. Return thelaunchmock fromtimedHostand assertexpect(launch).toHaveBeenCalledWith('Pixel_9', true)(andfalsefor a headful case)Prompt for AI agents