diff --git a/packages/command-registry/src/flag-definitions-workflow.ts b/packages/command-registry/src/flag-definitions-workflow.ts index 1ab0eb7a47..9b6a670ca7 100644 --- a/packages/command-registry/src/flag-definitions-workflow.ts +++ b/packages/command-registry/src/flag-definitions-workflow.ts @@ -85,7 +85,7 @@ export const WORKFLOW_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ min: 1, usageLabel: '--timeout ', usageDescription: - 'Open/Prepare: startup budget covering the Simulator boot (and runner preparation for prepare). Replay/Snapshot/Test: maximum wall-clock time for the command or attempt. With --settle: the settle-wait deadline (default 10s)', + 'Boot/Open/Prepare: startup budget covering the Simulator boot (and runner preparation for prepare). Replay/Snapshot/Test: maximum wall-clock time for the command or attempt. With --settle: the settle-wait deadline (default 10s)', projectConfig: true, recorded: false, }, diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 7f453e6224..c99bca037f 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -714,7 +714,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'state', }, platformExecution: { kind: 'device-runtime', uses: deviceBootRuntimeUses }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + // --timeout is a startup budget: it reaches the Simulator boot wait, same as open/prepare + // (#2325). A first boot can outlast the fixed 90s envelope (#3004). + timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, budget: { source: 'flag', envelope: 'margin' } }, batchable: true, }, { diff --git a/packages/contracts/src/client-device-view.ts b/packages/contracts/src/client-device-view.ts index 9f5d8b4f60..79bd486bf7 100644 --- a/packages/contracts/src/client-device-view.ts +++ b/packages/contracts/src/client-device-view.ts @@ -101,6 +101,8 @@ export type StartupPerfSample = { export type DeviceBootOptions = DeviceCommandBaseOptions & { headless?: boolean; + /** Startup budget in milliseconds: bounds the Simulator boot wait on a cold device. */ + timeoutMs?: number; }; export type DeviceShutdownOptions = DeviceCommandBaseOptions; diff --git a/packages/contracts/src/device-readiness-runtime.ts b/packages/contracts/src/device-readiness-runtime.ts index 4841e182a6..6c5672741a 100644 --- a/packages/contracts/src/device-readiness-runtime.ts +++ b/packages/contracts/src/device-readiness-runtime.ts @@ -4,6 +4,11 @@ import type { DeviceInventoryRequest } from './device-inventory.ts'; export type EnsureReadyInput = Readonly<{ serial?: string; androidSerialAllowlist?: readonly string[]; + /** + * Absolute deadline (epoch ms), from `boot --timeout`, already validated finite and positive. + * Bounds a cold Simulator boot wait; the Apple runtime is the only current consumer. + */ + deadlineAtMs?: number; }>; export type DeviceReadinessRuntimeOperations = Readonly<{ diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index fdee94259d..5499829530 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -522,6 +522,54 @@ test('readiness and boot keep the Apple automation helper warm inside the platfo expect(keepHot).toHaveBeenNthCalledWith(3, device); }); +test('bootTarget forwards a --timeout budget as the Simulator boot deadline (#3004)', async () => { + const host = platformRuntimeHostFixture(); + let state = 'Shutdown'; + const calls: Array<{ args: readonly string[]; timeoutMs?: number }> = []; + const runtime = createApplePlatformRuntime({ + ...host, + appleTools: { + ...host.appleTools, + run: vi.fn(async (request) => { + calls.push({ args: request.args, timeoutMs: request.timeoutMs }); + if (request.args.includes('list')) { + return { + stdout: JSON.stringify({ devices: { ios: [{ udid: 'apple-fact', state }] } }), + stderr: '', + exitCode: 0, + }; + } + if (request.args.includes('boot')) state = 'Booted'; + return { stdout: '', stderr: '', exitCode: 0 }; + }), + }, + }); + const device = appleDevice({ booted: false }); + const binding = await runtime.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + const deadlineAtMs = Date.now() + 45_000; + await binding.operations.bootTarget?.({ deadlineAtMs }); + + // The startup budget reaches the boot wait, same as open/prepare (#2325): every simctl call the + // wait issues runs under the caller's --timeout budget until the absolute deadline, not a fixed + // default. Both `boot` and `bootstatus` derive their timeout from the same deadline, so both + // must sit within a tight window of the 45s budget - a fixed default like 10s or 30s would fail. + const bootCall = calls.find((call) => call.args.includes('boot')); + const bootstatusCall = calls.find((call) => call.args.includes('bootstatus')); + expect(bootCall?.timeoutMs).toBeGreaterThan(44_900); + expect(bootCall?.timeoutMs).toBeLessThanOrEqual(45_000); + expect(bootstatusCall?.timeoutMs).toBeGreaterThan(44_900); + expect(bootstatusCall?.timeoutMs).toBeLessThanOrEqual(45_000); +}); + test('macOS readiness is a no-op while boot remains unavailable', async () => { const host = platformRuntimeHostFixture(); const ensureConnected = vi.fn(host.deviceReadiness.applePhysical.ensureConnected); diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 3907f76689..da0a4f4b6c 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -60,6 +60,7 @@ import { appleScreenRecordingFacts, createAppleScreenRecordingOperations, } from './recording/runtime.ts'; +import type { EnsureReadyInput } from '@agent-device/contracts/device-readiness-runtime'; import { ensureAppleReady } from './readiness/runtime.ts'; import { bindAppleApplicationLifecycle } from './lifecycle.ts'; import { @@ -475,8 +476,10 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR await ensureAppleReady(host, request.device, request.scope.signal), })), ...whenAdmitted(facts.operations.bootTarget, () => ({ - bootTarget: async () => - await ensureAppleReady(host, request.device, request.scope.signal), + bootTarget: async (input: EnsureReadyInput) => + await ensureAppleReady(host, request.device, request.scope.signal, { + deadlineAtMs: input.deadlineAtMs, + }), })), ...whenAdmitted(facts.operations.listApps, () => ({ listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => { diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index d8d444d278..03f49a5b83 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -113,9 +113,9 @@ test('budget sources deviating from the default are bounded, reviewed sets', () } // --timeout bounds the request envelope for these commands only. assert.deepEqual(flagBoundBudget.sort(), ['replay', 'snapshot']); - // --timeout is a daemon-side startup budget on these commands (#2324); the + // --timeout is a daemon-side startup budget on these commands (#2324, #3004); the // envelope keeps a margin over it so the daemon's own timeout wins the race. - assert.deepEqual(flagMarginBudget.sort(), ['open', 'prepare']); + assert.deepEqual(flagMarginBudget.sort(), ['boot', 'open', 'prepare']); // --timeout bounds the --settle wait on these commands (#1101); like wait's // positional budget it only ever widens the envelope, never shrinks it. assert.deepEqual(flagWidenBudget.sort(), settleObservationCommandNames()); diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts index 74112ff8c4..8300b54301 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -5,6 +5,7 @@ import { DEVICE_KINDS, DEVICE_TARGETS, PUBLIC_PLATFORMS } from '@agent-device/ke import { booleanField, booleanSchema, + integerField, enumSchema, looseObjectSchema, numberSchema, @@ -77,6 +78,10 @@ const bootCommandMetadata = defineFieldCommandMetadata( 'Boot or prepare the selected device or simulator so later commands can target it. The device is chosen through the device-selection inputs, not by naming it here.', { 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.', + { min: 1 }, + ), }, ); @@ -87,7 +92,7 @@ const shutdownCommandMetadata = defineFieldCommandMetadata( ); const bootCliSchema = { - allowedFlags: ['headless'], + allowedFlags: ['headless', 'timeoutMs'], } as const satisfies CommandSchemaOverride; const devicesCliSchema = {} as const satisfies CommandSchemaOverride; @@ -101,6 +106,7 @@ const commonCliReader: CliReader = (_positionals, flags) => commonInputFromFlags const bootCliReader: CliReader = (_positionals, flags) => ({ ...commonInputFromFlags(flags), headless: flags.headless, + timeoutMs: flags.timeoutMs, }); const devicesDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.devices); diff --git a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts index 54dfb1146d..a325dedd7c 100644 --- a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts @@ -95,6 +95,70 @@ test('boot prefers explicit device selector over active session device', async ( } }); +test('boot --timeout forwards a startup deadline to bootTarget (#3004)', async () => { + const sessionStore = makeSessionStore(); + const selectedDevice: SessionState['device'] = { + platform: 'apple', + id: 'sim-timeout', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: false, + }; + mockResolveTargetDevice.mockResolvedValue(selectedDevice); + + const beforeMs = Date.now(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro', timeoutMs: 300_000 }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + const afterMs = Date.now(); + + expect(response?.ok, JSON.stringify(response)).toBe(true); + expect(mockEnsureReadyRuntime).toHaveBeenCalledOnce(); + const deadlineAtMs = mockEnsureReadyRuntime.mock.calls[0]?.[0]?.deadlineAtMs; + expect(deadlineAtMs).toBeGreaterThanOrEqual(beforeMs + 300_000); + expect(deadlineAtMs).toBeLessThanOrEqual(afterMs + 300_000); +}); + +test('boot without --timeout leaves the startup deadline unset', async () => { + const sessionStore = makeSessionStore(); + const selectedDevice: SessionState['device'] = { + platform: 'apple', + id: 'sim-no-timeout', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: false, + }; + mockResolveTargetDevice.mockResolvedValue(selectedDevice); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok, JSON.stringify(response)).toBe(true); + expect(mockEnsureReadyRuntime).toHaveBeenCalledOnce(); + expect(mockEnsureReadyRuntime.mock.calls[0]?.[0]?.deadlineAtMs).toBeUndefined(); +}); + test('boot --headless admits a stopped Android emulator through facts and binds once', async () => { const sessionStore = makeSessionStore(); const placeholder: SessionState['device'] = { diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 1b095e3bb5..d6581266b0 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -15,6 +15,7 @@ import { } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { SessionStore } from '../session-store.ts'; +import { startupDeadlineAtMs } from '../startup-deadline.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { AppleApplicationState } from '@agent-device/kernel/snapshot'; import { resolveAndroidSerialAllowlist } from '@agent-device/kernel/device-isolation'; @@ -302,7 +303,11 @@ export async function handleSessionStateCommands(params: { }); if (admitted.type === 'response') return admitted.response; - const input = { serial: flags.serial, androidSerialAllowlist }; + const input = { + serial: flags.serial, + androidSerialAllowlist, + deadlineAtMs: startupDeadlineAtMs(flags.timeoutMs), + }; if (plan.kind === 'boot-target-headless') { device = await (await admitted.bind(device, plan.use)).operations.bootTargetHeadless(input); } else { diff --git a/src/daemon/session-lifecycle/internal/session-open-prepare.ts b/src/daemon/session-lifecycle/internal/session-open-prepare.ts index 27f4e5e5dd..59c45ec654 100644 --- a/src/daemon/session-lifecycle/internal/session-open-prepare.ts +++ b/src/daemon/session-lifecycle/internal/session-open-prepare.ts @@ -4,6 +4,7 @@ import { openApplicationRuntimeUse } from '@agent-device/contracts/application-l import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; +import { startupDeadlineAtMs } from '../../startup-deadline.ts'; import type { SessionRuntimeHints, SessionState } from '../../session-state.ts'; import { SessionStore } from '../../session-store.ts'; import { @@ -229,8 +230,5 @@ async function resolvePreparedOpenIdentity(params: { /** `open --timeout` is a startup budget; it becomes the absolute deadline the boot wait honors. */ function openStartupDeadlineAtMs(req: DaemonRequest): number | undefined { - const timeoutMs = req.flags?.timeoutMs; - return typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0 - ? Date.now() + timeoutMs - : undefined; + return startupDeadlineAtMs(req.flags?.timeoutMs); } diff --git a/src/daemon/startup-deadline.ts b/src/daemon/startup-deadline.ts new file mode 100644 index 0000000000..7718a17127 --- /dev/null +++ b/src/daemon/startup-deadline.ts @@ -0,0 +1,10 @@ +/** + * Converts a `--timeout ` startup budget into the absolute deadline a readiness wait honors. + * A missing, non-finite, or non-positive budget yields `undefined`, so callers fall back to their + * own default wait instead of silently disabling it. + */ +export function startupDeadlineAtMs(timeoutMs: unknown): number | undefined { + return typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? Date.now() + timeoutMs + : undefined; +} diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 857bf831ea..125f760998 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -69,11 +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 ` 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. - Android: `boot --platform android --device ` launches that emulator in GUI mode when needed. - Android: add `--headless` to launch without opening a GUI window. - Android: `shutdown --platform android --device ` stops a running emulator. - `open [app|url] [url]` already boots/activates the selected target when needed. -- `open --timeout ` 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 boot wait is capped at 120 seconds. 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 --timeout ` 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 --wait ` 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 ` deep links are supported on Android and iOS. - `open ` opens a deep link on iOS.