From 27d9aec0a66b7ede96d2c404e062db60ec70ae9b Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 4 Sep 2026 15:49:12 -0400 Subject: [PATCH 1/3] perf: enable Android emulator Quick Boot --- docs/agent-benchmark.md | 2 +- .../stim-cli/src/__tests__/engine-device.test.ts | 4 +--- packages/stim-cli/src/__tests__/guide.test.ts | 4 +++- .../stim-cli/src/__tests__/sim-android.test.ts | 16 ++++------------ packages/stim-cli/src/commands/guide.ts | 11 +++++++---- packages/stim-cli/src/sim/android.ts | 2 +- scripts/agent-benchmark/driver.mjs | 2 +- 7 files changed, 18 insertions(+), 23 deletions(-) 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..1b317ed7 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 () => { diff --git a/packages/stim-cli/src/__tests__/guide.test.ts b/packages/stim-cli/src/__tests__/guide.test.ts index 67054765..874099f2 100644 --- a/packages/stim-cli/src/__tests__/guide.test.ts +++ b/packages/stim-cli/src/__tests__/guide.test.ts @@ -392,7 +392,9 @@ 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(/emulator, system image, or AVD settings[^.]*cold/i); + expect(cleanup).toMatch(/8 GiB data partition[^.]*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 30a02689..0b2fc743 100644 --- a/packages/stim-cli/src/__tests__/sim-android.test.ts +++ b/packages/stim-cli/src/__tests__/sim-android.test.ts @@ -136,18 +136,15 @@ 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', ]); - 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', () => { @@ -533,12 +530,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/commands/guide.ts b/packages/stim-cli/src/commands/guide.ts index 631f2438..4d0fd1d9 100644 --- a/packages/stim-cli/src/commands/guide.ts +++ b/packages/stim-cli/src/commands/guide.ts @@ -2603,10 +2603,13 @@ 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: + the first boot and a boot after the emulator, system image, or AVD settings + change are cold, while later boots load the one automatic snapshot saved on + exit. The default 8 GiB data partition bounds userdata growth, 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 0c8de9c8..d72fe44d 100644 --- a/packages/stim-cli/src/sim/android.ts +++ b/packages/stim-cli/src/sim/android.ts @@ -449,7 +449,7 @@ 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'); } diff --git a/scripts/agent-benchmark/driver.mjs b/scripts/agent-benchmark/driver.mjs index 003b21ae..49ad2b5d 100644 --- a/scripts/agent-benchmark/driver.mjs +++ b/scripts/agent-benchmark/driver.mjs @@ -816,7 +816,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.`; } From 4642c64d67d68948d53c3d68cd4a62dc974d7c17 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 4 Sep 2026 16:15:55 -0400 Subject: [PATCH 2/3] fix: wait for Android emulator shutdown --- packages/stim-cli/src/__tests__/guide.test.ts | 4 +- .../src/__tests__/sim-android.test.ts | 139 ++++++++++++++++++ .../stim-cli/src/__tests__/teardown.test.ts | 35 ++++- packages/stim-cli/src/commands/guide.ts | 6 +- packages/stim-cli/src/sim/android.ts | 73 +++++++++ packages/stim-cli/src/teardown.ts | 20 ++- 6 files changed, 269 insertions(+), 8 deletions(-) diff --git a/packages/stim-cli/src/__tests__/guide.test.ts b/packages/stim-cli/src/__tests__/guide.test.ts index 874099f2..17a7da77 100644 --- a/packages/stim-cli/src/__tests__/guide.test.ts +++ b/packages/stim-cli/src/__tests__/guide.test.ts @@ -394,7 +394,9 @@ test('the guide documents Android AVD disk-space diagnosis and cleanup', () => { expect(cleanup).toMatch(/worktree remove[^.]*deletes[^.]*owned AVD/i); expect(cleanup).toMatch(/default Quick Boot/i); expect(cleanup).toMatch(/emulator, system image, or AVD settings[^.]*cold/i); - expect(cleanup).toMatch(/8 GiB data partition[^.]*one automatic snapshot/i); + expect(cleanup).toMatch(/stop[^.]*waits[^.]*snapshot save[^.]*process/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 0b2fc743..672713a6 100644 --- a/packages/stim-cli/src/__tests__/sim-android.test.ts +++ b/packages/stim-cli/src/__tests__/sim-android.test.ts @@ -39,6 +39,7 @@ import { resolveOwnedAvdSerial, physicalDeviceModel, resolvePhysicalDevice, + waitForAndroidEmulatorShutdown, waitForBoot, withAvdConfigOverrides, withAvdDataPartitionSize, @@ -444,6 +445,144 @@ test('resolveOwnedAvdSerial reports notRunning when the recorded port is held by expect(resolveOwnedAvdSerial('stim-mine')).toEqual({ notRunning: true }); }); +test('waitForAndroidEmulatorShutdown waits for the owned AVD process lock to disappear', () => { + let locked = true; + const sleeps: number[] = []; + const calls: string[] = []; + + waitForAndroidEmulatorShutdown('stim-app', () => calls.push('shutdown'), { + 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', 'wait']); +}); + +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('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('waitForBoot keeps polling while adb still fails', async () => { let calls = 0; setExecutor({ diff --git a/packages/stim-cli/src/__tests__/teardown.test.ts b/packages/stim-cli/src/__tests__/teardown.test.ts index 8ca4f29d..1303eaa2 100644 --- a/packages/stim-cli/src/__tests__/teardown.test.ts +++ b/packages/stim-cli/src/__tests__/teardown.test.ts @@ -540,10 +540,16 @@ test('teardownOwnedAvd shuts down the running emulator and deletes the AVD', () avdName: 'stim-app', }); setExecutor(exec); - const r = teardownOwnedAvd('stim-app', { del: true }); + const r = teardownOwnedAvd('stim-app', { + del: true, + waitForShutdown: (_avdName, shutdown) => shutdown(), + }); 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 +574,36 @@ test('teardownOwnedAvd contains a throw instead of propagating it', () => { throwOn: 'delete avd', }), ); - const r = teardownOwnedAvd('stim-app', { del: true }); + const r = teardownOwnedAvd('stim-app', { + del: true, + waitForShutdown: (_avdName, shutdown) => shutdown(), + }); 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(); + 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('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/commands/guide.ts b/packages/stim-cli/src/commands/guide.ts index 4d0fd1d9..6d07c741 100644 --- a/packages/stim-cli/src/commands/guide.ts +++ b/packages/stim-cli/src/commands/guide.ts @@ -2606,8 +2606,10 @@ DISK \`stop\` only shuts it down for reuse. Stim uses Android's default Quick Boot: the first boot and a boot after the emulator, system image, or AVD settings change are cold, while later boots load the one automatic snapshot saved on - exit. The default 8 GiB data partition bounds userdata growth, Quick Boot - keeps one automatic snapshot, and \`worktree remove\` deletes the whole AVD. + exit. \`stop\` waits for that snapshot save and the emulator process to finish. + New owned AVDs default to an 8 GiB data partition, though project settings can + change it. 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. diff --git a/packages/stim-cli/src/sim/android.ts b/packages/stim-cli/src/sim/android.ts index d72fe44d..28fc54dd 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 { @@ -710,6 +711,78 @@ export function shutdownAndroidEmulator(serial: string): void { getExecutor().runQuiet(`${androidTool('adb')} -s ${serial} emu kill`); } +const ANDROID_EMULATOR_SHUTDOWN_TIMEOUT_MS = 60_000; +const ANDROID_EMULATOR_SHUTDOWN_POLL_MS = 100; + +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.parseInt(readFileSync(path, 'utf8').split('\0')[0] ?? '', 10); + return Number.isSafeInteger(pid) && pid > 0 ? pid : null; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return null; + throw error; + } +} + +export function waitForAndroidEmulatorShutdown( + avdName: string, + shutdown: () => 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 = resolveDirectory(avdName); + if (!directory) throw new Error(`Could not resolve the content directory for owned AVD ${avdName}.`); + let processId: number | null = null; + for (const lockPath of avdProcessLockPaths(directory, platform)) { + processId = readProcessId(lockPath); + if (processId !== null) break; + } + if (processId === null) { + throw new Error(`Could not find the emulator process lock for owned AVD ${avdName}.`); + } + shutdown(); + const deadline = now() + timeoutMs; + 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 { const out = getExecutor().runQuiet(`${androidTool('adb')} -s ${serial} emu avd name`); if (!out) return null; diff --git a/packages/stim-cli/src/teardown.ts b/packages/stim-cli/src/teardown.ts index 03ee1ce5..00df0871 100644 --- a/packages/stim-cli/src/teardown.ts +++ b/packages/stim-cli/src/teardown.ts @@ -11,7 +11,12 @@ import { shutdownIosSim, type IosSimRecord, } from './sim/ios.ts'; -import { resolveOwnedAvdSerial, shutdownAndroidEmulator, deleteAvd } from './sim/android.ts'; +import { + resolveOwnedAvdSerial, + shutdownAndroidEmulator, + waitForAndroidEmulatorShutdown, + deleteAvd, +} from './sim/android.ts'; import { parkSim, removeParkedAfter, type ParkedSim } from './sim-pool.ts'; export interface ParkedDevice { @@ -134,14 +139,23 @@ export function teardownOwnedIosSim( } } -export function teardownOwnedAvd(avdName: string, { del = false }: { del?: boolean } = {}): TeardownOutcome { +export function teardownOwnedAvd( + avdName: string, + { + del = false, + waitForShutdown = waitForAndroidEmulatorShutdown, + }: { del?: boolean; waitForShutdown?: typeof waitForAndroidEmulatorShutdown } = {}, +): TeardownOutcome { try { const resolved = resolveOwnedAvdSerial(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); + const serial = resolved.serial; + if (serial) { + waitForShutdown(avdName, () => shutdownAndroidEmulator(serial)); + } if (del) deleteAvd(avdName); return { status: 'torn-down', label: avdName, serial: resolved.serial ?? null }; } catch (e) { From def40a5ae8602cc069de5ab0594814bf7624001b Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 4 Sep 2026 16:33:35 -0400 Subject: [PATCH 3/3] fix: harden Android emulator shutdown --- .../src/__tests__/engine-device.test.ts | 8 +- packages/stim-cli/src/__tests__/guide.test.ts | 5 +- .../src/__tests__/sim-android.test.ts | 83 ++++++++++++++++++- .../stim-cli/src/__tests__/teardown.test.ts | 63 +++++++++++++- packages/stim-cli/src/commands/guide.ts | 12 +-- packages/stim-cli/src/sim/android.ts | 82 ++++++++++++++---- packages/stim-cli/src/teardown.ts | 27 +++++- 7 files changed, 245 insertions(+), 35 deletions(-) diff --git a/packages/stim-cli/src/__tests__/engine-device.test.ts b/packages/stim-cli/src/__tests__/engine-device.test.ts index 1b317ed7..a8c1e8b5 100644 --- a/packages/stim-cli/src/__tests__/engine-device.test.ts +++ b/packages/stim-cli/src/__tests__/engine-device.test.ts @@ -1160,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 17a7da77..ddee1ef9 100644 --- a/packages/stim-cli/src/__tests__/guide.test.ts +++ b/packages/stim-cli/src/__tests__/guide.test.ts @@ -393,8 +393,9 @@ 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(/default Quick Boot/i); - expect(cleanup).toMatch(/emulator, system image, or AVD settings[^.]*cold/i); - expect(cleanup).toMatch(/stop[^.]*waits[^.]*snapshot save[^.]*process/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 672713a6..906ecc9b 100644 --- a/packages/stim-cli/src/__tests__/sim-android.test.ts +++ b/packages/stim-cli/src/__tests__/sim-android.test.ts @@ -39,6 +39,8 @@ import { resolveOwnedAvdSerial, physicalDeviceModel, resolvePhysicalDevice, + assertOwnedAvdStopped, + shutdownAndroidEmulator, waitForAndroidEmulatorShutdown, waitForBoot, withAvdConfigOverrides, @@ -142,6 +144,8 @@ test('headlessEmulatorArgs is headless on displayless linux only', () => { '-no-boot-anim', '-gpu', 'swiftshader_indirect', + '-no-snapshot-save', + '-no-snapshot-load', ]); expect(headlessEmulatorArgs({ DISPLAY: ':0' }, 'linux')).toEqual([]); expect(headlessEmulatorArgs({ WAYLAND_DISPLAY: 'wayland-0' }, 'linux')).toEqual([]); @@ -445,12 +449,60 @@ 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('shutdownAndroidEmulator bounds the adb console command', () => { + const calls: Array<{ command: string; timeoutMs: number | undefined }> = []; + setExecutor({ + run: () => '', + runQuiet: (command, options) => { + calls.push({ command, timeoutMs: options?.timeoutMs }); + return ''; + }, + spawn: () => null, + }); + + shutdownAndroidEmulator('emulator-5554', 12_345); + + expect(calls).toEqual([{ command: 'adb -s emulator-5554 emu kill', timeoutMs: 12_345 }]); +}); + test('waitForAndroidEmulatorShutdown waits for the owned AVD process lock to disappear', () => { let locked = true; const sleeps: number[] = []; const calls: string[] = []; - waitForAndroidEmulatorShutdown('stim-app', () => calls.push('shutdown'), { + waitForAndroidEmulatorShutdown('stim-app', (timeoutMs) => calls.push(`shutdown:${timeoutMs}`), { resolveDirectory: () => '/avds/stim-app.avd', readProcessId: () => 123, processAlive: () => locked, @@ -463,7 +515,34 @@ test('waitForAndroidEmulatorShutdown waits for the owned AVD process lock to dis }); expect(sleeps).toEqual([100]); - expect(calls).toEqual(['shutdown', 'wait']); + 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', () => { diff --git a/packages/stim-cli/src/__tests__/teardown.test.ts b/packages/stim-cli/src/__tests__/teardown.test.ts index 1303eaa2..17fdc46d 100644 --- a/packages/stim-cli/src/__tests__/teardown.test.ts +++ b/packages/stim-cli/src/__tests__/teardown.test.ts @@ -539,10 +539,13 @@ 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, - waitForShutdown: (_avdName, shutdown) => shutdown(), + 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(); @@ -574,9 +577,12 @@ test('teardownOwnedAvd contains a throw instead of propagating it', () => { throwOn: 'delete avd', }), ); + const resolutions = [{ serial: 'emulator-5554' }, { notRunning: true as const }]; const r = teardownOwnedAvd('stim-app', { del: true, - waitForShutdown: (_avdName, shutdown) => shutdown(), + waitForShutdown: (_avdName, shutdown) => shutdown(60_000), + assertStopped: () => {}, + resolveAvd: () => resolutions.shift()!, }); expect(r.status).toBe('failed'); expect(r.reason).toMatch(/boom/); @@ -593,7 +599,7 @@ test('teardownOwnedAvd does not delete an AVD when emulator shutdown times out', const r = teardownOwnedAvd('stim-app', { del: true, waitForShutdown: (_avdName, shutdown) => { - shutdown(); + shutdown(60_000); throw new Error('shutdown timed out'); }, }); @@ -604,6 +610,57 @@ test('teardownOwnedAvd does not delete an AVD when emulator shutdown times out', 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/commands/guide.ts b/packages/stim-cli/src/commands/guide.ts index 6d07c741..c4b9348c 100644 --- a/packages/stim-cli/src/commands/guide.ts +++ b/packages/stim-cli/src/commands/guide.ts @@ -2603,12 +2603,14 @@ 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 uses Android's default Quick Boot: - the first boot and a boot after the emulator, system image, or AVD settings - change are cold, while later boots load the one automatic snapshot saved on - exit. \`stop\` waits for that snapshot save and the emulator process to finish. + \`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. Quick Boot keeps one automatic snapshot, and \`worktree remove\` + 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. diff --git a/packages/stim-cli/src/sim/android.ts b/packages/stim-cli/src/sim/android.ts index 28fc54dd..5d0e9789 100644 --- a/packages/stim-cli/src/sim/android.ts +++ b/packages/stim-cli/src/sim/android.ts @@ -452,7 +452,15 @@ export function headlessEmulatorArgs( ): string[] { 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; } @@ -707,13 +715,16 @@ export async function waitForBoot( }; } -export function shutdownAndroidEmulator(serial: string): void { - getExecutor().runQuiet(`${androidTool('adb')} -s ${serial} emu kill`); -} - 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 { + getExecutor().runQuiet(`${androidTool('adb')} -s ${serial} emu kill`, { timeoutMs }); +} + function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } @@ -734,9 +745,48 @@ function readAvdProcessId(path: string): number | null { } } +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: () => void, + shutdown: (timeoutMs: number) => void, { timeoutMs = ANDROID_EMULATOR_SHUTDOWN_TIMEOUT_MS, pollMs = ANDROID_EMULATOR_SHUTDOWN_POLL_MS, @@ -759,18 +809,16 @@ export function waitForAndroidEmulatorShutdown( sleep?: (ms: number) => void; } = {}, ): void { - const directory = resolveDirectory(avdName); - if (!directory) throw new Error(`Could not resolve the content directory for owned AVD ${avdName}.`); - let processId: number | null = null; - for (const lockPath of avdProcessLockPaths(directory, platform)) { - processId = readProcessId(lockPath); - if (processId !== null) break; - } + 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}.`); } - shutdown(); 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) { @@ -793,7 +841,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 00df0871..b4b590f6 100644 --- a/packages/stim-cli/src/teardown.ts +++ b/packages/stim-cli/src/teardown.ts @@ -12,6 +12,7 @@ import { type IosSimRecord, } from './sim/ios.ts'; import { + assertOwnedAvdStopped, resolveOwnedAvdSerial, shutdownAndroidEmulator, waitForAndroidEmulatorShutdown, @@ -144,19 +145,37 @@ export function teardownOwnedAvd( { del = false, waitForShutdown = waitForAndroidEmulatorShutdown, - }: { del?: boolean; waitForShutdown?: typeof 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' }; const serial = resolved.serial; if (serial) { - waitForShutdown(avdName, () => shutdownAndroidEmulator(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); } - if (del) deleteAvd(avdName); return { status: 'torn-down', label: avdName, serial: resolved.serial ?? null }; } catch (e) { return { status: 'failed', reason: String((e as Error)?.message || e) };