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
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@
"types": "./src/facades/device.ts",
"default": "./src/facades/device.ts"
},
"./device-boot": {
"types": "./src/device-boot.ts",
"default": "./src/device-boot.ts"
},
"./device-readiness-runtime": {
"types": "./src/device-readiness-runtime.ts",
"default": "./src/device-readiness-runtime.ts"
Expand Down
25 changes: 25 additions & 0 deletions packages/contracts/src/device-boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { DeviceInfo } from '@agent-device/kernel/device';

/**
* Why a device owner could not name the start of its current boot. `unobserved` covers every
* answer that establishes nothing — the device is not running, the tool call failed, or its
* output was unreadable — and keeps the caller's decision binary.
*/
export type DeviceBootObservationFailure = 'unsupported-device' | 'unobserved';

export type DeviceBootObservation =
| Readonly<{ observed: true; bootedAtMs: number }>
| Readonly<{ observed: false; reason: DeviceBootObservationFailure }>;

/**
* When a device's CURRENT boot began, as a host-clock epoch in milliseconds. Owners answer for the
* device kinds they can actually observe and report `unsupported-device` for every other leaf, so a
* caller never has to know which family answered.
*
* A probe answers a question a caller cannot answer from its own state, so its budget must stay far
* below the operation it precedes, and an unanswered probe must leave that operation as cautious as
* it was before the probe existed.
*/
export type DeviceBootObservationService = Readonly<{
observeBootTimeMs(device: DeviceInfo): Promise<DeviceBootObservation>;
}>;
4 changes: 4 additions & 0 deletions packages/platform-android/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"types": "./src/adb-host.ts",
"default": "./src/adb-host.ts"
},
"./device-boot": {
"types": "./src/device-boot.ts",
"default": "./src/device-boot.ts"
},
"./mechanics": {
"types": "./src/mechanics.ts",
"default": "./src/mechanics.ts"
Expand Down
77 changes: 77 additions & 0 deletions packages/platform-android/src/device-boot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, test, vi } from 'vitest';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts';
import { observeAndroidBootTimeMs } from './device-boot.ts';

const DEVICE: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};

const NOW_MS = 1_700_000_000_000;

function answersUptime(stdout: string, exitCode = 0) {
let received: { serial: string; args: string[] } | undefined;
bindAndroidAdbHostStub({
execSerialAdb: async (serial, args) => {
received = { serial, args };
return { exitCode, stdout, stderr: '' };
},
});
return () => received;
}

beforeEach(() => {
vi.useFakeTimers({ now: NOW_MS });
});

afterEach(() => {
vi.useRealTimers();
});

test('derives the boot instant from the uptime duration on the host clock', async () => {
const received = answersUptime('120.45 300.12\n');

assert.deepEqual(await observeAndroidBootTimeMs(DEVICE), {
observed: true,
bootedAtMs: NOW_MS - 120_450,
});
assert.deepEqual(received(), {
serial: 'emulator-5554',
args: ['shell', 'cat', '/proc/uptime'],
});
});

test('a slow uptime answer cannot move the boot instant past the moment the probe began', async () => {
bindAndroidAdbHostStub({
execSerialAdb: async () => {
vi.setSystemTime(NOW_MS + 4_000);
return { exitCode: 0, stdout: '120.45 0', stderr: '' };
},
});

assert.deepEqual(await observeAndroidBootTimeMs(DEVICE), {
observed: true,
bootedAtMs: NOW_MS - 120_450,
});
});

test('a refused or unreadable uptime answers nothing', async () => {
for (const stdout of ['', 'cat: /proc/uptime: Permission denied', 'not-a-number 0']) {
answersUptime(stdout);
assert.deepEqual(await observeAndroidBootTimeMs(DEVICE), {
observed: false,
reason: 'unobserved',
});
}

answersUptime('120.45 0', 1);
assert.deepEqual(await observeAndroidBootTimeMs(DEVICE), {
observed: false,
reason: 'unobserved',
});
});
38 changes: 38 additions & 0 deletions packages/platform-android/src/device-boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { DeviceBootObservation } from '@agent-device/contracts/device-boot';
import type { DeviceInfo } from '@agent-device/kernel/device';

/** The probe answers in tens of milliseconds and must not become the reason an `open` waits. */
const BOOT_PROBE_TIMEOUT_MS = 3_000;

const UPTIME_FIELDS = /^\s*(\d+(?:\.\d+)?)/;

/**
* When this device's current boot began, derived from how long it has been up rather than from any
* clock it keeps itself. `/proc/uptime` is a duration, so the host clock supplies the absolute
* instant and a guest wall clock that disagrees with the host cannot move the answer — which is why
* this does not read the guest-clock stamps `/proc/stat`'s `btime` and `ro.runtime.firstboot` offer.
*/
export async function observeAndroidBootTimeMs(device: DeviceInfo): Promise<DeviceBootObservation> {
const probeStartedAtMs = Date.now();
const uptimeSeconds = await readUptimeSeconds(device);
if (uptimeSeconds === undefined) return { observed: false, reason: 'unobserved' };
// The sample was taken at or after the probe began, so this bound is never later than the real
// boot instant. Reading the clock after the response instead would let transport latency push the
// answer forward and condemn a claim taken after the reboot to look stale.
return { observed: true, bootedAtMs: probeStartedAtMs - uptimeSeconds * 1000 };
}

async function readUptimeSeconds(device: DeviceInfo): Promise<number | undefined> {
try {
const { runAndroidAdb } = await import('./adb.ts');
const result = await runAndroidAdb(device, ['shell', 'cat', '/proc/uptime'], {
allowFailure: true,
timeoutMs: BOOT_PROBE_TIMEOUT_MS,
});
if (result.exitCode !== 0) return undefined;
const seconds = Number.parseFloat(UPTIME_FIELDS.exec(result.stdout)?.[1] ?? '');
return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
} catch {
return undefined;
}
}
4 changes: 4 additions & 0 deletions packages/platform-apple/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@
"types": "./src/simulator-facade.ts",
"default": "./src/simulator-facade.ts"
},
"./simulator-boot": {
"types": "./src/simulator-boot.ts",
"default": "./src/simulator-boot.ts"
},
"./tool-provider": {
"types": "./src/tool-provider-facade.ts",
"default": "./src/tool-provider-facade.ts"
Expand Down
108 changes: 108 additions & 0 deletions packages/platform-apple/src/simulator-boot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import { beforeEach, test, vi } from 'vitest';

vi.mock('@agent-device/host-kit/command', async (importOriginal) => {
const actual = await importOriginal<typeof import('@agent-device/host-kit/command')>();
return { ...actual, runCmd: vi.fn() };
});

import { runCmd } from '@agent-device/host-kit/command';
import { IOS_DEVICE, IOS_SIMULATOR, MACOS_DEVICE } from './__tests__/device-fixtures.ts';
import { observeSimulatorBootTimeMs } from './simulator-boot.ts';

const mockRunCmd = vi.mocked(runCmd);

const BOOTED_AT = new Date(2024, 0, 15, 10, 20, 30).getTime();
const LAUNCHD = '/usr/bin/coresimd/launchd_sim';
const BOOTSTRAP = `/data/users/*/library/developer/core simulator/devices/${IOS_SIMULATOR.id}/data/var/run/launchd_bootstrap.plist`;

function psRow(pid: number, lstart: string, command: string): string {
return `${pid} ${lstart} ${command}`;
}

function launchdRow(pid: number, lstart: string, udid: string): string {
return psRow(pid, lstart, `${LAUNCHD} -c ${BOOTSTRAP.replace(IOS_SIMULATOR.id, udid)}`);
}

function succeed(...stdout: string[]) {
mockRunCmd.mockResolvedValueOnce({ exitCode: 0, stdout: stdout.join('\n'), stderr: '' } as never);
}

beforeEach(() => {
mockRunCmd.mockReset();
});

test('a device that is not an iOS Simulator is answered without probing the host', async () => {
for (const device of [IOS_DEVICE, MACOS_DEVICE]) {
assert.deepEqual(await observeSimulatorBootTimeMs(device), {
observed: false,
reason: 'unsupported-device',
});
}
assert.equal(mockRunCmd.mock.calls.length, 0);
});

test('reads the boot from the launchd_sim that names this device and no other', async () => {
succeed(String(4242));
succeed(launchdRow(4242, 'Mon Jan 15 10:20:30 2024', IOS_SIMULATOR.id));

assert.deepEqual(await observeSimulatorBootTimeMs(IOS_SIMULATOR), {
observed: true,
bootedAtMs: BOOTED_AT,
});
const [pgrep, ps] = mockRunCmd.mock.calls;
assert.deepEqual(pgrep?.slice(0, 2), ['/usr/bin/pgrep', ['-x', 'launchd_sim']]);
assert.equal(ps?.[0], '/bin/ps');
assert.deepEqual(ps?.[1], ['-p', '4242', '-o', 'pid=,lstart=,command=']);
// `lstart` spells weekday and month names in the caller's locale, which the parser rejects.
assert.deepEqual((ps?.[2] as { env?: Record<string, string> })?.env, { LC_ALL: 'C' });
});

test('the newest boot wins when a device appears in more than one row', async () => {
succeed('4242\n4343');
succeed(
launchdRow(4242, 'Mon Jan 15 10:20:30 2024', IOS_SIMULATOR.id),
launchdRow(4343, 'Tue Jan 16 11:20:30 2024', IOS_SIMULATOR.id),
);

const observation = await observeSimulatorBootTimeMs(IOS_SIMULATOR);
assert.equal(observation.observed, true);
if (!observation.observed) return;
assert.ok(observation.bootedAtMs > BOOTED_AT);
});

test('another device launchd_sim answers nothing about this one', async () => {
succeed('4242');
succeed(launchdRow(4242, 'Mon Jan 15 10:20:30 2024', 'OTHER-UDID'));

assert.deepEqual(await observeSimulatorBootTimeMs(IOS_SIMULATOR), {
observed: false,
reason: 'unobserved',
});
});

test('a boot that begins after the probe instant is a moved clock, not evidence', async () => {
succeed('4242');
succeed(launchdRow(4242, 'Sat Jan 15 10:20:30 2099', IOS_SIMULATOR.id));

assert.deepEqual(await observeSimulatorBootTimeMs(IOS_SIMULATOR), {
observed: false,
reason: 'unobserved',
});
});

test('an absent launchd_sim or a failed probe leaves the caller as cautious as it was', async () => {
mockRunCmd.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' } as never);
assert.deepEqual(await observeSimulatorBootTimeMs(IOS_SIMULATOR), {
observed: false,
reason: 'unobserved',
});
assert.equal(mockRunCmd.mock.calls.length, 1);

succeed('4242');
mockRunCmd.mockRejectedValueOnce(new Error('ps unavailable'));
assert.deepEqual(await observeSimulatorBootTimeMs(IOS_SIMULATOR), {
observed: false,
reason: 'unobserved',
});
});
Loading
Loading