Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export function hasRuntimeTransportHintValues(values: RuntimeHintValues): boolea

/** Request-scoped runner/diagnostic context, without daemon request types. */
export type ApplicationLifecycleExecution = Readonly<{
/** Absolute daemon-owned deadline shared by boot and runner preparation. */
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, including device boot and Apple runner readiness. */
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
85 changes: 85 additions & 0 deletions packages/platform-apple/src/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runti
import type { DeviceInfo } from '@agent-device/kernel/device';
import { bindAppleApplicationLifecycle } from './lifecycle.ts';
import { platformRuntimeHostFixture } from './runtime.fixtures.ts';
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((complete) => {
resolve = complete;
});
return { promise, resolve };
}

const device: DeviceInfo = {
platform: 'apple',
Expand Down Expand Up @@ -259,3 +266,81 @@ function openInput(): OpenApplicationInput {
execution: {},
};
}

test('an explicit startup deadline waits for runner readiness before opening the app', async () => {
const baseHost = platformRuntimeHostFixture();
const ready = deferred<void>();
const prewarmStarted = deferred<void>();
const open = vi.fn(async () => {});
const prewarmRunnerSession = vi.fn<
PlatformRuntimeHost['appleApplications']['prewarmRunnerSession']
>(async () => {
prewarmStarted.resolve();
await ready.promise;
});
const lifecycle = bindAppleApplicationLifecycle({
device: { ...device, kind: 'simulator' },
signal: new AbortController().signal,
host: {
...baseHost,
localInteractors: { resolve: async () => ({ open }) as unknown as Interactor },
appleApplications: { ...baseHost.appleApplications, prewarmRunnerSession },
},
});
const pending = lifecycle.openApplication({
...openInput(),
relaunch: false,
hasExistingSession: false,
execution: { startupDeadlineAtMs: Date.now() + 600_000 },
});
await prewarmStarted.promise;
await new Promise<void>((resolve) => setImmediate(resolve));
expect(open).not.toHaveBeenCalled();
ready.resolve();
await pending;
expect(open).toHaveBeenCalledOnce();
expect(prewarmRunnerSession.mock.calls[0]?.[3]).toBe(true);
});

test('prepare shares its timeout between simulator boot and runner preparation', async () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000);
const baseHost = platformRuntimeHostFixture();
const prepareRunner = vi.fn(baseHost.appleApplications.prepareRunner);
const run = vi.fn<PlatformRuntimeHost['appleTools']['run']>(async (request) => {
if (request.args.includes('bootstatus')) now.mockReturnValue(301_000);
return {
stdout: request.args.includes('list')
? JSON.stringify({
devices: {
ios: [{ udid: device.id, state: Date.now() === 1_000 ? 'Shutdown' : 'Booted' }],
},
})
: '',
stderr: '',
exitCode: 0,
};
});
try {
const lifecycle = bindAppleApplicationLifecycle({
host: {
...baseHost,
appleTools: { ...baseHost.appleTools, run },
appleApplications: { ...baseHost.appleApplications, prepareRunner },
},
device: { ...device, kind: 'simulator' },
signal: new AbortController().signal,
});
await lifecycle.prepareAppleRunner({ timeoutMs: 600_000, execution: {} });
expect(run).toHaveBeenCalledWith(
expect.objectContaining({ args: ['bootstatus', device.id, '-b'], timeoutMs: 600_000 }),
expect.any(AbortSignal),
);
expect(prepareRunner).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ timeoutMs: 300_000 }),
expect.any(AbortSignal),
);
} finally {
now.mockRestore();
}
});
21 changes: 17 additions & 4 deletions packages/platform-apple/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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 @@ -102,7 +103,8 @@ async function openAppleApplication(
input,
localIosSimulator,
);
if (localIosSimulator && shouldPrewarmRunner && !input.prewarmRunnerBeforeOpen) runner.schedule();
const requireRunnerReady = requiresRunnerReadiness(input);
if (localIosSimulator && shouldPrewarmRunner && !requireRunnerReady) runner.schedule();
try {
await closeAppleApplicationForRelaunch(
host,
Expand All @@ -113,7 +115,7 @@ async function openAppleApplication(
timing,
);
await applyAppleOpenRuntimeHints(input, timing);
await prewarmAppleRunnerBeforeOpen(runner, shouldPrewarmRunner, input.prewarmRunnerBeforeOpen);
await prewarmAppleRunnerBeforeOpen(runner, shouldPrewarmRunner, requireRunnerReady);
const runnerTargetPredatesOpen = runner.wasAwaited();
await dispatchAppleOpen(binding, input, localIosSimulator, timing);
await finishAppleRunnerPrewarm(runner, shouldPrewarmRunner, input.relaunch);
Expand All @@ -135,6 +137,10 @@ async function openAppleApplication(
}
}

function requiresRunnerReadiness(input: OpenApplicationInput): boolean {
return input.prewarmRunnerBeforeOpen || input.execution.startupDeadlineAtMs !== undefined;
}

async function closeAppleApplicationForRelaunch(
host: AppleLifecycleHost,
binding: BoundAppleInteractor,
Expand Down Expand Up @@ -343,8 +349,15 @@ async function prepareAppleRunner(
signal: AbortSignal,
input: PrepareAppleRunnerInput,
): Promise<PrepareAppleRunnerResult> {
await ensureAppleReady(host, device, signal);
return await host.appleApplications.prepareRunner(device, input, signal);
const deadlineAtMs = Date.now() + input.timeoutMs;
await ensureAppleReady(host, device, signal, { deadlineAtMs });
const timeoutMs = deadlineAtMs - Date.now();
if (timeoutMs <= 0) {
throw new AppError('COMMAND_FAILED', 'Apple runner preparation deadline exceeded', {
reason: 'startup_timeout',
});
}
return await host.appleApplications.prepareRunner(device, { ...input, timeoutMs }, signal);
}

type RunnerPrewarm = Readonly<{
Expand Down
140 changes: 140 additions & 0 deletions packages/platform-apple/src/readiness/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runti
import type { DeviceInfo } from '@agent-device/kernel/device';
import { platformRuntimeHostFixture } from '../runtime.fixtures.ts';
import { ensureAppleReady } from './runtime.ts';
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((complete) => {
resolve = complete;
});
return { promise, resolve };
}

test('recent native boot observation avoids a duplicate simulator listing', async () => {
const run = vi.fn(async () => ({
Expand Down Expand Up @@ -166,3 +173,136 @@ function simulator(overrides: Partial<DeviceInfo> = {}): DeviceInfo {
...overrides,
};
}

test('cold bootstatus receives the remaining shared startup budget rather than a fixed cap', async () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000);
const host = platformRuntimeHostFixture();
let booted = false;
const run = vi.fn<PlatformRuntimeHost['appleTools']['run']>(async (request) => {
if (request.args.includes('boot')) now.mockReturnValue(11_000);
if (request.args.includes('bootstatus')) booted = true;
return {
stdout: request.args.includes('list')
? JSON.stringify({
devices: { ios: [{ udid: 'sim-1', state: booted ? 'Booted' : 'Shutdown' }] },
})
: '',
stderr: '',
exitCode: 0,
};
});
try {
await ensureAppleReady(
{ ...host, appleTools: { ...host.appleTools, run } },
simulator(),
new AbortController().signal,
{ deadlineAtMs: 601_000 },
);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({ args: ['bootstatus', 'sim-1', '-b'], timeoutMs: 590_000 }),
expect.any(AbortSignal),
);
} finally {
now.mockRestore();
}
});

test('canceled readiness does not settle until its exact simulator shutdown completes', async () => {
const host = platformRuntimeHostFixture();
const controller = new AbortController();
const shutdown = deferred<{ stdout: string; stderr: string; exitCode: number }>();
const shutdownStarted = deferred<void>();
const run = vi.fn<PlatformRuntimeHost['appleTools']['run']>(async (request) => {
if (request.args.includes('list'))
return {
stdout: JSON.stringify({ devices: { ios: [{ udid: 'sim-1', state: 'Shutdown' }] } }),
stderr: '',
exitCode: 0,
};
if (request.args.includes('bootstatus')) {
controller.abort(new Error('cancel'));
throw controller.signal.reason;
}
if (request.args.includes('shutdown')) {
shutdownStarted.resolve();
return await shutdown.promise;
}
return { stdout: '', stderr: '', exitCode: 0 };
});
let settled = false;
const pending = ensureAppleReady(
{ ...host, appleTools: { ...host.appleTools, run } },
simulator(),
controller.signal,
).catch((error) => {
settled = true;
throw error;
});
const rejection = expect(pending).rejects.toThrow('cancel');
await shutdownStarted.promise;
await new Promise<void>((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
expect(run).toHaveBeenLastCalledWith(
expect.objectContaining({ args: ['shutdown', 'sim-1'], timeoutMs: 15_000 }),
);
shutdown.resolve({ stdout: '', stderr: '', exitCode: 0 });
await rejection;
});

test('an explicit startup budget waits for initialization even when inventory reports Booted', async () => {
const host = platformRuntimeHostFixture();
const run = vi.fn<PlatformRuntimeHost['appleTools']['run']>(async () => ({
stdout: '',
stderr: '',
exitCode: 0,
}));
await ensureAppleReady(
{
...host,
appleTools: { ...host.appleTools, run },
deviceReadiness: {
...host.deviceReadiness,
appleAutomation: {
...host.deviceReadiness.appleAutomation,
wasRecentlyObservedBooted: async () => true,
},
},
},
simulator(),
new AbortController().signal,
{ deadlineAtMs: Date.now() + 600_000 },
);
expect(run).toHaveBeenCalledOnce();
expect(run).toHaveBeenCalledWith(
expect.objectContaining({ args: ['bootstatus', 'sim-1', '-b'] }),
expect.any(AbortSignal),
);
});

test('cancellation during the boot command reconciles its potentially started simulator', async () => {
const host = platformRuntimeHostFixture();
const controller = new AbortController();
const run = vi.fn<PlatformRuntimeHost['appleTools']['run']>(async (request) => {
if (request.args.includes('list'))
return {
stdout: JSON.stringify({ devices: { ios: [{ udid: 'sim-1', state: 'Shutdown' }] } }),
stderr: '',
exitCode: 0,
};
if (request.args.includes('boot')) {
controller.abort(new Error('cancel boot command'));
throw controller.signal.reason;
}
return { stdout: '', stderr: '', exitCode: 0 };
});
await expect(
ensureAppleReady(
{ ...host, appleTools: { ...host.appleTools, run } },
simulator(),
controller.signal,
),
).rejects.toThrow('cancel boot command');
expect(run).toHaveBeenLastCalledWith(
expect.objectContaining({ args: ['shutdown', 'sim-1'], timeoutMs: 15_000 }),
);
});
Loading