Skip to content

Commit bc1df93

Browse files
thymikeePrinceD96
andcommitted
fix(ios): honor the startup budget through a cold Simulator boot
A never-booted Simulator runs Apple's first-boot migration, which can take minutes, but the boot wait was capped at a fixed 120 seconds that neither `prepare --timeout` nor `open` could reach (#2324). - The boot wait takes an absolute deadline. `prepare --timeout` now covers the boot and the runner preparation as one budget; `open --timeout` is new and bounds the boot. Expiry fails with `boot_timeout` and leaves the Simulator booting, so a retry finds it further along. - The client envelope for open/prepare keeps the 30s margin over the budget so the daemon's structured timeout wins the race against the client's reset. - `close --shutdown` no longer trusts the session device's selection-time `booted: false`; it always asks simctl. A session opened on a cold Simulator otherwise reported a shutdown that never happened. Supersedes the original implementation of #2325 by @PrinceD96 (head 8bdb85b), which found the bug, the shutdown shortcut, and the validation recipe. Closes #2324 Co-authored-by: PrinceD96 <53633741+PrinceD96@users.noreply.github.com>
1 parent e7a5b8b commit bc1df93

18 files changed

Lines changed: 397 additions & 64 deletions

File tree

packages/command-registry/src/registry.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -996,7 +996,8 @@ export const RAW_COMMAND_DESCRIPTORS = [
996996
allowSessionlessDefaultDevice: allowAnyDeviceSessionless,
997997
saveScriptFlagOwner: true,
998998
},
999-
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
999+
// --timeout is a startup budget: it reaches the Simulator boot wait (#2324).
1000+
timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, budget: { source: 'flag', envelope: 'margin' } },
10001001
batchable: true,
10011002
platformExecution: { kind: 'device-runtime', uses: openApplicationRuntimePlanUses },
10021003
},
@@ -1008,9 +1009,10 @@ export const RAW_COMMAND_DESCRIPTORS = [
10081009
frameworkTier: 'extended',
10091010
recordsSessionAction: false,
10101011
daemon: { route: 'session', refFrameEffect: 'preserve' },
1011-
// Runner warm-up builds are the longest fixed envelope; --timeout overrides.
1012+
// Runner warm-up builds are the longest fixed envelope; --timeout is the
1013+
// daemon-side boot + runner budget, so the envelope keeps a margin over it.
10121014
timeoutPolicy: {
1013-
budget: { source: 'flag' },
1015+
budget: { source: 'flag', envelope: 'margin' },
10141016
envelopeMs: PREPARE_REQUEST_TIMEOUT_MS,
10151017
onTimeout: 'reset-daemon',
10161018
},

packages/command-registry/src/timeout-policy.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ function resolveFlagBudgetTimeoutMs(
8080
if (policy.budget.envelope === 'widen') {
8181
return resolveWideningFlagBudget(policy, policy.budget, flags);
8282
}
83+
if (policy.budget.envelope === 'margin') {
84+
return typeof flags?.timeoutMs === 'number'
85+
? widenToUserBudget(policy, flags.timeoutMs)
86+
: policy.envelopeMs;
87+
}
8388
return typeof flags?.timeoutMs === 'number' ? flags.timeoutMs : policy.envelopeMs;
8489
}
8590

packages/command-registry/src/types.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,22 @@ export type DaemonCommandTraits = Omit<DaemonCommandDescriptor, 'command'>;
3636
* ever EXTENDS the envelope to envelopeMs + budget +
3737
* margin (interaction --settle semantics, #1101: the
3838
* flag bounds a post-action wait, so the request must
39-
* also cover selector/action overhead). `defaultBudgetMs`
40-
* is used when the feature flag is present but the
41-
* numeric timeout flag is omitted.
39+
* also cover selector/action overhead). With
40+
* `envelope: 'margin'` the budget is a daemon-side
41+
* deadline (open/prepare startup): the envelope is
42+
* budget + margin, never below `envelopeMs`, so the
43+
* daemon's own structured timeout wins the race against
44+
* the client envelope. `defaultBudgetMs` is used when
45+
* the feature flag is present but the numeric timeout
46+
* flag is omitted.
4247
* - `'positional-parser'`— the budget travels inside the positionals; `parser`
4348
* extracts it (or returns null when none was given).
4449
* The client widens the envelope to
4550
* budget + margin, never shrinking below `envelopeMs`.
4651
*/
4752
export type CommandTimeoutBudget =
4853
| { source: 'none' }
49-
| { source: 'flag'; envelope?: 'bound' | 'widen'; defaultBudgetMs?: number }
54+
| { source: 'flag'; envelope?: 'bound' | 'widen' | 'margin'; defaultBudgetMs?: number }
5055
| { source: 'positional-parser'; parser: (positionals: string[]) => number | null };
5156

5257
/**

packages/contracts/src/application-lifecycle-runtime.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export function hasRuntimeTransportHintValues(values: RuntimeHintValues): boolea
2828

2929
/** Request-scoped runner/diagnostic context, without daemon request types. */
3030
export type ApplicationLifecycleExecution = Readonly<{
31+
/**
32+
* Absolute time by which a cold Simulator's boot must finish, from `open --timeout`. Absent
33+
* means the platform's default boot wait; `prepare` derives its own deadline from `timeoutMs`.
34+
*/
35+
startupDeadlineAtMs?: number;
3136
requestId?: string;
3237
logPath?: string;
3338
traceLogPath?: string;

packages/contracts/src/client-app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &
6666
launchConsole?: string;
6767
launchArgs?: string[];
6868
relaunch?: boolean;
69+
/** Startup budget in milliseconds: bounds the Simulator boot wait on a cold device. */
70+
timeoutMs?: number;
6971
/**
7072
* Include the initial interactive snapshot in a fresh open response. With
7173
* no app argument, iOS can discover the sole running app on the sole booted

packages/platform-apple/src/lifecycle.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,94 @@ test('discards a retained physical iOS runner when relaunch fails and preserves
244244
expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled();
245245
});
246246

247+
test('prepare shares one startup budget across the Simulator boot and the runner preparation', async () => {
248+
vi.useFakeTimers();
249+
try {
250+
const startedAtMs = 1_000_000;
251+
vi.setSystemTime(startedAtMs);
252+
const { host, calls, prepareRunner } = coldSimulatorLifecycleHost({
253+
onBoot: () => vi.setSystemTime(startedAtMs + 10_000),
254+
onBootstatus: () => vi.setSystemTime(startedAtMs + 50_000),
255+
});
256+
const lifecycle = bindAppleApplicationLifecycle({
257+
host,
258+
device: { ...simulator, booted: false },
259+
signal: new AbortController().signal,
260+
});
261+
262+
await lifecycle.prepareAppleRunner({ timeoutMs: 100_000, execution: {} });
263+
264+
// The boot wait gets what the boot left; the runner gets what the boot wait left.
265+
expect(calls.find((call) => call.args.includes('bootstatus'))?.timeoutMs).toBe(90_000);
266+
expect(prepareRunner).toHaveBeenCalledExactlyOnceWith(
267+
expect.objectContaining({ id: simulator.id }),
268+
{ timeoutMs: 50_000, execution: {} },
269+
expect.anything(),
270+
);
271+
} finally {
272+
vi.useRealTimers();
273+
}
274+
});
275+
276+
test('open forwards its startup deadline to the Simulator boot wait', async () => {
277+
vi.useFakeTimers();
278+
try {
279+
const startedAtMs = 1_000_000;
280+
vi.setSystemTime(startedAtMs);
281+
const { host, calls } = coldSimulatorLifecycleHost({
282+
onBoot: () => vi.setSystemTime(startedAtMs + 5_000),
283+
});
284+
const lifecycle = bindAppleApplicationLifecycle({
285+
host,
286+
device: { ...simulator, booted: false },
287+
signal: new AbortController().signal,
288+
});
289+
290+
await lifecycle.prepareApplicationOpen({
291+
target: 'com.example.app',
292+
hasExistingSession: false,
293+
surface: 'app',
294+
deviceHub: false,
295+
prewarmRunnerOnColdBoot: false,
296+
execution: { startupDeadlineAtMs: startedAtMs + 45_000 },
297+
});
298+
299+
expect(calls.find((call) => call.args.includes('bootstatus'))?.timeoutMs).toBe(40_000);
300+
} finally {
301+
vi.useRealTimers();
302+
}
303+
});
304+
305+
/** A Shutdown Simulator host whose boot and bootstatus calls run the given hooks before succeeding. */
306+
function coldSimulatorLifecycleHost(hooks: { onBoot?: () => void; onBootstatus?: () => void }) {
307+
const calls: Array<{ args: string[]; timeoutMs?: number }> = [];
308+
let state = 'Shutdown';
309+
const run: PlatformRuntimeHost['appleTools']['run'] = vi.fn(async (request) => {
310+
calls.push({ args: [...request.args], timeoutMs: request.timeoutMs });
311+
if (request.args.includes('list')) {
312+
return {
313+
stdout: JSON.stringify({ devices: { ios: [{ udid: simulator.id, state }] } }),
314+
stderr: '',
315+
exitCode: 0,
316+
};
317+
}
318+
if (request.args.includes('boot')) {
319+
hooks.onBoot?.();
320+
state = 'Booted';
321+
}
322+
if (request.args.includes('bootstatus')) hooks.onBootstatus?.();
323+
return { stdout: '', stderr: '', exitCode: 0 };
324+
});
325+
const prepareRunner = vi.fn(async () => ({ runner: {}, connectMs: 0, healthCheckMs: 0 }));
326+
const base = platformRuntimeHostFixture();
327+
const host = {
328+
...base,
329+
appleTools: { isXcrunAvailable: async () => true, run },
330+
appleApplications: { ...base.appleApplications, prepareRunner },
331+
} as unknown as PlatformRuntimeHost;
332+
return { host, calls, prepareRunner };
333+
}
334+
247335
function openInput(): OpenApplicationInput {
248336
return {
249337
target: 'com.example.app',

packages/platform-apple/src/lifecycle.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export function bindAppleApplicationLifecycle(
6363
await params.host.appleApplications.resolveOpenTarget(params.device, input),
6464
prepareApplicationOpen: async (input) => {
6565
await ensureAppleReady(params.host, params.device, params.signal, {
66+
deadlineAtMs: input.execution.startupDeadlineAtMs,
6667
onColdBootStart: input.prewarmRunnerOnColdBoot
6768
? () => {
6869
void params.host.appleApplications
@@ -347,8 +348,12 @@ async function prepareAppleRunner(
347348
signal: AbortSignal,
348349
input: PrepareAppleRunnerInput,
349350
): Promise<PrepareAppleRunnerResult> {
350-
await ensureAppleReady(host, device, signal);
351-
return await host.appleApplications.prepareRunner(device, input, signal);
351+
// One budget covers the boot and the runner: a cold Simulator's boot spends part of it, and
352+
// the runner preparation gets what is left rather than the full budget again.
353+
const deadlineAtMs = Date.now() + input.timeoutMs;
354+
await ensureAppleReady(host, device, signal, { deadlineAtMs });
355+
const timeoutMs = Math.max(1, deadlineAtMs - Date.now());
356+
return await host.appleApplications.prepareRunner(device, { ...input, timeoutMs }, signal);
352357
}
353358

354359
type RunnerPrewarm = Readonly<{

packages/platform-apple/src/readiness/runtime.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,45 @@ test('cancellation interrupts simulator bootstatus and schedules cleanup for the
139139
expect(keepHot).toHaveBeenCalledOnce();
140140
});
141141

142+
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 () => {
143+
vi.useFakeTimers();
144+
try {
145+
const startedAtMs = 1_000_000;
146+
vi.setSystemTime(startedAtMs);
147+
const { host, calls } = coldSimulatorHost({
148+
onBoot: () => vi.setSystemTime(startedAtMs + 2_000),
149+
onBootstatus: () => {
150+
vi.setSystemTime(startedAtMs + 30_000);
151+
throw new Error('xcrun timed out after 28000ms');
152+
},
153+
});
154+
155+
await expect(
156+
ensureAppleReady(host, simulator(), new AbortController().signal, {
157+
deadlineAtMs: startedAtMs + 30_000,
158+
}),
159+
).rejects.toMatchObject({
160+
code: 'COMMAND_FAILED',
161+
details: { reason: 'boot_timeout', deviceId: 'sim-1' },
162+
});
163+
164+
expect(calls.find((call) => call.args.includes('boot'))?.timeoutMs).toBe(30_000);
165+
expect(calls.find((call) => call.args.includes('bootstatus'))?.timeoutMs).toBe(28_000);
166+
// A deadline is not a cancellation: the boot it started is left to finish.
167+
expect(calls.some((call) => call.args.includes('shutdown'))).toBe(false);
168+
} finally {
169+
vi.useRealTimers();
170+
}
171+
});
172+
173+
test('without a startup deadline the boot wait keeps its default budget', async () => {
174+
const { host, calls } = coldSimulatorHost({});
175+
176+
await ensureAppleReady(host, simulator(), new AbortController().signal);
177+
178+
expect(calls.find((call) => call.args.includes('bootstatus'))?.timeoutMs).toBe(120_000);
179+
});
180+
142181
test('physical readiness forwards the request signal to the focused host port', async () => {
143182
const host = platformRuntimeHostFixture();
144183
const ensureConnected = vi.fn(async () => {});
@@ -154,6 +193,42 @@ test('physical readiness forwards the request signal to the focused host port',
154193
expect(ensureConnected).toHaveBeenCalledWith(expect.anything(), controller.signal);
155194
});
156195

196+
/** A Shutdown Simulator whose boot and bootstatus calls run the given hooks before succeeding. */
197+
function coldSimulatorHost(hooks: { onBoot?: () => void; onBootstatus?: () => void }) {
198+
const calls: Array<{ args: string[]; timeoutMs?: number }> = [];
199+
let state = 'Shutdown';
200+
const run: PlatformRuntimeHost['appleTools']['run'] = vi.fn(async (request) => {
201+
calls.push({ args: [...request.args], timeoutMs: request.timeoutMs });
202+
if (request.args.includes('list')) {
203+
return {
204+
stdout: JSON.stringify({ devices: { ios: [{ udid: 'sim-1', state }] } }),
205+
stderr: '',
206+
exitCode: 0,
207+
};
208+
}
209+
if (request.args.includes('boot')) {
210+
hooks.onBoot?.();
211+
state = 'Booted';
212+
}
213+
if (request.args.includes('bootstatus')) hooks.onBootstatus?.();
214+
return { stdout: '', stderr: '', exitCode: 0 };
215+
});
216+
const base = platformRuntimeHostFixture();
217+
const host = {
218+
...base,
219+
appleTools: { isXcrunAvailable: async () => true, run },
220+
deviceReadiness: {
221+
...base.deviceReadiness,
222+
appleAutomation: {
223+
keepHot: vi.fn(),
224+
markBooted: vi.fn(),
225+
wasRecentlyObservedBooted: vi.fn(async () => false),
226+
},
227+
},
228+
} satisfies PlatformRuntimeHost;
229+
return { host, calls };
230+
}
231+
157232
function simulator(overrides: Partial<DeviceInfo> = {}): DeviceInfo {
158233
return {
159234
platform: 'apple',

0 commit comments

Comments
 (0)