Skip to content
Draft
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
27 changes: 27 additions & 0 deletions packages/contracts/src/device-readiness-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { AppError } from '@agent-device/kernel/errors';
import type { DeviceInventoryRequest } from './device-inventory.ts';

/** Typed `details.reason` every platform reports when the boot deadline expires. */
Expand All @@ -21,6 +22,18 @@ export type DeviceReadinessRuntimeOperations = Readonly<{
bootTargetHeadless(input: EnsureReadyInput): Promise<DeviceInfo>;
}>;

/**
* One run of the operator's device boot hook: its argv and what it printed. `failure` says why it
* did not exit on its own (it could not start, or ran out of time); `exitCode` is then null.
*/
export type DeviceBootHookRun = Readonly<{
command: readonly string[];
exitCode: number | null;
failure?: string;
stdout: string;
stderr: string;
}>;

export type DeviceReadinessRuntimeHost = Readonly<{
applePhysical: Readonly<{
ensureConnected(device: DeviceInfo, signal: AbortSignal): Promise<void>;
Expand All @@ -41,4 +54,18 @@ export type DeviceReadinessRuntimeHost = Readonly<{
launch(avdName: string, headless: boolean, bootBudgetMs: number): Promise<number>;
terminate(pid: number): Promise<void>;
}>;
/**
* The operator's boot hook, `boot-hook.json` in the daemon state directory. `run` returns null
* when none is configured, and readiness boots the device itself. After a run, readiness reads
* the device state again and throws `leftDeviceDown` when it is still not booted: it never boots
* the device itself then, because the hook's owner admits devices.
*/
bootHook: Readonly<{
run(
device: DeviceInfo,
signal?: AbortSignal,
options?: Readonly<{ deadlineAtMs?: number }>,
): Promise<DeviceBootHookRun | null>;
leftDeviceDown(run: DeviceBootHookRun, device: DeviceInfo): AppError;
}>;
}>;
87 changes: 86 additions & 1 deletion packages/platform-android/src/readiness/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test, vi } from 'vitest';
import type { DeviceReadinessRuntimeHost } from '@agent-device/contracts/device-readiness-runtime';
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { ensureAndroidReady } from './runtime.ts';
Expand Down Expand Up @@ -36,6 +37,7 @@ test('cancellation interrupts Android boot polling and terminates the emulator l
launch: async () => 4242,
terminate,
},
bootHook: { run: async () => null, leftDeviceDown: () => new Error('left down') as never },
},
} as unknown as PlatformRuntimeHost;

Expand All @@ -48,7 +50,11 @@ test('cancellation interrupts Android boot polling and terminates the emulator l
expect(terminate).toHaveBeenCalledWith(4242);
});

function timedHost(overrides: { bootCompleted: string; discover: () => DeviceInfo[] }) {
function timedHost(overrides: {
bootCompleted: string;
discover: () => DeviceInfo[];
bootHook?: DeviceReadinessRuntimeHost['bootHook'];
}) {
let now = 1_000;
const run = vi.fn(async () => ({ stdout: overrides.bootCompleted, stderr: '', exitCode: 0 }));
const terminate = vi.fn(async () => {});
Expand All @@ -68,6 +74,10 @@ function timedHost(overrides: { bootCompleted: string; discover: () => DeviceInf
launch,
terminate,
},
bootHook: overrides.bootHook ?? {
run: async () => null,
leftDeviceDown: () => new Error('left down') as never,
},
},
} as unknown as PlatformRuntimeHost;
return { host, run, launch, terminate, elapsed: () => now - 1_000 };
Expand Down Expand Up @@ -155,6 +165,81 @@ test('without a deadline the default 120s boot wait applies', async () => {
expect(elapsed()).toBeGreaterThanOrEqual(120_000);
});

const HOOK_RUN = {
command: ['stim', 'revive', '--json'],
exitCode: 1,
stdout: '{"code":"STIM_DEVICE_LOST"}',
stderr: '',
};

const LEFT_DOWN = new Error('boot hook left the device down');

function bootHook(run: DeviceReadinessRuntimeHost['bootHook']['run']) {
return { run, leftDeviceDown: vi.fn(() => LEFT_DOWN as never) };
}

test('a boot hook starts a shut-down AVD instead of the emulator launch', async () => {
let discoveries = 0;
const hookRun = vi.fn(async () => ({ ...HOOK_RUN, exitCode: 0 }));
const { host, launch } = timedHost({
bootCompleted: '1',
discover: () => (++discoveries === 1 ? [stoppedAvd()] : [runningEmulator()]),
bootHook: bootHook(hookRun),
});

const ready = await ensureAndroidReady(
host,
stoppedAvd(),
{ headless: false },
new AbortController().signal,
);

expect(ready).toMatchObject({ id: 'emulator-5554', booted: true });
expect(hookRun).toHaveBeenCalledWith(stoppedAvd(), expect.any(AbortSignal), {
deadlineAtMs: expect.any(Number),
});
expect(launch).not.toHaveBeenCalled();
});

test('an emulator a slow boot hook started is still checked once after the boot budget ran out', async () => {
let discoveries = 0;
const hookHost: { clock?: PlatformRuntimeHost['clock'] } = {};
const { host, run } = timedHost({
bootCompleted: '1',
discover: () => (++discoveries === 1 ? [stoppedAvd()] : [runningEmulator()]),
bootHook: bootHook(async () => {
await hookHost.clock?.sleep(300_000, new AbortController().signal);
return { ...HOOK_RUN, exitCode: 0 };
}),
});
hookHost.clock = host.clock;

const ready = await ensureAndroidReady(
host,
stoppedAvd(),
{ headless: false, deadlineAtMs: 3_000 },
new AbortController().signal,
);

expect(ready).toMatchObject({ id: 'emulator-5554', booted: true });
expect(run).toHaveBeenCalledOnce();
});

test('a boot hook that leaves no running emulator refuses with its run and never launches one', async () => {
const hook = bootHook(async () => HOOK_RUN);
const { host, launch } = timedHost({
bootCompleted: '1',
discover: () => [stoppedAvd()],
bootHook: hook,
});

await expect(
ensureAndroidReady(host, stoppedAvd(), { headless: false }, new AbortController().signal),
).rejects.toBe(LEFT_DOWN);
expect(hook.leftDeviceDown).toHaveBeenCalledWith(HOOK_RUN, stoppedAvd());
expect(launch).not.toHaveBeenCalled();
});

function stoppedAvd(): DeviceInfo {
return {
platform: 'android',
Expand Down
37 changes: 35 additions & 2 deletions packages/platform-android/src/readiness/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,15 @@ async function ensureEmulatorReady(
const available = await host.deviceReadiness.androidEmulator.discover(request, signal);
const selected = requireAvailableAvd(available, device.name, input.serial);

const existing = isRunningEmulator(selected) ? selected : undefined;
const hookStarted = isRunningEmulator(selected)
? undefined
: await startedByBootHook(
host,
selected,
{ request, serial: input.serial, deadlineAtMs },
signal,
);
const existing = isRunningEmulator(selected) ? selected : hookStarted;
const launchedPid = existing
? undefined
: await host.deviceReadiness.androidEmulator.launch(
Expand All @@ -56,7 +64,10 @@ async function ensureEmulatorReady(
const discovered =
existing ??
(await waitForDiscovery(host, selected.name, request, input.serial, deadlineAtMs, signal));
await waitForBoot(host, discovered.id, deadlineAtMs, signal);
const bootDeadline = hookStarted
? Math.max(deadlineAtMs, host.clock.now() + POLL_MS)
: deadlineAtMs;
await waitForBoot(host, discovered.id, bootDeadline, signal);
const refreshed = (await host.deviceReadiness.androidEmulator.discover(request, signal)).find(
(candidate) => candidate.id === discovered.id,
);
Expand All @@ -70,6 +81,28 @@ async function ensureEmulatorReady(
}
}

async function startedByBootHook(
host: AndroidReadinessHost,
avd: DeviceInfo,
target: Readonly<{
request: ReturnType<typeof inventoryRequest>;
serial: string | undefined;
deadlineAtMs: number;
}>,
signal: AbortSignal,
): Promise<DeviceInfo | undefined> {
const hookRun = await host.deviceReadiness.bootHook.run(avd, signal, {
deadlineAtMs: target.deadlineAtMs,
});
if (!hookRun) return undefined;
const devices = await host.deviceReadiness.androidEmulator.discover(target.request, signal);
const started = findByAvdName(devices, avd.name, target.serial);
if (!started || !isRunningEmulator(started)) {
throw host.deviceReadiness.bootHook.leftDeviceDown(hookRun, avd);
}
return started;
}

async function prepareAndroidEmulatorToolchain(host: AndroidReadinessHost): Promise<void> {
await host.toolchains.prepare('android');
if (!(await host.commands.which('adb'))) {
Expand Down
38 changes: 38 additions & 0 deletions packages/platform-apple/src/core/__tests__/simulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
ensureBootedSimulator,
openIosSimulatorApp,
setSimulatorBootHook,
wasSimulatorRecentlyObservedBooted,
} from '../simulator.ts';
import { IOS_BOOT_TIMEOUT_MS, IOS_SIMULATOR_SHOW_TIMEOUT_MS } from '../config.ts';
Expand Down Expand Up @@ -342,3 +343,40 @@ test('ensureBootedSimulator stops at the boot deadline instead of starting anoth

assert.equal(booted, true);
});

test('ensureBootedSimulator runs the bound boot hook instead of booting a shut-down simulator', async () => {
mockRunCmdResponses({
'xcrun simctl list devices -j': simulatorStateSequence('Shutdown', 'Booted'),
});
const run = vi.fn(async () => ({
command: ['stim', 'revive'],
exitCode: 0,
stdout: '',
stderr: '',
}));
setSimulatorBootHook({ run, leftDeviceDown: () => new AppError('COMMAND_FAILED', 'left down') });
try {
await ensureBootedSimulator(IOS_TEST_SIMULATOR);
} finally {
setSimulatorBootHook(undefined);
}

assert.equal(run.mock.calls.length, 1);
assert.equal(wasSimulatorRecentlyObservedBooted(IOS_TEST_SIMULATOR), true);
});

test('ensureBootedSimulator refuses without booting when the boot hook leaves the simulator shut down', async () => {
mockRunCmdResponses({
'xcrun simctl list devices -j': simulatorStateSequence('Shutdown'),
});
const hookRun = { command: ['stim', 'revive', '--json'], exitCode: 1, stdout: '', stderr: '' };
const leftDown = new AppError('COMMAND_FAILED', 'left down');
const leftDeviceDown = vi.fn(() => leftDown);
setSimulatorBootHook({ run: async () => hookRun, leftDeviceDown });
try {
await assert.rejects(ensureBootedSimulator(IOS_TEST_SIMULATOR), (error) => error === leftDown);
} finally {
setSimulatorBootHook(undefined);
}
assert.deepEqual(leftDeviceDown.mock.calls, [[hookRun, IOS_TEST_SIMULATOR]]);
});
28 changes: 28 additions & 0 deletions packages/platform-apple/src/core/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { execFailureDetails, requireExecSuccess } from '@agent-device/host-kit/c
import { Deadline, retryWithPolicy } from '@agent-device/host-kit/retry';

import { createTtlMemo } from '@agent-device/kernel/ttl-memo';
import { runtimeSlot } from '@agent-device/host-kit/runtime-slots';
import type { DeviceReadinessRuntimeHost } from '@agent-device/contracts/device-readiness-runtime';
import { bootFailureHint, classifyBootFailure } from '@agent-device/provision-kit/boot-diagnostics';

import {
Expand Down Expand Up @@ -58,6 +60,22 @@ export async function openIosSimulatorApp(options: SimulatorCommandOptions = {})
});
}

type SimulatorBootHookSlot = { hook: DeviceReadinessRuntimeHost['bootHook'] | undefined };

const BOOT_HOOK_SLOT = Symbol('appleSimulatorBootHook');
const bootHookSlot = (): SimulatorBootHookSlot =>
runtimeSlot(BOOT_HOOK_SLOT, (): SimulatorBootHookSlot => ({ hook: undefined }));

/**
* Binds the operator's boot hook for every simulator boot this runtime makes. Root composition
* calls it once per runtime; without it, a shut-down simulator is booted with `simctl boot`.
*/
export function setSimulatorBootHook(
hook: DeviceReadinessRuntimeHost['bootHook'] | undefined,
): void {
bootHookSlot().hook = hook;
}

export async function ensureBootedSimulator(
device: DeviceInfo,
options: SimulatorCommandOptions = {},
Expand All @@ -72,6 +90,16 @@ export async function ensureBootedSimulator(
return;
}

const bootHook = bootHookSlot().hook;
const hookRun = await bootHook?.run(device, options.signal);
if (bootHook && hookRun) {
if ((await getSimulatorState(device, options.signal)) !== 'Booted') {
throw bootHook.leftDeviceDown(hookRun, device);
}
markSimulatorBooted(device);
return;
}

const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS);
let bootResult:
| {
Expand Down
65 changes: 65 additions & 0 deletions packages/platform-apple/src/readiness/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,71 @@ function simctlVerb(args: readonly string[]): string {
return args.find((arg) => ['list', 'boot', 'bootstatus', 'shutdown'].includes(arg)) ?? '';
}

function hookHost(
states: string[],
hookRun: PlatformRuntimeHost['deviceReadiness']['bootHook']['run'],
) {
const leftDown = new Error('boot hook left the device down');
const leftDeviceDown = vi.fn(() => leftDown as never);
const calls: string[][] = [];
const run: PlatformRuntimeHost['appleTools']['run'] = vi.fn(async (request) => {
calls.push([...request.args]);
const state = request.args.includes('list') ? (states.shift() ?? 'Shutdown') : null;
return {
stdout: state ? JSON.stringify({ devices: { ios: [{ udid: 'sim-1', state }] } }) : '',
stderr: '',
exitCode: 0,
};
});
const base = platformRuntimeHostFixture();
const host = {
...base,
appleTools: { isXcrunAvailable: async () => true, run },
deviceReadiness: { ...base.deviceReadiness, bootHook: { run: hookRun, leftDeviceDown } },
} satisfies PlatformRuntimeHost;
return {
host,
leftDown,
leftDeviceDown,
booted: () => calls.some((args) => args.includes('boot')),
};
}

test('a boot hook restores a shut-down simulator instead of a simctl boot', async () => {
const hookRun = vi.fn(async () => ({
command: ['stim', 'revive'],
exitCode: 0,
stdout: '',
stderr: '',
}));
const { host, booted } = hookHost(['Shutdown', 'Booted'], hookRun);

const ready = await ensureAppleReady(host, simulator(), new AbortController().signal);

expect(ready.booted).toBe(true);
expect(hookRun).toHaveBeenCalledOnce();
expect(booted()).toBe(false);
});

test('a boot hook that leaves the simulator shut down refuses with its run and never boots it', async () => {
const hookRun = {
command: ['stim', 'revive', '--json'],
exitCode: 1,
stdout: '{"code":"STIM_NO_METRO"}',
stderr: '',
};
const { host, leftDown, leftDeviceDown, booted } = hookHost(
['Shutdown', 'Shutdown'],
async () => hookRun,
);

await expect(ensureAppleReady(host, simulator(), new AbortController().signal)).rejects.toBe(
leftDown,
);
expect(leftDeviceDown).toHaveBeenCalledWith(hookRun, simulator());
expect(booted()).toBe(false);
});

function simulator(overrides: Partial<DeviceInfo> = {}): DeviceInfo {
return {
platform: 'apple',
Expand Down
Loading
Loading