diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 86edf1c15f..e872b3284a 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -996,7 +996,8 @@ export const RAW_COMMAND_DESCRIPTORS = [ allowSessionlessDefaultDevice: allowAnyDeviceSessionless, saveScriptFlagOwner: true, }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + // --timeout is a startup budget: it reaches the Simulator boot wait (#2324). + timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, budget: { source: 'flag', envelope: 'margin' } }, batchable: true, platformExecution: { kind: 'device-runtime', uses: openApplicationRuntimePlanUses }, }, @@ -1008,9 +1009,10 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: false, daemon: { route: 'session', refFrameEffect: 'preserve' }, - // Runner warm-up builds are the longest fixed envelope; --timeout overrides. + // Runner warm-up builds are the longest fixed envelope; --timeout is the + // daemon-side boot + runner budget, so the envelope keeps a margin over it. timeoutPolicy: { - budget: { source: 'flag' }, + budget: { source: 'flag', envelope: 'margin' }, envelopeMs: PREPARE_REQUEST_TIMEOUT_MS, onTimeout: 'reset-daemon', }, diff --git a/packages/command-registry/src/timeout-policy.ts b/packages/command-registry/src/timeout-policy.ts index e48c3e751c..b199b59a8c 100644 --- a/packages/command-registry/src/timeout-policy.ts +++ b/packages/command-registry/src/timeout-policy.ts @@ -80,6 +80,11 @@ function resolveFlagBudgetTimeoutMs( if (policy.budget.envelope === 'widen') { return resolveWideningFlagBudget(policy, policy.budget, flags); } + if (policy.budget.envelope === 'margin') { + return typeof flags?.timeoutMs === 'number' + ? widenToUserBudget(policy, flags.timeoutMs) + : policy.envelopeMs; + } return typeof flags?.timeoutMs === 'number' ? flags.timeoutMs : policy.envelopeMs; } diff --git a/packages/command-registry/src/types.ts b/packages/command-registry/src/types.ts index 9424d9e9c9..8e348986cb 100644 --- a/packages/command-registry/src/types.ts +++ b/packages/command-registry/src/types.ts @@ -36,9 +36,14 @@ export type DaemonCommandTraits = Omit; * ever EXTENDS the envelope to envelopeMs + budget + * margin (interaction --settle semantics, #1101: the * flag bounds a post-action wait, so the request must - * also cover selector/action overhead). `defaultBudgetMs` - * is used when the feature flag is present but the - * numeric timeout flag is omitted. + * also cover selector/action overhead). With + * `envelope: 'margin'` the budget is a daemon-side + * deadline (open/prepare startup): the envelope is + * budget + margin, never below `envelopeMs`, so the + * daemon's own structured timeout wins the race against + * the client envelope. `defaultBudgetMs` is used when + * the feature flag is present but the numeric timeout + * flag is omitted. * - `'positional-parser'`— the budget travels inside the positionals; `parser` * extracts it (or returns null when none was given). * The client widens the envelope to @@ -46,7 +51,7 @@ export type DaemonCommandTraits = Omit; */ export type CommandTimeoutBudget = | { source: 'none' } - | { source: 'flag'; envelope?: 'bound' | 'widen'; defaultBudgetMs?: number } + | { source: 'flag'; envelope?: 'bound' | 'widen' | 'margin'; defaultBudgetMs?: number } | { source: 'positional-parser'; parser: (positionals: string[]) => number | null }; /** diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index 492e14909d..d71a11e70d 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -28,6 +28,11 @@ export function hasRuntimeTransportHintValues(values: RuntimeHintValues): boolea /** Request-scoped runner/diagnostic context, without daemon request types. */ export type ApplicationLifecycleExecution = Readonly<{ + /** + * Absolute time by which a cold Simulator's boot must finish, from `open --timeout`. Absent + * means the platform's default boot wait; `prepare` derives its own deadline from `timeoutMs`. + */ + startupDeadlineAtMs?: number; requestId?: string; logPath?: string; traceLogPath?: string; diff --git a/packages/contracts/src/client-app.ts b/packages/contracts/src/client-app.ts index bf4bef329b..4c0c60c6ee 100644 --- a/packages/contracts/src/client-app.ts +++ b/packages/contracts/src/client-app.ts @@ -66,6 +66,8 @@ export type AppOpenOptions = AgentDeviceRequestOverrides & launchConsole?: string; launchArgs?: string[]; relaunch?: boolean; + /** Startup budget in milliseconds: bounds the Simulator boot wait on a cold device. */ + timeoutMs?: number; /** * Include the initial interactive snapshot in a fresh open response. With * no app argument, iOS can discover the sole running app on the sole booted diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index 15e2cb50b2..99eff7e690 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -244,6 +244,94 @@ test('discards a retained physical iOS runner when relaunch fails and preserves expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled(); }); +test('prepare shares one startup budget across the Simulator boot and the runner preparation', async () => { + vi.useFakeTimers(); + try { + const startedAtMs = 1_000_000; + vi.setSystemTime(startedAtMs); + const { host, calls, prepareRunner } = coldSimulatorLifecycleHost({ + onBoot: () => vi.setSystemTime(startedAtMs + 10_000), + onBootstatus: () => vi.setSystemTime(startedAtMs + 50_000), + }); + const lifecycle = bindAppleApplicationLifecycle({ + host, + device: { ...simulator, booted: false }, + signal: new AbortController().signal, + }); + + await lifecycle.prepareAppleRunner({ timeoutMs: 100_000, execution: {} }); + + // The boot wait gets what the boot left; the runner gets what the boot wait left. + expect(calls.find((call) => call.args.includes('bootstatus'))?.timeoutMs).toBe(90_000); + expect(prepareRunner).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ id: simulator.id }), + { timeoutMs: 50_000, execution: {} }, + expect.anything(), + ); + } finally { + vi.useRealTimers(); + } +}); + +test('open forwards its startup deadline to the Simulator boot wait', async () => { + vi.useFakeTimers(); + try { + const startedAtMs = 1_000_000; + vi.setSystemTime(startedAtMs); + const { host, calls } = coldSimulatorLifecycleHost({ + onBoot: () => vi.setSystemTime(startedAtMs + 5_000), + }); + const lifecycle = bindAppleApplicationLifecycle({ + host, + device: { ...simulator, booted: false }, + signal: new AbortController().signal, + }); + + await lifecycle.prepareApplicationOpen({ + target: 'com.example.app', + hasExistingSession: false, + surface: 'app', + deviceHub: false, + prewarmRunnerOnColdBoot: false, + execution: { startupDeadlineAtMs: startedAtMs + 45_000 }, + }); + + expect(calls.find((call) => call.args.includes('bootstatus'))?.timeoutMs).toBe(40_000); + } finally { + vi.useRealTimers(); + } +}); + +/** A Shutdown Simulator host whose boot and bootstatus calls run the given hooks before succeeding. */ +function coldSimulatorLifecycleHost(hooks: { onBoot?: () => void; onBootstatus?: () => void }) { + const calls: Array<{ args: string[]; timeoutMs?: number }> = []; + let state = 'Shutdown'; + const run: PlatformRuntimeHost['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: simulator.id, state }] } }), + stderr: '', + exitCode: 0, + }; + } + if (request.args.includes('boot')) { + hooks.onBoot?.(); + state = 'Booted'; + } + if (request.args.includes('bootstatus')) hooks.onBootstatus?.(); + return { stdout: '', stderr: '', exitCode: 0 }; + }); + const prepareRunner = vi.fn(async () => ({ runner: {}, connectMs: 0, healthCheckMs: 0 })); + const base = platformRuntimeHostFixture(); + const host = { + ...base, + appleTools: { isXcrunAvailable: async () => true, run }, + appleApplications: { ...base.appleApplications, prepareRunner }, + } as unknown as PlatformRuntimeHost; + return { host, calls, prepareRunner }; +} + function openInput(): OpenApplicationInput { return { target: 'com.example.app', diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index 9f3a593d8d..5d10c7dc91 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -63,6 +63,7 @@ export function bindAppleApplicationLifecycle( await params.host.appleApplications.resolveOpenTarget(params.device, input), prepareApplicationOpen: async (input) => { await ensureAppleReady(params.host, params.device, params.signal, { + deadlineAtMs: input.execution.startupDeadlineAtMs, onColdBootStart: input.prewarmRunnerOnColdBoot ? () => { void params.host.appleApplications @@ -347,8 +348,12 @@ async function prepareAppleRunner( signal: AbortSignal, input: PrepareAppleRunnerInput, ): Promise { - await ensureAppleReady(host, device, signal); - return await host.appleApplications.prepareRunner(device, input, signal); + // One budget covers the boot and the runner: a cold Simulator's boot spends part of it, and + // the runner preparation gets what is left rather than the full budget again. + const deadlineAtMs = Date.now() + input.timeoutMs; + await ensureAppleReady(host, device, signal, { deadlineAtMs }); + const timeoutMs = Math.max(1, deadlineAtMs - Date.now()); + return await host.appleApplications.prepareRunner(device, { ...input, timeoutMs }, signal); } type RunnerPrewarm = Readonly<{ diff --git a/packages/platform-apple/src/readiness/runtime.test.ts b/packages/platform-apple/src/readiness/runtime.test.ts index 64aaf7e49b..44b87cab42 100644 --- a/packages/platform-apple/src/readiness/runtime.test.ts +++ b/packages/platform-apple/src/readiness/runtime.test.ts @@ -139,6 +139,70 @@ test('cancellation interrupts simulator bootstatus and schedules cleanup for the expect(keepHot).toHaveBeenCalledOnce(); }); +test('a startup deadline is one budget shared by simctl boot and bootstatus, and its expiry reports boot_timeout while the Simulator keeps booting', async () => { + vi.useFakeTimers(); + try { + const startedAtMs = 1_000_000; + vi.setSystemTime(startedAtMs); + const { host, calls } = coldSimulatorHost({ + onBoot: () => vi.setSystemTime(startedAtMs + 2_000), + onBootstatus: () => { + vi.setSystemTime(startedAtMs + 30_000); + throw new Error('xcrun timed out after 28000ms'); + }, + }); + + await expect( + ensureAppleReady(host, simulator(), new AbortController().signal, { + deadlineAtMs: startedAtMs + 30_000, + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'boot_timeout', deviceId: 'sim-1' }, + }); + + expect(calls.find((call) => call.args.includes('boot'))?.timeoutMs).toBe(30_000); + expect(calls.find((call) => call.args.includes('bootstatus'))?.timeoutMs).toBe(28_000); + // A deadline is not a cancellation: the boot it started is left to finish. + expect(calls.some((call) => call.args.includes('shutdown'))).toBe(false); + } finally { + vi.useRealTimers(); + } +}); + +test('a boot confirmed only after the deadline is a boot_timeout, and the confirming listing runs inside the budget', async () => { + vi.useFakeTimers(); + try { + const startedAtMs = 1_000_000; + vi.setSystemTime(startedAtMs); + const { host, calls } = coldSimulatorHost({ + onBoot: () => vi.setSystemTime(startedAtMs + 2_000), + onBootstatus: () => vi.setSystemTime(startedAtMs + 22_000), + onBootedList: () => vi.setSystemTime(startedAtMs + 31_000), + }); + + await expect( + ensureAppleReady(host, simulator(), new AbortController().signal, { + deadlineAtMs: startedAtMs + 30_000, + }), + ).rejects.toMatchObject({ details: { reason: 'boot_timeout', deviceId: 'sim-1' } }); + + const listings = calls.filter((call) => call.args.includes('list')); + expect(listings.at(-1)?.timeoutMs).toBe(8_000); + expect(calls.some((call) => call.args.includes('shutdown'))).toBe(false); + } finally { + vi.useRealTimers(); + } +}); + +test('without a startup deadline the boot wait keeps its default budget', async () => { + const { host, calls } = coldSimulatorHost({}); + + await ensureAppleReady(host, simulator(), new AbortController().signal); + + expect(calls.find((call) => call.args.includes('bootstatus'))?.timeoutMs).toBe(120_000); +}); + test('physical readiness forwards the request signal to the focused host port', async () => { const host = platformRuntimeHostFixture(); const ensureConnected = vi.fn(async () => {}); @@ -154,6 +218,47 @@ test('physical readiness forwards the request signal to the focused host port', expect(ensureConnected).toHaveBeenCalledWith(expect.anything(), controller.signal); }); +/** A Shutdown Simulator whose boot, bootstatus, and post-boot listing run the given hooks first. */ +function coldSimulatorHost(hooks: { + onBoot?: () => void; + onBootstatus?: () => void; + onBootedList?: () => void; +}) { + const calls: Array<{ args: string[]; timeoutMs?: number }> = []; + let state = 'Shutdown'; + const run: PlatformRuntimeHost['appleTools']['run'] = vi.fn(async (request) => { + calls.push({ args: [...request.args], timeoutMs: request.timeoutMs }); + if (request.args.includes('list')) { + if (state === 'Booted') hooks.onBootedList?.(); + return { + stdout: JSON.stringify({ devices: { ios: [{ udid: 'sim-1', state }] } }), + stderr: '', + exitCode: 0, + }; + } + if (request.args.includes('boot')) { + hooks.onBoot?.(); + state = 'Booted'; + } + if (request.args.includes('bootstatus')) hooks.onBootstatus?.(); + return { stdout: '', stderr: '', exitCode: 0 }; + }); + const base = platformRuntimeHostFixture(); + const host = { + ...base, + appleTools: { isXcrunAvailable: async () => true, run }, + deviceReadiness: { + ...base.deviceReadiness, + appleAutomation: { + keepHot: vi.fn(), + markBooted: vi.fn(), + wasRecentlyObservedBooted: vi.fn(async () => false), + }, + }, + } satisfies PlatformRuntimeHost; + return { host, calls }; +} + function simulator(overrides: Partial = {}): DeviceInfo { return { platform: 'apple', diff --git a/packages/platform-apple/src/readiness/runtime.ts b/packages/platform-apple/src/readiness/runtime.ts index 52df3d34b5..c8f41c18c1 100644 --- a/packages/platform-apple/src/readiness/runtime.ts +++ b/packages/platform-apple/src/readiness/runtime.ts @@ -1,6 +1,7 @@ import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { emitRequestProgress } from '@agent-device/host-kit/request'; import { delegateManagedDeviceReadiness } from '@agent-device/provision-kit/managed-device-scope'; import { getSimulatorState, simctlArgs } from '../simulator-state.ts'; @@ -14,6 +15,12 @@ const BOOT_TIMEOUT_MS = 120_000; const LIST_TIMEOUT_MS = 15_000; export type AppleReadinessOptions = Readonly<{ + /** + * Absolute deadline for the Simulator boot wait. A first boot runs Apple's data migration and + * can take minutes, so a caller-stated `--timeout` must reach this wait instead of the + * 120-second default preempting it (#2324). Without a deadline the default applies. + */ + deadlineAtMs?: number; /** * Runs when this call is about to cold-boot the simulator. `open` uses it to start warming the * runner cache in parallel with the boot, which is the whole reason the hook exists. @@ -41,7 +48,7 @@ export async function ensureAppleReady( if (state !== 'Booted') { options.onColdBootStart?.(); host.deviceReadiness.appleAutomation.keepHot(device); - await bootSimulator(host, device, signal); + await bootSimulator(host, device, signal, options.deadlineAtMs); await showSimulator(host, signal); } host.deviceReadiness.appleAutomation.keepHot(device); @@ -65,55 +72,111 @@ async function bootSimulator( host: AppleReadinessHost, device: DeviceInfo, signal: AbortSignal, + deadlineAtMs = Date.now() + BOOT_TIMEOUT_MS, ): Promise { let started = false; try { - const boot = await host.appleTools.run( - { - tool: 'simctl', - args: simctlArgs(device, ['boot', device.id]), - allowFailure: true, - timeoutMs: BOOT_TIMEOUT_MS, - }, - signal, - ); - const output = `${boot.stdout}\n${boot.stderr}`.toLowerCase(); - const alreadyBooted = - output.includes('already booted') || output.includes('current state: booted'); - if (boot.exitCode !== 0 && !alreadyBooted) { - throw new AppError('COMMAND_FAILED', 'simctl boot failed', { - stdout: boot.stdout, - stderr: boot.stderr, - exitCode: boot.exitCode, - }); - } - started = !alreadyBooted; - const status = await host.appleTools.run( - { - tool: 'simctl', - args: simctlArgs(device, ['bootstatus', device.id, '-b']), - allowFailure: true, - timeoutMs: BOOT_TIMEOUT_MS, - }, - signal, - ); - if (status.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', { - stdout: status.stdout, - stderr: status.stderr, - exitCode: status.exitCode, - }); - } - if ((await simulatorState(host, device, signal)) !== 'Booted') { - throw new AppError('COMMAND_FAILED', 'Simulator is still booting', { deviceId: device.id }); - } + started = await startSimulatorBoot(host, device, signal, deadlineAtMs); + await waitForSimulatorBoot(host, device, signal, deadlineAtMs); } catch (error) { if (started && signal.aborted) scheduleSimulatorShutdown(host, device); signal.throwIfAborted(); + // The wait ended past its deadline: report the budget, not whichever tool call it cut short. + // The Simulator keeps booting so the next attempt finds it further along or ready. + if (Date.now() >= deadlineAtMs) throw bootDeadlineError(device, error); throw error; } } +/** Issues `simctl boot`; true when this call started the boot, false when it was already booted. */ +async function startSimulatorBoot( + host: AppleReadinessHost, + device: DeviceInfo, + signal: AbortSignal, + deadlineAtMs: number, +): Promise { + const boot = await host.appleTools.run( + { + tool: 'simctl', + args: simctlArgs(device, ['boot', device.id]), + allowFailure: true, + timeoutMs: remainingBootBudgetMs(deadlineAtMs, device), + }, + signal, + ); + const output = `${boot.stdout}\n${boot.stderr}`.toLowerCase(); + const alreadyBooted = + output.includes('already booted') || output.includes('current state: booted'); + if (boot.exitCode !== 0 && !alreadyBooted) { + throw new AppError('COMMAND_FAILED', 'simctl boot failed', { + stdout: boot.stdout, + stderr: boot.stderr, + exitCode: boot.exitCode, + }); + } + return !alreadyBooted; +} + +async function waitForSimulatorBoot( + host: AppleReadinessHost, + device: DeviceInfo, + signal: AbortSignal, + deadlineAtMs: number, +): Promise { + emitRequestProgress({ + type: 'command', + status: 'progress', + message: 'Waiting for the Simulator to finish booting...', + }); + const status = await host.appleTools.run( + { + tool: 'simctl', + args: simctlArgs(device, ['bootstatus', device.id, '-b']), + allowFailure: true, + timeoutMs: remainingBootBudgetMs(deadlineAtMs, device), + }, + signal, + ); + if (status.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', { + stdout: status.stdout, + stderr: status.stderr, + exitCode: status.exitCode, + }); + } + // The confirming listing runs inside the same budget, and a confirmation that lands after the + // deadline is still a timeout: the caller's budget is the contract, not the boot's outcome. + const state = await getSimulatorState( + host.appleTools, + device, + signal, + Math.min(LIST_TIMEOUT_MS, remainingBootBudgetMs(deadlineAtMs, device)), + ); + if (state !== 'Booted') { + throw new AppError('COMMAND_FAILED', 'Simulator is still booting', { deviceId: device.id }); + } + if (Date.now() >= deadlineAtMs) throw bootDeadlineError(device); +} + +function remainingBootBudgetMs(deadlineAtMs: number, device: DeviceInfo): number { + const remainingMs = deadlineAtMs - Date.now(); + if (remainingMs <= 0) throw bootDeadlineError(device); + return remainingMs; +} + +function bootDeadlineError(device: DeviceInfo, cause?: unknown): AppError { + return new AppError( + 'COMMAND_FAILED', + 'Simulator did not finish booting within the startup budget', + { + reason: 'boot_timeout', + deviceId: device.id, + hint: 'The Simulator keeps booting in the background; a first boot can take several minutes. Retry once it is up, or pass a larger --timeout.', + }, + cause instanceof Error ? cause : undefined, + ); +} + async function simulatorState( host: AppleReadinessHost, device: DeviceInfo, diff --git a/packages/platform-apple/src/shutdown/runtime.test.ts b/packages/platform-apple/src/shutdown/runtime.test.ts index c78b5df7eb..e59c848be4 100644 --- a/packages/platform-apple/src/shutdown/runtime.test.ts +++ b/packages/platform-apple/src/shutdown/runtime.test.ts @@ -14,13 +14,17 @@ beforeEach(() => { run.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); }); -test('an already-stopped simulator succeeds without native shutdown', async () => { +test('a device selected while Shutdown is still stopped natively: the session may have booted it since', async () => { + run.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); const runtime = createAppleShutdownRuntime({ appleTools }); await expect(runtime.shutdownTarget(appleDevice({ booted: false }), signal())).resolves.toEqual( success(), ); - expect(run).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ args: ['shutdown', 'sim-1'] }), + expect.anything(), + ); }); test('a shutdown error is successful when final simulator state is Shutdown', async () => { diff --git a/packages/platform-apple/src/shutdown/runtime.ts b/packages/platform-apple/src/shutdown/runtime.ts index c9b49bfc4d..3ee3a9a37f 100644 --- a/packages/platform-apple/src/shutdown/runtime.ts +++ b/packages/platform-apple/src/shutdown/runtime.ts @@ -30,8 +30,8 @@ async function shutdownAppleTarget( device: DeviceInfo, signal: AbortSignal, ): Promise { - if (device.booted === false) return stoppedTargetSuccess(); - + // `device.booted` is the state at selection time. A session opened on a cold Simulator carries + // `false` for its whole life, so only the native tool may decide that nothing needs stopping. signal.throwIfAborted(); try { const result = await appleTools.run( @@ -90,7 +90,3 @@ function toShutdownResult(result: { stderr: result.stderr, }; } - -function stoppedTargetSuccess(): TargetShutdownResult { - return { success: true, exitCode: 0, stdout: '', stderr: '' }; -} diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index 75a3ce975f..d12d682520 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -93,19 +93,29 @@ test('daemon-preserving timeout commands are a bounded, reviewed set', () => { test('budget sources deviating from the default are bounded, reviewed sets', () => { const flagBoundBudget: string[] = []; const flagWidenBudget: string[] = []; + const flagMarginBudget: string[] = []; const positionalBudget: string[] = []; for (const descriptor of commandDescriptors) { const budget = descriptor.timeoutPolicy.budget; if (budget.source === 'flag') { - const widen = 'envelope' in budget && budget.envelope === 'widen'; - (widen ? flagWidenBudget : flagBoundBudget).push(descriptor.name); + const envelope = 'envelope' in budget ? budget.envelope : undefined; + const bucket = + envelope === 'widen' + ? flagWidenBudget + : envelope === 'margin' + ? flagMarginBudget + : flagBoundBudget; + bucket.push(descriptor.name); } if (budget.source === 'positional-parser') { positionalBudget.push(descriptor.name); } } // --timeout bounds the request envelope for these commands only. - assert.deepEqual(flagBoundBudget.sort(), ['prepare', 'replay', 'snapshot']); + assert.deepEqual(flagBoundBudget.sort(), ['replay', 'snapshot']); + // --timeout is a daemon-side startup budget on these commands (#2324); the + // envelope keeps a margin over it so the daemon's own timeout wins the race. + assert.deepEqual(flagMarginBudget.sort(), ['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()); @@ -306,16 +316,52 @@ test('snapshot uses the standard daemon request timeout with an explicit overrid }), 240_000, ); + assert.equal( + resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('test'), { ...base }), + undefined, + ); +}); + +test('open and prepare startup budgets keep a client-envelope margin over the daemon deadline', () => { + const base = { positionals: [] as string[], flags: {} }; + + // A cold Simulator's first boot can take minutes (#2324): the budget reaches + // the daemon's boot wait, and the envelope stays past it so the daemon's + // structured boot_timeout arrives instead of a client-side daemon reset. + assert.equal( + resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('open'), { + ...base, + flags: { timeoutMs: 600_000 }, + }), + 630_000, + ); + assert.equal( + resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('prepare'), { + ...base, + positionals: ['ios-runner'], + flags: { timeoutMs: 600_000 }, + }), + 630_000, + ); + // Small budgets never shrink the envelope below the command's base. + assert.equal( + resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('open'), { + ...base, + flags: { timeoutMs: 5_000 }, + }), + 90_000, + ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('prepare'), { ...base, positionals: ['ios-runner'], flags: { timeoutMs: 240_000 }, }), - 240_000, + 270_000, ); + // No budget → base envelope. assert.equal( - resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('test'), { ...base }), - undefined, + resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('open'), { ...base }), + 90_000, ); }); diff --git a/src/commands/cli-grammar/flag-definitions-workflow.ts b/src/commands/cli-grammar/flag-definitions-workflow.ts index c82dad2ca5..15d5628772 100644 --- a/src/commands/cli-grammar/flag-definitions-workflow.ts +++ b/src/commands/cli-grammar/flag-definitions-workflow.ts @@ -71,7 +71,7 @@ export const WORKFLOW_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ min: 1, usageLabel: '--timeout ', usageDescription: - 'Prepare/Replay/Snapshot/Test: maximum wall-clock time for the command or attempt. With --settle: the settle-wait deadline (default 10s)', + '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)', }, { key: 'retries', diff --git a/src/commands/management/app.test.ts b/src/commands/management/app.test.ts index 343fe9fac1..26a29e275d 100644 --- a/src/commands/management/app.test.ts +++ b/src/commands/management/app.test.ts @@ -37,6 +37,29 @@ function createOpenClient(params: { stateDir: string; session: string; sessionRe return { client, calls }; } +describe('open startup budget', () => { + test('open --timeout projects the startup budget onto the daemon request', async () => { + const parsed = parseArgs(['open', 'Settings', '--timeout', '600000'], { strictFlags: true }); + const stateDir = tempStateDir(); + try { + const { client, calls } = createOpenClient({ stateDir, session: 'cold-start' }); + await openCommandFacet.definition.invoke( + client, + openCommandFacet.cliReader(parsed.positionals, parsed.flags), + ); + expect(calls[0]?.flags?.timeoutMs).toBe(600_000); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + test.each([0, -1, 1.5])('structured open input rejects the startup budget %s', (timeoutMs) => { + expect(() => openCommandFacet.metadata.readInput({ app: 'Settings', timeoutMs })).toThrow( + /timeoutMs/, + ); + }); +}); + describe('open command metro session hints', () => { test('CLI parser accepts --metro-host/--metro-port/--bundle-url/--launch-url on open', () => { const parsed = parseArgs( diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index d877ce134d..2cc664069c 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -49,6 +49,10 @@ const openCommandMetadata = defineFieldCommandMetadata( 'Launch arguments forwarded verbatim to the platform launch command.', ), relaunch: booleanField('Force relaunch.'), + 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 }, + ), foreground: booleanField( 'Include an initial interactive snapshot in a fresh open response. With no app argument, discover the sole running app on the sole booted iOS simulator; ambiguous environments fail closed.', ), @@ -121,6 +125,7 @@ const openCliSchema = { 'noRecord', 'relaunch', 'foreground', + 'timeoutMs', 'surface', ...METRO_RELOAD_FLAGS, 'launchUrl', @@ -147,6 +152,7 @@ const openCliReader: CliReader = (positionals, flags) => ({ launchArgs: flags.launchArgs, relaunch: flags.relaunch, foreground: flags.foreground, + timeoutMs: flags.timeoutMs, saveScript: flags.saveScript, force: flags.force, deviceHub: flags.deviceHub, diff --git a/src/daemon/session-lifecycle/internal/session-open-prepare.ts b/src/daemon/session-lifecycle/internal/session-open-prepare.ts index 3c45c92088..c416969a10 100644 --- a/src/daemon/session-lifecycle/internal/session-open-prepare.ts +++ b/src/daemon/session-lifecycle/internal/session-open-prepare.ts @@ -161,6 +161,7 @@ export async function prepareOpenCommandDetails(params: { prewarmRunnerOnColdBoot: surface === 'app' && Boolean(openTarget) && !isDeepLinkTarget(openTarget ?? ''), execution: { + startupDeadlineAtMs: openStartupDeadlineAtMs(req), requestId: req.meta?.requestId, logPath, traceLogPath: existingSession?.trace?.outPath, @@ -226,3 +227,11 @@ async function resolvePreparedOpenIdentity(params: { appName: resolved.appName, }; } + +/** `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; +} diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index c713b967b3..7c93a218f4 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -69,6 +69,7 @@ agent-device app-switcher - 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 ` deep links are supported on Android and iOS. - `open ` opens a deep link on iOS. - `open --launch-console ` captures launch-time stdout/stderr for direct iOS simulator app launches. It is not valid for URL opens or @@ -246,6 +247,7 @@ agent-device prepare ios-runner --platform ios --timeout 240000 - `prepare ios-runner` is intended for Apple-platform CI setup before `snapshot`, `replay`, or `test`. - Run it after the simulator/device is booted and the app is installed, but before the first snapshot, replay, or test command. +- `--timeout ` is one budget shared by the Simulator boot (when the target is not booted yet) and the runner preparation; a never-booted Simulator's first-boot migration is bounded by it, not by the 120-second default boot wait. - It builds or reuses the local XCTest runner, starts a runner session, and verifies that the runner can answer a lightweight health command. - In JSON output, top-level `buildMs`, `connectMs`, and `healthCheckMs` are diagnostic fields and may overlap; use `timing.additiveParts` for additive wall-clock phase totals. `connectMs` contains `buildMs` when a runner artifact is built or rebuilt. - If health checking exposes a bad restored runner artifact, Agent Device marks that artifact bad and rebuilds once. diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index 7a257c1eee..61ff4a2d7b 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -48,6 +48,12 @@ Shut down the simulator/emulator on close (Apple simulators and Android emulator agent-device close --shutdown ``` +A never-booted iOS Simulator can take several minutes to finish its first boot. Give `open` (or `prepare ios-runner`) a startup budget that covers it; the session's device claim is held from the first `open` onward, so a competing workspace sees `DEVICE_IN_USE` throughout: + +```bash +agent-device open Settings --platform ios --udid --timeout 600000 +``` + Notes: - `open ` within an existing session switches the active app and updates the session bundle id.