diff --git a/docs/agent-benchmark.md b/docs/agent-benchmark.md index 2f3f5c89..c9d0810e 100644 --- a/docs/agent-benchmark.md +++ b/docs/agent-benchmark.md @@ -107,7 +107,7 @@ must report adoption of the prepared simulator. On Android it requests the pinned system image. The control must not inspect that home or use Stim; it creates a new benchmark-named device with the same platform configuration. Android control uses the same `avdmanager` default profile, 8 GiB data -partition, system image, and no-snapshot boot flags as Stim. +partition, system image, and default Quick Boot policy as Stim. ## Settings readiness proof diff --git a/packages/stim-cli/src/__tests__/engine-device.test.ts b/packages/stim-cli/src/__tests__/engine-device.test.ts index ffa8619b..a8c1e8b5 100644 --- a/packages/stim-cli/src/__tests__/engine-device.test.ts +++ b/packages/stim-cli/src/__tests__/engine-device.test.ts @@ -345,9 +345,7 @@ describe('ensureBooted: android', () => { timeoutMs: 5000, }); expect(result).toEqual({ ok: true, serial: 'emulator-5556' }); - expect(spawned).toEqual([ - ['emulator', '-avd', 'stim-app', '-port', '5556', '-no-snapshot-save', '-no-snapshot-load'], - ]); + expect(spawned).toEqual([['emulator', '-avd', 'stim-app', '-port', '5556']]); }); test('reuses the serial returned by a fresh owned AVD boot when adb listing briefly misses it', async () => { @@ -1162,11 +1160,11 @@ describe('ensureOwnedDevice: android', () => { const name = / -n "([^"]+)"/.exec(cmd)?.[1]; assert(name); avds.push(name); + const root = process.env.ANDROID_AVD_HOME!; + const content = join(root, `${name}.avd`); + mkdirSync(content, { recursive: true }); + writeFileSync(join(root, `${name}.ini`), `path=${content}\n`); if (writeAvdFiles) { - const root = process.env.ANDROID_AVD_HOME!; - const content = join(root, `${name}.avd`); - mkdirSync(content, { recursive: true }); - writeFileSync(join(root, `${name}.ini`), `path=${content}\n`); writeFileSync(join(content, 'config.ini'), 'hw.cpu.ncore=4\ndisk.dataPartition.size=10G\n'); } return ''; diff --git a/packages/stim-cli/src/__tests__/guide.test.ts b/packages/stim-cli/src/__tests__/guide.test.ts index c8a6ae09..bc024e14 100644 --- a/packages/stim-cli/src/__tests__/guide.test.ts +++ b/packages/stim-cli/src/__tests__/guide.test.ts @@ -431,7 +431,12 @@ test('the guide documents Android AVD disk-space diagnosis and cleanup', () => { } expect(errors).toMatch(/ENOSPC[^.]*disk space/i); expect(cleanup).toMatch(/worktree remove[^.]*deletes[^.]*owned AVD/i); - expect(cleanup).toMatch(/neither loads nor saves[^.]*Quick\s+Boot snapshot/i); + expect(cleanup).toMatch(/default Quick Boot/i); + expect(cleanup).toMatch(/displayless Linux[^.]*snapshots are[^.]*disabled/i); + expect(cleanup).toMatch(/system image, or AVD\s+settings change are cold/i); + expect(cleanup).toMatch(/stop[^.]*waits[^.]*process[^.]*snapshot save/i); + expect(cleanup).toMatch(/default to an 8 GiB data partition[^.]*project settings[^.]*change it/i); + expect(cleanup).toMatch(/Quick Boot[^.]*one automatic snapshot/i); expect(cleanup).toMatch(/gc[^.]*on-disk size[^.]*orphaned[^.]*stale owned Android AVD/i); }); diff --git a/packages/stim-cli/src/__tests__/sim-android.test.ts b/packages/stim-cli/src/__tests__/sim-android.test.ts index 74942617..d1697175 100644 --- a/packages/stim-cli/src/__tests__/sim-android.test.ts +++ b/packages/stim-cli/src/__tests__/sim-android.test.ts @@ -42,6 +42,8 @@ import { shutdownAndroidEmulator, physicalDeviceModel, resolvePhysicalDevice, + assertOwnedAvdStopped, + waitForAndroidEmulatorShutdown, waitForBoot, withAvdConfigOverrides, withAvdDataPartitionSize, @@ -139,18 +141,17 @@ test('nextConsolePort returns next even port above max claimed', () => { test('headlessEmulatorArgs is headless on displayless linux only', () => { expect(headlessEmulatorArgs({}, 'linux')).toEqual([ - '-no-snapshot-save', - '-no-snapshot-load', '-no-window', '-noaudio', '-no-boot-anim', '-gpu', 'swiftshader_indirect', + '-no-snapshot-save', + '-no-snapshot-load', ]); - const snapshotArgs = ['-no-snapshot-save', '-no-snapshot-load']; - expect(headlessEmulatorArgs({ DISPLAY: ':0' }, 'linux')).toEqual(snapshotArgs); - expect(headlessEmulatorArgs({ WAYLAND_DISPLAY: 'wayland-0' }, 'linux')).toEqual(snapshotArgs); - expect(headlessEmulatorArgs({}, 'darwin')).toEqual(snapshotArgs); + expect(headlessEmulatorArgs({ DISPLAY: ':0' }, 'linux')).toEqual([]); + expect(headlessEmulatorArgs({ WAYLAND_DISPLAY: 'wayland-0' }, 'linux')).toEqual([]); + expect(headlessEmulatorArgs({}, 'darwin')).toEqual([]); }); test('parseAvdRootIni keeps the content paths and ignores unrelated lines', () => { @@ -450,8 +451,254 @@ test('resolveOwnedAvdSerial reports notRunning when the recorded port is held by expect(resolveOwnedAvdSerial('stim-mine')).toEqual({ notRunning: true }); }); +test('resolveOwnedAvdSerial resolves an offline emulator through its console identity', () => { + setExecutor({ + run: (cmd) => { + if (cmd === 'emulator -list-avds') return 'stim-mine\n'; + if (cmd === 'adb devices') return 'List of devices attached\nemulator-5554\toffline\n'; + return ''; + }, + runQuiet: (cmd) => (/adb -s emulator-5554 emu avd name/.test(cmd) ? 'stim-mine\nOK' : null), + spawn: () => null, + }); + + expect(resolveOwnedAvdSerial('stim-mine')).toEqual({ serial: 'emulator-5554' }); +}); + +test('assertOwnedAvdStopped rejects a live process and accepts a stale lock', () => { + expect(() => + assertOwnedAvdStopped('stim-app', { + resolveDirectory: () => '/avds/stim-app.avd', + readProcessId: () => 123, + processAlive: () => true, + }), + ).toThrow(/still has a live emulator process/); + + expect(() => + assertOwnedAvdStopped('stim-app', { + resolveDirectory: () => '/avds/stim-app.avd', + readProcessId: () => 123, + processAlive: () => false, + }), + ).not.toThrow(); +}); + +test.each([ + { timeoutMs: 12_345, flushMs: 5000, killTimeoutMs: 7345 }, + { timeoutMs: 250, flushMs: 250, killTimeoutMs: 1 }, +])( + 'shutdownAndroidEmulator shares a $timeoutMs ms deadline across sync and kill', + ({ timeoutMs, flushMs, killTimeoutMs }) => { + const calls: Array<{ command: string; timeoutMs: number | undefined }> = []; + const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(flushMs); + setExecutor({ + run: () => '', + runQuiet: (command, options) => { + calls.push({ command, timeoutMs: options?.timeoutMs }); + return ''; + }, + spawn: () => null, + }); + + try { + shutdownAndroidEmulator('emulator-5554', timeoutMs); + } finally { + now.mockRestore(); + } + + expect(calls).toEqual([ + { command: 'adb -s emulator-5554 shell sync', timeoutMs: Math.min(5000, timeoutMs) }, + { command: 'adb -s emulator-5554 emu kill', timeoutMs: killTimeoutMs }, + ]); + }, +); + +test('waitForAndroidEmulatorShutdown waits for the owned AVD process lock to disappear', () => { + let locked = true; + const sleeps: number[] = []; + const calls: string[] = []; + + waitForAndroidEmulatorShutdown('stim-app', (timeoutMs) => calls.push(`shutdown:${timeoutMs}`), { + resolveDirectory: () => '/avds/stim-app.avd', + readProcessId: () => 123, + processAlive: () => locked, + directoryExists: () => true, + sleep: (ms) => { + calls.push('wait'); + sleeps.push(ms); + locked = false; + }, + }); + + expect(sleeps).toEqual([100]); + expect(calls).toEqual(['shutdown:60000', 'wait']); +}); + +test('waitForAndroidEmulatorShutdown includes the shutdown command in its deadline', () => { + let elapsed = 0; + let commandTimeout = 0; + + expect(() => + waitForAndroidEmulatorShutdown( + 'stim-app', + (timeoutMs) => { + commandTimeout = timeoutMs; + elapsed += 200; + }, + { + timeoutMs: 250, + resolveDirectory: () => '/avds/stim-app.avd', + readProcessId: () => 123, + processAlive: () => true, + directoryExists: () => true, + now: () => elapsed, + sleep: (ms) => { + elapsed += ms; + }, + }, + ), + ).toThrow(/did not finish shutting down within 1s/); + expect(commandTimeout).toBe(250); +}); + +test('waitForAndroidEmulatorShutdown reads Android emulator lock PIDs on Unix and Windows', () => { + const avdDirectory = join(tmpHome, 'stim-app.avd'); + for (const [platform, lockPath] of [ + ['darwin', join(avdDirectory, 'hardware-qemu.ini.lock')], + ['win32', join(avdDirectory, 'hardware-qemu.ini.lock', 'pid')], + ] as const) { + rmSync(avdDirectory, { recursive: true, force: true }); + mkdirSync(join(lockPath, '..'), { recursive: true }); + writeFileSync(lockPath, '412503\0'); + let observedPid: number | null = null; + + waitForAndroidEmulatorShutdown('stim-app', () => {}, { + platform, + resolveDirectory: () => avdDirectory, + processAlive: (pid) => { + observedPid = pid; + return false; + }, + }); + + expect(observedPid).toBe(412503); + } +}); + +test.each(['darwin', 'win32'] as const)( + 'assertOwnedAvdStopped refuses a present invalid %s process lock', + (platform) => { + const avdDirectory = join(tmpHome, 'stim-app.avd'); + const lockPath = join(avdDirectory, 'hardware-qemu.ini.lock', ...(platform === 'win32' ? ['pid'] : [])); + mkdirSync(join(lockPath, '..'), { recursive: true }); + const options = { platform, resolveDirectory: () => avdDirectory }; + + for (const content of ['', 'invalid', '123garbage']) { + writeFileSync(lockPath, content); + expect(() => assertOwnedAvdStopped('stim-app', options)).toThrow(/Could not read the emulator PID/); + } + + rmSync(lockPath); + expect(() => assertOwnedAvdStopped('stim-app', options)).not.toThrow(); + }, +); + +test('waitForAndroidEmulatorShutdown prefers the active process lock over the legacy fallback', () => { + const paths: string[] = []; + let observedPid: number | null = null; + + waitForAndroidEmulatorShutdown('stim-app', () => {}, { + resolveDirectory: () => '/avds/stim-app.avd', + readProcessId: (path) => { + paths.push(path); + return path.endsWith('hardware-qemu.ini.lock') ? 123 : 456; + }, + processAlive: (pid) => { + observedPid = pid; + return false; + }, + directoryExists: () => true, + }); + + expect(paths).toEqual(['/avds/stim-app.avd/hardware-qemu.ini.lock']); + expect(observedPid).toBe(123); +}); + +test('waitForAndroidEmulatorShutdown falls back to the legacy process lock', () => { + const paths: string[] = []; + let observedPid: number | null = null; + + waitForAndroidEmulatorShutdown('stim-app', () => {}, { + resolveDirectory: () => '/avds/stim-app.avd', + readProcessId: (path) => { + paths.push(path); + return path.endsWith('userdata-qemu.img.lock') ? 456 : null; + }, + processAlive: (pid) => { + observedPid = pid; + return false; + }, + directoryExists: () => true, + }); + + expect(paths).toEqual(['/avds/stim-app.avd/hardware-qemu.ini.lock', '/avds/stim-app.avd/userdata-qemu.img.lock']); + expect(observedPid).toBe(456); +}); + +test('waitForAndroidEmulatorShutdown times out while the owned AVD process lock remains', () => { + let elapsed = 0; + + expect(() => + waitForAndroidEmulatorShutdown('stim-app', () => {}, { + timeoutMs: 250, + pollMs: 100, + resolveDirectory: () => '/avds/stim-app.avd', + readProcessId: () => 123, + processAlive: () => true, + directoryExists: () => true, + now: () => elapsed, + sleep: (ms) => { + elapsed += ms; + }, + }), + ).toThrow(/did not finish shutting down within 1s/); +}); + +test('waitForAndroidEmulatorShutdown refuses to signal a process without an AVD lock', () => { + const shutdown = vi.fn<() => void>(); + + expect(() => + waitForAndroidEmulatorShutdown('stim-app', shutdown, { + resolveDirectory: () => '/avds/stim-app.avd', + readProcessId: () => null, + processAlive: () => false, + directoryExists: () => true, + }), + ).toThrow(/Could not find the emulator process lock/); + expect(shutdown).not.toHaveBeenCalled(); +}); + +test('waitForAndroidEmulatorShutdown verifies the AVD directory remains available', () => { + let locked = true; + + expect(() => + waitForAndroidEmulatorShutdown('stim-app', () => {}, { + resolveDirectory: () => '/avds/stim-app.avd', + readProcessId: () => 123, + processAlive: () => { + const result = locked; + locked = false; + return result; + }, + directoryExists: () => false, + sleep: () => {}, + }), + ).toThrow(/Could not verify the content directory/); +}); + test('shutdownAndroidEmulator bounds the guest write flush before killing the emulator', () => { const calls: Array<{ command: string; timeoutMs: number | undefined }> = []; + const now = vi.spyOn(Date, 'now').mockReturnValue(0); setExecutor({ runQuiet: (command: string, options) => { calls.push({ command, timeoutMs: options?.timeoutMs }); @@ -459,11 +706,15 @@ test('shutdownAndroidEmulator bounds the guest write flush before killing the em }, }); - shutdownAndroidEmulator('emulator-5554'); + try { + shutdownAndroidEmulator('emulator-5554'); + } finally { + now.mockRestore(); + } expect(calls).toEqual([ { command: 'adb -s emulator-5554 shell sync', timeoutMs: 5000 }, - { command: 'adb -s emulator-5554 emu kill', timeoutMs: undefined }, + { command: 'adb -s emulator-5554 emu kill', timeoutMs: 60_000 }, ]); }); @@ -575,12 +826,7 @@ test('bootAndroidEmulator spawns the resolved emulator binary', () => { if (savedDisplay === undefined) delete process.env.DISPLAY; else process.env.DISPLAY = savedDisplay; } - expect(spawned).toEqual([ - [ - join(sdk, 'emulator', 'emulator'), - ['-avd', 'stim-app', '-port', '5556', '-no-snapshot-save', '-no-snapshot-load'], - ], - ]); + expect(spawned).toEqual([[join(sdk, 'emulator', 'emulator'), ['-avd', 'stim-app', '-port', '5556']]]); }); test('listAvds keeps the bare command when resolution falls back to PATH', () => { diff --git a/packages/stim-cli/src/__tests__/teardown.test.ts b/packages/stim-cli/src/__tests__/teardown.test.ts index 8ca4f29d..17fdc46d 100644 --- a/packages/stim-cli/src/__tests__/teardown.test.ts +++ b/packages/stim-cli/src/__tests__/teardown.test.ts @@ -539,11 +539,20 @@ test('teardownOwnedAvd shuts down the running emulator and deletes the AVD', () adb: 'List of devices attached\nemulator-5554\tdevice\n', avdName: 'stim-app', }); + const resolutions = [{ serial: 'emulator-5554' }, { notRunning: true as const }]; setExecutor(exec); - const r = teardownOwnedAvd('stim-app', { del: true }); + const r = teardownOwnedAvd('stim-app', { + del: true, + waitForShutdown: (_avdName, shutdown) => shutdown(60_000), + assertStopped: () => {}, + resolveAvd: () => resolutions.shift()!, + }); expect(r.status).toBe('torn-down'); expect(exec.calls.some((c) => /emu kill/.test(c))).toBeTruthy(); expect(exec.calls.some((c) => /delete avd -n/.test(c))).toBeTruthy(); + expect(exec.calls.findIndex((c) => /emu kill/.test(c))).toBeLessThan( + exec.calls.findIndex((c) => /delete avd -n/.test(c)), + ); }); test('teardownOwnedAvd refuses an AVD that is not Stim-owned by name', () => { @@ -568,11 +577,90 @@ test('teardownOwnedAvd contains a throw instead of propagating it', () => { throwOn: 'delete avd', }), ); - const r = teardownOwnedAvd('stim-app', { del: true }); + const resolutions = [{ serial: 'emulator-5554' }, { notRunning: true as const }]; + const r = teardownOwnedAvd('stim-app', { + del: true, + waitForShutdown: (_avdName, shutdown) => shutdown(60_000), + assertStopped: () => {}, + resolveAvd: () => resolutions.shift()!, + }); expect(r.status).toBe('failed'); expect(r.reason).toMatch(/boom/); }); +test('teardownOwnedAvd does not delete an AVD when emulator shutdown times out', () => { + const exec = androidExecutor({ + avds: ['stim-app'], + adb: 'List of devices attached\nemulator-5554\tdevice\n', + avdName: 'stim-app', + }); + setExecutor(exec); + + const r = teardownOwnedAvd('stim-app', { + del: true, + waitForShutdown: (_avdName, shutdown) => { + shutdown(60_000); + throw new Error('shutdown timed out'); + }, + }); + + expect(r.status).toBe('failed'); + expect(r.reason).toMatch(/shutdown timed out/); + expect(exec.calls.some((c) => /emu kill/.test(c))).toBeTruthy(); + expect(exec.calls.some((c) => /delete avd -n/.test(c))).toBeFalsy(); +}); + +test('teardownOwnedAvd refuses an AVD with a live process that adb cannot resolve', () => { + const exec = androidExecutor({ avds: ['stim-app'], adb: 'List of devices attached\n' }); + setExecutor(exec); + + const r = teardownOwnedAvd('stim-app', { + del: true, + assertStopped: () => { + throw new Error('live emulator process'); + }, + }); + + expect(r.status).toBe('failed'); + expect(r.reason).toMatch(/live emulator process/); + expect(exec.calls.some((c) => /delete avd -n/.test(c))).toBeFalsy(); +}); + +test('teardownOwnedAvd refuses deletion when the AVD restarts after shutdown', () => { + const exec = androidExecutor({ avds: ['stim-app'], adb: 'List of devices attached\n' }); + const resolutions = [{ serial: 'emulator-5554' }, { serial: 'emulator-5556' }]; + setExecutor(exec); + + const r = teardownOwnedAvd('stim-app', { + del: true, + waitForShutdown: () => {}, + resolveAvd: () => resolutions.shift()!, + }); + + expect(r.status).toBe('failed'); + expect(r.reason).toMatch(/started again before deletion/); + expect(exec.calls.some((c) => /delete avd -n/.test(c))).toBeFalsy(); +}); + +test('teardownOwnedAvd rechecks the process lock immediately before deletion', () => { + const exec = androidExecutor({ avds: ['stim-app'], adb: 'List of devices attached\n' }); + const resolutions = [{ serial: 'emulator-5554' }, { notRunning: true as const }]; + setExecutor(exec); + + const r = teardownOwnedAvd('stim-app', { + del: true, + waitForShutdown: () => {}, + resolveAvd: () => resolutions.shift()!, + assertStopped: () => { + throw new Error('new live emulator process'); + }, + }); + + expect(r.status).toBe('failed'); + expect(r.reason).toMatch(/new live emulator process/); + expect(exec.calls.some((c) => /delete avd -n/.test(c))).toBeFalsy(); +}); + test('ownership skip outcomes carry a machine-readable kind', () => { setExecutor(iosExecutor({ sims: [{ ...OWNED, name: 'My Real Sim' }] })); expect(teardownOwnedIosSim('U1', { del: true }).kind).toBe('not-owned'); diff --git a/packages/stim-cli/src/guide/cleanup.ts b/packages/stim-cli/src/guide/cleanup.ts index 4bf5750a..6ade103e 100644 --- a/packages/stim-cli/src/guide/cleanup.ts +++ b/packages/stim-cli/src/guide/cleanup.ts @@ -175,10 +175,17 @@ DISK Android AVDs normally live under ~/.android/avd, and a booted owned AVD can use several GB. \`worktree remove\` deletes the workspace's owned AVD; plain - \`stop\` only shuts it down for reuse. Stim neither loads nor saves Quick - Boot snapshots for owned AVDs, so every restart is a full boot but exit does - not retain a large snapshot. \`gc\` prints the on-disk size beside an - orphaned or stale owned Android AVD when its content directory can be read. + \`stop\` only shuts it down for reuse. Stim uses Android's default Quick Boot + unless displayless Linux requires software rendering, where snapshots are + disabled. The first boot and a boot after the emulator, system image, or AVD + settings change are cold, while later supported boots load the one automatic + snapshot saved on exit. \`stop\` waits for the emulator process and, when + enabled, the snapshot save to finish. + New owned AVDs default to an 8 GiB data partition, though project settings can + change it. When enabled, Quick Boot keeps one automatic snapshot, and \`worktree remove\` + deletes the whole AVD. + \`gc\` prints the on-disk size beside an orphaned or stale owned Android AVD + when its content directory can be read. So are the logs, and one of them is not small: build-ios.ndjson / build-android.ndjson hold the whole xcodebuild or gradle transcript at debug diff --git a/packages/stim-cli/src/sim/android.ts b/packages/stim-cli/src/sim/android.ts index 70536123..3d52af84 100644 --- a/packages/stim-cli/src/sim/android.ts +++ b/packages/stim-cli/src/sim/android.ts @@ -13,6 +13,7 @@ import { import { homedir } from 'os'; import { dirname, isAbsolute, join, resolve } from 'path'; import { type Executor, getExecutor } from '../exec.ts'; +import { isPidAlive } from '../metro.ts'; import { androidDataPartitionSizeBytes } from '../settings.ts'; export interface SystemImage { @@ -464,9 +465,17 @@ export function headlessEmulatorArgs( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, ): string[] { - const args = ['-no-snapshot-save', '-no-snapshot-load']; + const args: string[] = []; if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY) { - args.push('-no-window', '-noaudio', '-no-boot-anim', '-gpu', 'swiftshader_indirect'); + args.push( + '-no-window', + '-noaudio', + '-no-boot-anim', + '-gpu', + 'swiftshader_indirect', + '-no-snapshot-save', + '-no-snapshot-load', + ); } return args; } @@ -721,12 +730,132 @@ export async function waitForBoot( }; } -export function shutdownAndroidEmulator(serial: string): void { +const ANDROID_EMULATOR_SHUTDOWN_TIMEOUT_MS = 60_000; +const ANDROID_EMULATOR_SHUTDOWN_POLL_MS = 100; + +export function shutdownAndroidEmulator( + serial: string, + timeoutMs: number = ANDROID_EMULATOR_SHUTDOWN_TIMEOUT_MS, +): void { const exec = getExecutor(); - if (exec.runQuiet(`${androidTool('adb')} -s ${serial} shell sync`, { timeoutMs: 5000 }) === null) { + const started = Date.now(); + if ( + exec.runQuiet(`${androidTool('adb')} -s ${serial} shell sync`, { timeoutMs: Math.min(5000, timeoutMs) }) === null + ) { console.error(`warning: could not flush ${serial} before shutdown; shutting it down anyway`); } - exec.runQuiet(`${androidTool('adb')} -s ${serial} emu kill`); + exec.runQuiet(`${androidTool('adb')} -s ${serial} emu kill`, { + timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)), + }); +} + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function avdProcessLockPaths(avdDirectory: string, platform: NodeJS.Platform): string[] { + const names = ['hardware-qemu.ini.lock', 'userdata-qemu.img.lock']; + return names.map((name) => join(avdDirectory, name, ...(platform === 'win32' ? ['pid'] : []))); +} + +function readAvdProcessId(path: string): number | null { + try { + const pid = Number(readFileSync(path, 'utf8').split('\0')[0]); + if (!Number.isSafeInteger(pid) || pid <= 0) { + throw new Error(`Could not read the emulator PID from process lock ${path}.`); + } + return pid; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return null; + throw error; + } +} + +function resolveAvdProcess( + avdName: string, + { + platform = process.platform, + resolveDirectory = ownedAvdDirectory, + readProcessId = readAvdProcessId, + }: { + platform?: NodeJS.Platform; + resolveDirectory?: typeof ownedAvdDirectory; + readProcessId?: (path: string) => number | null; + } = {}, +): { directory: string; processId: number | null } { + const directory = resolveDirectory(avdName); + if (!directory) throw new Error(`Could not resolve the content directory for owned AVD ${avdName}.`); + for (const lockPath of avdProcessLockPaths(directory, platform)) { + const processId = readProcessId(lockPath); + if (processId !== null) return { directory, processId }; + } + return { directory, processId: null }; +} + +export function assertOwnedAvdStopped( + avdName: string, + { + processAlive = isPidAlive, + ...resolveOptions + }: { + platform?: NodeJS.Platform; + resolveDirectory?: typeof ownedAvdDirectory; + readProcessId?: (path: string) => number | null; + processAlive?: (pid: number) => boolean; + } = {}, +): void { + const { processId } = resolveAvdProcess(avdName, resolveOptions); + if (processId !== null && processAlive(processId)) { + throw new Error(`Owned AVD ${avdName} still has a live emulator process (${processId}).`); + } +} + +export function waitForAndroidEmulatorShutdown( + avdName: string, + shutdown: (timeoutMs: number) => void, + { + timeoutMs = ANDROID_EMULATOR_SHUTDOWN_TIMEOUT_MS, + pollMs = ANDROID_EMULATOR_SHUTDOWN_POLL_MS, + platform = process.platform, + resolveDirectory = ownedAvdDirectory, + readProcessId = readAvdProcessId, + processAlive = isPidAlive, + directoryExists = (path: string) => statSync(path).isDirectory(), + now = Date.now, + sleep = sleepSync, + }: { + timeoutMs?: number; + pollMs?: number; + platform?: NodeJS.Platform; + resolveDirectory?: typeof ownedAvdDirectory; + readProcessId?: (path: string) => number | null; + processAlive?: (pid: number) => boolean; + directoryExists?: (path: string) => boolean; + now?: () => number; + sleep?: (ms: number) => void; + } = {}, +): void { + const { directory, processId } = resolveAvdProcess(avdName, { platform, resolveDirectory, readProcessId }); + if (processId === null) { + throw new Error(`Could not find the emulator process lock for owned AVD ${avdName}.`); + } + const deadline = now() + timeoutMs; + const shutdownTimeoutMs = deadline - now(); + if (shutdownTimeoutMs <= 0) { + throw new Error(`Owned AVD ${avdName} did not finish shutting down within ${Math.ceil(timeoutMs / 1000)}s.`); + } + shutdown(shutdownTimeoutMs); + while (processAlive(processId)) { + const remaining = deadline - now(); + if (remaining <= 0) { + throw new Error(`Owned AVD ${avdName} did not finish shutting down within ${Math.ceil(timeoutMs / 1000)}s.`); + } + sleep(Math.min(pollMs, remaining)); + } + if (!directoryExists(directory)) { + throw new Error(`Could not verify the content directory for owned AVD ${avdName} after shutdown.`); + } } export function getAvdNameForSerial(serial: string): string | null { @@ -739,7 +868,11 @@ export function resolveOwnedAvdSerial(avdName: string): ResolvedAvdSerial { if (!listAvds().includes(avdName)) return { missing: true }; if (!avdName?.startsWith('stim-')) return { notOwned: true }; const adb = listAdbDevices(); - const match = adb.emulators.find((e) => getAvdNameForSerial(e.serial) === avdName); + const candidates = [ + ...adb.emulators, + ...adb.unhealthy.filter((entry) => entry.kind === 'emulator' && entry.consolePort !== undefined), + ]; + const match = candidates.find((e) => getAvdNameForSerial(e.serial) === avdName); if (match) return { serial: match.serial }; return { notRunning: true }; } diff --git a/packages/stim-cli/src/teardown.ts b/packages/stim-cli/src/teardown.ts index 03ee1ce5..b4b590f6 100644 --- a/packages/stim-cli/src/teardown.ts +++ b/packages/stim-cli/src/teardown.ts @@ -11,7 +11,13 @@ import { shutdownIosSim, type IosSimRecord, } from './sim/ios.ts'; -import { resolveOwnedAvdSerial, shutdownAndroidEmulator, deleteAvd } from './sim/android.ts'; +import { + assertOwnedAvdStopped, + resolveOwnedAvdSerial, + shutdownAndroidEmulator, + waitForAndroidEmulatorShutdown, + deleteAvd, +} from './sim/android.ts'; import { parkSim, removeParkedAfter, type ParkedSim } from './sim-pool.ts'; export interface ParkedDevice { @@ -134,15 +140,42 @@ export function teardownOwnedIosSim( } } -export function teardownOwnedAvd(avdName: string, { del = false }: { del?: boolean } = {}): TeardownOutcome { +export function teardownOwnedAvd( + avdName: string, + { + del = false, + waitForShutdown = waitForAndroidEmulatorShutdown, + assertStopped = assertOwnedAvdStopped, + resolveAvd = resolveOwnedAvdSerial, + }: { + del?: boolean; + waitForShutdown?: typeof waitForAndroidEmulatorShutdown; + assertStopped?: typeof assertOwnedAvdStopped; + resolveAvd?: typeof resolveOwnedAvdSerial; + } = {}, +): TeardownOutcome { try { - const resolved = resolveOwnedAvdSerial(avdName); + const resolved = resolveAvd(avdName); if (resolved.notOwned) { return { status: 'skipped', kind: 'not-owned', reason: `AVD ${avdName} is not Stim-owned by name` }; } if (resolved.missing) return { status: 'missing' }; - if (resolved.serial) shutdownAndroidEmulator(resolved.serial); - if (del) deleteAvd(avdName); + const serial = resolved.serial; + if (serial) { + waitForShutdown(avdName, (timeoutMs) => shutdownAndroidEmulator(serial, timeoutMs)); + } else { + assertStopped(avdName); + } + if (del) { + const current = resolveAvd(avdName); + if (current.notOwned) { + return { status: 'skipped', kind: 'not-owned', reason: `AVD ${avdName} is not Stim-owned by name` }; + } + if (current.missing) return { status: 'missing' }; + if (current.serial) throw new Error(`Owned AVD ${avdName} started again before deletion.`); + assertStopped(avdName); + deleteAvd(avdName); + } return { status: 'torn-down', label: avdName, serial: resolved.serial ?? null }; } catch (e) { return { status: 'failed', reason: String((e as Error)?.message || e) }; diff --git a/scripts/agent-benchmark/driver.mjs b/scripts/agent-benchmark/driver.mjs index 82042f46..6a0a906f 100644 --- a/scripts/agent-benchmark/driver.mjs +++ b/scripts/agent-benchmark/driver.mjs @@ -817,7 +817,7 @@ function platformLaunchInstructions(arm, platform, runId, startMetro) { : `Use the Stim skill and only the pinned published command available on PATH as exactly \`stim\` (never through npx or an absolute path). Keep the inherited STIM_HOME unchanged. Run \`stim start\`, then run \`stim android --system-image ${JSON.stringify(pins.ANDROID_SYSTEM_IMAGE)}\`. Leave Metro and the changed app running until the screenshot is saved.`; } if (platform === 'android') { - return `Use only the project's local Expo and Android SDK tooling; do not use Stim. Create a new AVD named exactly ${JSON.stringify(`Trailhead_${runId}`)} from ${JSON.stringify(pins.ANDROID_SYSTEM_IMAGE)} using avdmanager's default hardware profile, matching Stim; do not use an existing emulator. Set disk.dataPartition.size=8589934592 in its config.ini, matching Stim's default 8 GiB data partition. Boot it with -no-snapshot-save and -no-snapshot-load, then wait for Android boot completion. ${startMetro ? 'Start Metro detached. ' : ''}Build, install, and launch only the default Debug variant; do not use a Release variant. Start that native build/install/launch as a shell background process with its PID and log under /tmp, then poll it using repeated short foreground shell calls such as \`ps -p \` and \`tail\`; do not use a long blocking shell call or end the turn while waiting. After it finishes successfully, immediately perform the agent-device proof. Leave the emulator${startMetro ? ', Metro,' : ''} and app running. `; + return `Use only the project's local Expo and Android SDK tooling; do not use Stim. Create a new AVD named exactly ${JSON.stringify(`Trailhead_${runId}`)} from ${JSON.stringify(pins.ANDROID_SYSTEM_IMAGE)} using avdmanager's default hardware profile, matching Stim; do not use an existing emulator. Set disk.dataPartition.size=8589934592 in its config.ini, matching Stim's default 8 GiB data partition. Boot it with the emulator's default Quick Boot policy, matching Stim; the fresh AVD cold-boots because no snapshot exists. Wait for Android boot completion. ${startMetro ? 'Start Metro detached. ' : ''}Build, install, and launch only the default Debug variant; do not use a Release variant. Start that native build/install/launch as a shell background process with its PID and log under /tmp, then poll it using repeated short foreground shell calls such as \`ps -p \` and \`tail\`; do not use a long blocking shell call or end the turn while waiting. After it finishes successfully, immediately perform the agent-device proof. Leave the emulator${startMetro ? ', Metro,' : ''} and app running. `; } return `Run the iOS app with the project's local Expo and Apple tooling on a new iPhone 17 simulator running iOS 26.5; do not use an existing simulator. ${startMetro ? 'Start Metro detached. ' : ''}Start the native build/install/launch as a shell background process with its PID and log under /tmp, then poll it using repeated short foreground shell calls such as \`ps -p \` and \`tail\`; do not use a long blocking shell call or end the turn while waiting. After it finishes successfully, immediately perform the agent-device proof. Leave the changed app running. Do not use Stim.`; }