Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
Expand All @@ -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',
},
Expand Down
5 changes: 5 additions & 0 deletions packages/command-registry/src/timeout-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
13 changes: 9 additions & 4 deletions packages/command-registry/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,22 @@ export type DaemonCommandTraits = Omit<DaemonCommandDescriptor, 'command'>;
* 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
* budget + margin, never shrinking below `envelopeMs`.
*/
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 };

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/client-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions packages/platform-apple/src/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 7 additions & 2 deletions packages/platform-apple/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -347,8 +348,12 @@ async function prepareAppleRunner(
signal: AbortSignal,
input: PrepareAppleRunnerInput,
): Promise<PrepareAppleRunnerResult> {
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<{
Expand Down
105 changes: 105 additions & 0 deletions packages/platform-apple/src/readiness/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {});
Expand All @@ -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> = {}): DeviceInfo {
return {
platform: 'apple',
Expand Down
Loading
Loading