diff --git a/packages/stim-cli/README.md b/packages/stim-cli/README.md index 83f84209..59dd4a7d 100644 --- a/packages/stim-cli/README.md +++ b/packages/stim-cli/README.md @@ -193,7 +193,7 @@ All commands below take the same `npx stim-cli` prefix. | `android [--json] [--no-metro-check] [--no-build-cache] [--variant ]` | The same over `gradlew assembleDebug` and `adb`, on this workspace's owned emulator, with `adb reverse tcp:8081 tcp:` doing the port wiring. On a project with product flavors, `--variant productionDebug` (or the `android.variant` setting, which the flag overrides) runs `assembleProductionDebug`, finds the APK in `apk/production/debug/` and keys the build cache on the variant; the launched applicationId is always read from the built APK's manifest. A variant whose name **ends in `Release`** is a release build -- no second flag: the JS is embedded, so Metro is skipped entirely (`metroPort: null`, no `adb reverse`, no dev-client deep link, a plain `am start`, and `launched` proven by the app process being alive on the device). A release cache hit re-packs the cached APK -- copy aside, regenerate the bundle with the project's own tools + hermesc, `zip -0` it back in (stored, because the runtime mmaps it), zipalign, then `apksigner` with `android/app/debug.keystore` (override via `android.keystore` / `android.keystorePassword`). An **asset gate** compares the freshly emitted assets against the ones the APK carries and falls back to a full build on any difference, and a signer conflict from a CI-signed copy uninstalls the package once and retries. Local emulator installs only. | | `stop [--force] [--json]` | The inverse of `start`: halt this workspace's supervisor, reap its device-log collectors, shut the owned device **down** (never deleted, so it stays assigned), and free the reserved port. Non-destructive and takes no target -- it acts on the current workspace. With no supervisor recorded it falls back to killing an identity-verified Metro on the reserved port; `--force` is only for an unproven listener there. Already-stopped is a success at every step. | | `status [--json]` | Show every registered project (machine-wide by default; there is no `--all`): device assignments, Metro state, supervisor pid / mode / health, last build (fingerprint, cache hit, duration), log directory and error count since the last marker, plus machine capacity and free disk on the boot, STIM_CLI_HOME and current-project volumes when distinct. | -| `gc [--delete] [--older-than ] [--all]` | Report what stim-cli has left behind: entries for projects whose directory no longer exists, orphaned `stim-cli-*` devices, records naming a device that is no longer on the machine, and every shared build cache with its size. Reports and writes nothing by default; `--delete` reclaims the dead entries (freeing their Metro ports), reaps the orphaned devices, and clears the stale device records (the record only -- there is no device left to touch, so it issues no simctl/avdmanager command). `--older-than ` additionally reaps owned devices whose _project_ has gone untouched that long, and trims cache entries nothing has used in that time. `--all` (with `--delete`) empties the caches whole -- see below. | +| `gc [--delete] [--older-than ] [--all]` | Report what stim-cli has left behind: entries for projects whose directory no longer exists, orphaned `stim-cli-*` devices, records naming a device that is no longer on the machine, and every shared build cache with its size. Orphaned and stale owned Android AVD rows include their on-disk size when the AVD content directory can be read. Reports and writes nothing by default; `--delete` reclaims the dead entries (freeing their Metro ports), reaps the orphaned devices, and clears the stale device records (the record only -- there is no device left to touch, so it issues no simctl/avdmanager command). `--older-than ` additionally reaps owned devices whose _project_ has gone untouched that long, and trims cache entries nothing has used in that time. `--all` (with `--delete`) empties the caches whole -- see below. | | `doctor [--json]` | Report what stim-cli cannot handle on its own. The ABSENCE of a project-side cache setting is not a finding -- stim-cli supplies the Metro store (bare React Native and Expo SDK 54+), the Xcode compilation cache and the Gradle build cache on its own command lines. What it reports is active misconfiguration: a missing dev client, ccache (which is what stops stim-cli adding its own compilation cache), a `cacheStores` wired behind a conditional so it is off in the case that matters, a compilation CAS left at the per-workspace default, a configured build-cache provider on the key this SDK ignores, an EAS session that cannot answer, and -- last, because it computes a real fingerprint twice via a temporary worktree of HEAD (removed again) -- a checkout that does not fingerprint like a fresh worktree. A clean run means nothing stim-cli cannot handle itself. Read-only, and always exits 0. | | `worktree create [--base ] [--label ] [--carry-ignored]` | Create an isolated git worktree: carries over gitignored files, prints the worktree path (and, on stderr, what it branched from -- ref and short sha). `--base` takes `fresh` (origin/HEAD, the default), `head`, or any ref `git rev-parse` resolves; an unresolvable one is refused before anything is created. Does not install dependencies unless `--carry-ignored` clones the source's working state: its gitignored paths (node_modules, Pods, build output) plus its uncommitted tracked changes, applied when they fit the base and reported when they do not. | | `worktree remove [] [--force]` | Remove a worktree, reclaiming its global build artifacts, Metro port, and owned devices (deleted, not just freed). Defaults to the current workspace. Refuses if it has uncommitted or unpushed work unless `--force`, naming the right restore command per class (`git checkout --` for modified tracked files, `git clean -fd` for untracked ones). Current stim-cli state never dirties the project. On the main checkout it reclaims the environment only and never touches source files. | @@ -203,7 +203,7 @@ All commands below take the same `npx stim-cli` prefix. - **Config** at `~/.stim-cli/config.json`, keyed by absolute project path. Symlinked worktrees collapse via `realpath`. Every write goes through a lockfile and lands by atomic rename, so several agents provisioning at once cannot lose each other's device records. A config that will not parse is reported by name and never reset automatically -- it holds the records of every device stim-cli owns, and resetting it would orphan all of them. - **Port allocation:** `start` scans upward from 8082 for a port that is both unclaimed in the registry and actually free on the machine, reclaiming ports from dead projects on the way. Claiming is race-safe: the write only lands if the config still shows the port unclaimed, so two parallel runs that probe the same free port cannot both take it. A project whose directory only _looks_ gone because its volume is unmounted keeps its port. -- **Owned device creation:** on iOS, `ios` creates the newest iPhone device type -- highest generation number, base model rather than Pro/Pro Max -- on the newest installed runtime by default (or reuses the project's already-recorded owned sim, booting it if shut down). On Android, it creates an AVD via `avdmanager create avd` against the newest installed arm64 system image (stim-cli never installs system images itself -- it errors with install instructions if none is found). Override the defaults with `ios.deviceType` / `ios.runtime` / `android.systemImage` in a settings file -- see "Settings" below. +- **Owned device creation:** on iOS, `ios` creates the newest iPhone device type -- highest generation number, base model rather than Pro/Pro Max -- on the newest installed runtime by default (or reuses the project's already-recorded owned sim, booting it if shut down). On Android, it creates an AVD via `avdmanager create avd` against the newest installed arm64 system image (stim-cli never installs system images itself -- it errors with install instructions if none is found). Owned AVDs always cold-boot: the emulator neither loads nor saves a Quick Boot snapshot, avoiding a large snapshot for a disposable device at the cost of a slower restart after `stop`. Override the defaults with `ios.deviceType` / `ios.runtime` / `android.systemImage` in a settings file -- see "Settings" below. - **Runtime output is externalized.** Logs, state, pidfiles and Xcode DerivedData live under the global workspace directory, so `worktree remove` can reclaim them without project-tree state. Gradle still uses its normal project build directories; `--build-cache` points task caching at the shared Gradle user home. - **The port is never baked into a build.** The fingerprint cache shares binaries across workspaces, so a port compiled in would let a binary built for 8082 be served to a workspace holding 8083. iOS gets `RCT_jsLocation` written into the app's simulator defaults (or an `expo-development-client` deep link); Android gets `adb reverse tcp:8081 tcp:`. `RCT_METRO_PORT` is deliberately not passed to builds. - **Starting the bundler yourself still works.** Both Expo and the RN CLI probe the port and skip spawning a second bundler when one already answers `/status`, and `ios`'s Metro gate accepts a server you started as long as it runs from inside the project -- but nothing is captured that way, so `stim-cli logs` stays empty. Teardown (`stop`, `worktree remove`, `gc`) finds Metro by port via `lsof` and only kills it after confirming it answers `/status` **and** runs from inside the project: a port is not identity, so an unidentified listener is reported instead of killed. diff --git a/packages/stim-cli/skill/SKILL.md b/packages/stim-cli/skill/SKILL.md index 8d6157b6..4ff700e1 100644 --- a/packages/stim-cli/skill/SKILL.md +++ b/packages/stim-cli/skill/SKILL.md @@ -92,12 +92,12 @@ One more consequence of "the clone matches the source, not the branch": a carrie Permanent local deletion lives in exactly **two** commands. `stop` never deletes a local device, but it irreversibly ends a recorded EAS session. - **`worktree remove --force`** discards uncommitted changes and untracked files permanently. Plain `worktree remove` deletes the worktree and reaps its owned device; it refuses when the tree is dirty. **`pod install` churn no longer causes that refusal**: when the only dirt left is `/ios/Podfile.lock` or `/ios/*.xcodeproj/project.pbxproj`, tracked and unstaged, stim-cli restores those files itself and proceeds, printing `restored (pod install churn; the worktree is being removed)` for each. They are inside a directory about to be deleted wholesale, and a lockfile change anyone intended would have been committed. **One other dirty path and the whole set is refused, churn included** -- that fail-closed rule is what keeps this from ever eating real work. When it does refuse over something else, **restore what it names and retry** rather than forcing. The refusal names the right command per class: `git checkout --` for modified tracked files, `git clean -fd` (or an `rm`) for untracked ones, which checkout cannot clear. Current stim-cli state is global and never dirties the tree. It takes no argument: run it from anywhere inside the worktree, including a monorepo app dir, and it resolves the enclosing worktree root. `npx stim-cli guide errors` shows how. **On the main checkout it reclaims the environment only**: the owned device is deleted, the Metro port freed, the registry entries dropped, and the global workspace directory removed -- and it never touches source files (git cannot remove the main working tree, so the dirty-tree guard does not apply). It ends with `Reclaimed the environment; the working tree stays (it is the main checkout).` A registered project directory that is not a git repo at all gets the same environment reclaim. -- **`gc --delete`** drops dead project entries and deletes the orphaned `stim-cli-*` devices it reported, potentially tens of GB of simulator. With `--older-than ` it also reaps the device of a project nothing has touched that long. With `--all` it empties the shared build caches, which every project on the machine then pays to refill. From an Expo project, `gc` also reports orphaned `stim-cli-*` EAS Simulator sessions for the current EAS project; `--delete` stops only verified owned sessions. A project-classification or lock failure skips the EAS sweep, reports a notice, and leaves local cleanup running. A bare `gc` only reports -- it writes nothing and is always safe. +- **`gc --delete`** drops dead project entries and deletes the orphaned `stim-cli-*` devices it reported, potentially tens of GB of simulator. Android rows for orphaned and stale owned AVDs include their on-disk size when the AVD content directory can be read. With `--older-than ` it also reaps the device of a project nothing has touched that long. With `--all` it empties the shared build caches, which every project on the machine then pays to refill. From an Expo project, `gc` also reports orphaned `stim-cli-*` EAS Simulator sessions for the current EAS project; `--delete` stops only verified owned sessions. A project-classification or lock failure skips the EAS sweep, reports a notice, and leaves local cleanup running. A bare `gc` only reports -- it writes nothing and is always safe. - **`stop --force`** kills a process on the reserved port that stim-cli could not identify as yours. Plain `stop` halts the supervisor, reaps the device-log collectors, shuts the local device down (**never** deletes it), and frees the port. When the workspace owns a recorded EAS session, `stop` irreversibly ends that session. There is no `--delete` flag on `stop`. ## Capacity -A booted iOS sim is roughly 1-2 GB of RAM, an Android emulator 2-3 GB. On a 16 GB machine plan for **2-3 live environments**, not more. By default nothing enforces this. `npx stim-cli status` reports every workspace on the machine (it is machine-wide by default, not scoped to where you are standing), which is how you find out you would be the fourth. One caveat when counting: a monorepo workspace holds TWO registry entries -- the worktree root (which only reserves the label) and the app directory -- so the root's entry carries `labelOnly: true` in `--json` and is relabelled in the human view; count only the entries without it. Tear down what you're done with before creating more. +A booted iOS sim is roughly 1-2 GB of RAM, an Android emulator 2-3 GB. Owned Android AVDs neither load nor save Quick Boot snapshots: restarts are full boots, but a disposable workspace device does not retain a large snapshot on disk. On a 16 GB machine plan for **2-3 live environments**, not more. By default nothing enforces this. `npx stim-cli status` reports every workspace on the machine (it is machine-wide by default, not scoped to where you are standing), which is how you find out you would be the fourth. One caveat when counting: a monorepo workspace holds TWO registry entries -- the worktree root (which only reserves the label) and the app directory -- so the root's entry carries `labelOnly: true` in `--json` and is relabelled in the human view; count only the entries without it. Tear down what you're done with before creating more. **Opt-in concurrency limits (unlimited by default).** When a machine cannot host as many parallel builds or devices as there are agents, two machine-level caps can rein it in -- set them under a top-level `concurrency` key in `~/.stim-cli/config.json` (`{ "concurrency": { "maxBuilds": 2, "maxDevices": 3 } }`), or via `STIM_CLI_MAX_BUILDS` / `STIM_CLI_MAX_DEVICES` which override the file. `maxBuilds` caps how many builds **compile** at once (a full slate WAITS -- it is a semaphore taken after the single-flight lock, so a waiter installing another workspace's artifact never burns a slot); `maxDevices` caps how many stim-cli-owned devices are **booted** at once, and a new `ios`/`android` at that cap is **refused** with `STIM_CLI_AT_CAPACITY` (interactive-shaped: it does not queue -- stop an environment or raise the cap). Unset, `0`, or any non-positive value means no enforcement. There is no `stim-cli config` command; these are files and env vars. See `npx stim-cli guide lifecycle` and `guide settings`. diff --git a/packages/stim-cli/src/__tests__/engine-device.test.ts b/packages/stim-cli/src/__tests__/engine-device.test.ts index 088e4426..6cf86d45 100644 --- a/packages/stim-cli/src/__tests__/engine-device.test.ts +++ b/packages/stim-cli/src/__tests__/engine-device.test.ts @@ -186,7 +186,9 @@ describe('ensureBooted: android', () => { timeoutMs: 5000, }); expect(result).toEqual({ ok: true, serial: 'emulator-5556' }); - expect(spawned).toEqual([['emulator', '-avd', 'stim-cli-app', '-port', '5556']]); + expect(spawned).toEqual([ + ['emulator', '-avd', 'stim-cli-app', '-port', '5556', '-no-snapshot-save', '-no-snapshot-load'], + ]); }); 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__/fs-util.test.ts b/packages/stim-cli/src/__tests__/fs-util.test.ts index 4c6fffc6..1177eca5 100644 --- a/packages/stim-cli/src/__tests__/fs-util.test.ts +++ b/packages/stim-cli/src/__tests__/fs-util.test.ts @@ -1,7 +1,8 @@ import { mkdtempSync, symlinkSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { volumeRootFor, isRealMount, isOnMountedVolume } from '../fs-util.ts'; +import { setExecutor, resetExecutor } from '../exec.ts'; +import { directorySize, volumeRootFor, isRealMount, isOnMountedVolume } from '../fs-util.ts'; test('volumeRootFor identifies external and boot volumes', () => { expect(volumeRootFor('/Volumes/ExternalSSD/Developer/app')).toBe('/Volumes/ExternalSSD'); @@ -53,3 +54,22 @@ test('isOnMountedVolume returns false for a path it cannot resolve', () => { expect(isOnMountedVolume('~/Developer/app', ['/'])).toBe(false); expect(isOnMountedVolume('/Volumes/StimCliTestVolumeThatDoesNotExist/app', ['/'])).toBe(false); }); + +test('directorySize forwards its optional timeout and contains a timeout failure', () => { + let seenTimeout: number | undefined; + let seenArgs: string[] | undefined; + setExecutor({ + runFile(_file, args, options) { + seenArgs = args; + seenTimeout = options?.timeoutMs; + throw new Error('timed out'); + }, + }); + try { + expect(directorySize('/tmp/stim-cli-avd', { timeoutMs: 5000 })).toBe(0); + expect(seenArgs).toEqual(['-sk', '/tmp/stim-cli-avd']); + expect(seenTimeout).toBe(5000); + } finally { + resetExecutor(); + } +}); diff --git a/packages/stim-cli/src/__tests__/gc.test.ts b/packages/stim-cli/src/__tests__/gc.test.ts index 9822c151..f8cfc157 100644 --- a/packages/stim-cli/src/__tests__/gc.test.ts +++ b/packages/stim-cli/src/__tests__/gc.test.ts @@ -114,6 +114,84 @@ test('findOrphanedDevices proposes only stim-cli devices absent from config', () expect(result.orphaned.map((o) => o.id).toSorted()).toEqual(['U1', 'stim-cli-old']); }); +test('gc sizes only listed owned Android AVDs after ownership classification', async () => { + const now = Date.now(); + const project = join(tmpHome, 'stale-project'); + mkdirSync(project, { recursive: true }); + saveConfig({ + version: 2, + projects: { + [project]: { platforms: { android: { avdName: 'stim-cli-stale', owned: true } } }, + }, + repos: {}, + }); + const sized: string[] = []; + const sizeTimeouts: Array = []; + setExecutor({ + run(cmd) { + if (cmd.includes('simctl list devices --json')) return JSON.stringify({ devices: {} }); + if (cmd.endsWith(' -list-avds')) { + return 'stim-cli-orphan\nstim-cli-stale\nstim-cli-unreadable\nPixel_7\n'; + } + throw new Error(`unexpected run: ${cmd}`); + }, + runQuiet: () => null, + spawn: () => null, + }); + + const report = await collectGcReport( + { + olderThan: 30, + now, + lastTouched: () => now - 90 * DAY_MS, + unsafeAllowScopedDeviceSweep: true, + }, + { + avdDirectory: (name) => `/avds/${name}.avd`, + directorySize: (dir, options) => { + sized.push(dir); + sizeTimeouts.push(options?.timeoutMs); + if (dir.includes('unreadable')) throw new Error('timed out'); + return dir.includes('orphan') ? 5 * 1024 ** 3 : 2 * 1024 ** 3; + }, + precollectedEasSessionSweep: { + projectScope: null, + orphaned: [], + notices: [], + deletionSafe: true, + }, + }, + ); + + expect(report.orphanedDevices).toContainEqual({ + kind: 'android', + id: 'stim-cli-orphan', + name: 'stim-cli-orphan', + bytes: 5 * 1024 ** 3, + }); + expect(report.staleDevices).toContainEqual({ + kind: 'android', + id: 'stim-cli-stale', + name: 'stim-cli-stale', + project, + idleDays: 90, + bytes: 2 * 1024 ** 3, + }); + expect(report.orphanedDevices).toContainEqual({ + kind: 'android', + id: 'stim-cli-unreadable', + name: 'stim-cli-unreadable', + }); + expect(sized).toEqual(['/avds/stim-cli-orphan.avd', '/avds/stim-cli-unreadable.avd', '/avds/stim-cli-stale.avd']); + expect(sizeTimeouts).toEqual([5000, 5000, 5000]); + + const output = formatGcReport(report).join('\n'); + expect(output).toMatch(/stim-cli-orphan.*5\.0G on disk/); + expect(output).toMatch(/stim-cli-stale.*2\.0G on disk/); + expect(output).not.toMatch(/stim-cli-unreadable.*on disk/); + expect(output).not.toMatch(/Pixel_7/); +}); + test('devices referenced by a project on an unmounted volume are kept', () => { const result = findOrphanedDevices({ sims: [makeIosSim({ udid: 'U1', name: 'stim-cli-ext' })], diff --git a/packages/stim-cli/src/__tests__/guide.test.ts b/packages/stim-cli/src/__tests__/guide.test.ts index fa465dc8..c9d57d0c 100644 --- a/packages/stim-cli/src/__tests__/guide.test.ts +++ b/packages/stim-cli/src/__tests__/guide.test.ts @@ -128,6 +128,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(/gc[^.]*on-disk size[^.]*orphaned[^.]*stale owned Android AVD/i); + + const skill = readFileSync(new URL('../../skill/SKILL.md', import.meta.url), 'utf-8'); + expect(skill).toMatch(/neither load nor save[^.]*Quick Boot snapshot/i); + expect(skill).toMatch(/Android rows[^.]*on-disk size/i); }); test('the guide documents remote providers and backend credential boundaries', () => { diff --git a/packages/stim-cli/src/__tests__/sim-android.test.ts b/packages/stim-cli/src/__tests__/sim-android.test.ts index a7507f27..e08b0c22 100644 --- a/packages/stim-cli/src/__tests__/sim-android.test.ts +++ b/packages/stim-cli/src/__tests__/sim-android.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { setExecutor, resetExecutor } from '../exec.ts'; @@ -16,8 +16,10 @@ import { parseAvdList, parseAdbDevices, nextConsolePort, + parseAvdRootIni, pickDefaultSystemImage, hostSystemImageArch, + ownedAvdDirectory, deleteAvd, resolveOwnedAvdSerial, waitForBoot, @@ -115,15 +117,109 @@ 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', ]); - expect(headlessEmulatorArgs({ DISPLAY: ':0' }, 'linux')).toEqual([]); - expect(headlessEmulatorArgs({ WAYLAND_DISPLAY: 'wayland-0' }, 'linux')).toEqual([]); - expect(headlessEmulatorArgs({}, 'darwin')).toEqual([]); + 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); +}); + +test('parseAvdRootIni keeps the content paths and ignores unrelated lines', () => { + expect( + parseAvdRootIni( + 'avd.ini.encoding=UTF-8\npath = /moved/stim-cli-app.avd\npath.rel=avd/stim-cli-app.avd\ntarget=android-36\n', + ), + ).toEqual({ path: '/moved/stim-cli-app.avd', relativePath: 'avd/stim-cli-app.avd' }); +}); + +function writeAvdRoot(root: string, name: string, contents: string): void { + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, `${name}.ini`), contents); +} + +test('ownedAvdDirectory uses AVD_HOME, SDK_HOME, then HOME precedence', () => { + const avdHome = join(tmpHome, 'avd-home'); + const sdkHome = join(tmpHome, 'android-sdk-home'); + const home = join(tmpHome, 'home'); + const candidates: [string, string, string] = [ + join(tmpHome, 'custom.avd'), + join(tmpHome, 'user.avd'), + join(tmpHome, 'default.avd'), + ]; + for (const candidate of candidates) mkdirSync(candidate, { recursive: true }); + writeAvdRoot(avdHome, 'stim-cli-app', `path=${candidates[0]}\n`); + writeAvdRoot(join(sdkHome, 'avd'), 'stim-cli-app', `path=${candidates[1]}\n`); + writeAvdRoot(join(home, '.android', 'avd'), 'stim-cli-app', `path=${candidates[2]}\n`); + + expect( + ownedAvdDirectory('stim-cli-app', { env: { ANDROID_AVD_HOME: avdHome, ANDROID_SDK_HOME: sdkHome }, home }), + ).toBe(realpathSync(candidates[0])); + expect(ownedAvdDirectory('stim-cli-app', { env: { ANDROID_SDK_HOME: sdkHome }, home })).toBe( + realpathSync(candidates[1]), + ); + expect(ownedAvdDirectory('stim-cli-app', { env: {}, home })).toBe(realpathSync(candidates[2])); +}); + +test('ownedAvdDirectory resolves moved, relative, and symlinked content directories', () => { + const sdkHome = join(tmpHome, 'android-sdk-home'); + const root = join(sdkHome, 'avd'); + const moved = join(tmpHome, 'moved', 'stim-cli-moved.avd'); + const relative = join(sdkHome, 'elsewhere', 'stim-cli-relative.avd'); + const target = join(tmpHome, 'target', 'stim-cli-linked.avd'); + const link = join(tmpHome, 'linked.avd'); + for (const dir of [moved, relative, target]) mkdirSync(dir, { recursive: true }); + symlinkSync(target, link, 'dir'); + writeAvdRoot(root, 'stim-cli-moved', `path=${moved}\n`); + writeAvdRoot( + root, + 'stim-cli-relative', + `path=${join(tmpHome, 'missing.avd')}\npath.rel=elsewhere/stim-cli-relative.avd\n`, + ); + writeAvdRoot(root, 'stim-cli-linked', `path=${link}\n`); + const options = { env: { ANDROID_SDK_HOME: sdkHome }, home: join(tmpHome, 'home') }; + + expect(ownedAvdDirectory('stim-cli-moved', options)).toBe(realpathSync(moved)); + expect(ownedAvdDirectory('stim-cli-relative', options)).toBe(realpathSync(relative)); + expect(ownedAvdDirectory('stim-cli-linked', options)).toBe(realpathSync(target)); +}); + +test('ownedAvdDirectory fails closed for invalid names and a malformed selected root', () => { + const avdHome = join(tmpHome, 'avd-home'); + const fallbackRoot = join(tmpHome, 'home', '.android', 'avd'); + const fallback = join(tmpHome, 'fallback.avd'); + mkdirSync(fallback, { recursive: true }); + writeAvdRoot(avdHome, 'stim-cli-app', 'target=android-36\n'); + writeAvdRoot(fallbackRoot, 'stim-cli-app', `path=${fallback}\n`); + const options = { env: { ANDROID_AVD_HOME: avdHome }, home: join(tmpHome, 'home') }; + + expect(ownedAvdDirectory('stim-cli-app', options)).toBe(null); + expect(ownedAvdDirectory('Pixel_7', options)).toBe(null); + expect(ownedAvdDirectory('stim-cli-../../outside', options)).toBe(null); +}); + +test('ownedAvdDirectory returns null when every emulator root is missing and ignores USER_HOME', () => { + const userHome = join(tmpHome, 'android-user-home'); + const content = join(tmpHome, 'unsupported-user-home.avd'); + mkdirSync(content, { recursive: true }); + writeAvdRoot(join(userHome, 'avd'), 'stim-cli-app', `path=${content}\n`); + + expect( + ownedAvdDirectory('stim-cli-app', { + env: { + ANDROID_AVD_HOME: join(tmpHome, 'missing-avd-home'), + ANDROID_SDK_HOME: join(tmpHome, 'missing-sdk-home'), + ANDROID_USER_HOME: userHome, + }, + home: join(tmpHome, 'missing-home'), + }), + ).toBe(null); }); test('pickDefaultSystemImage prefers highest api, then google_apis, arm64 only', () => { @@ -359,7 +455,12 @@ 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-cli-app', '-port', '5556']]]); + expect(spawned).toEqual([ + [ + join(sdk, 'emulator', 'emulator'), + ['-avd', 'stim-cli-app', '-port', '5556', '-no-snapshot-save', '-no-snapshot-load'], + ], + ]); }); test('listAvds keeps the bare command when resolution falls back to PATH', () => { diff --git a/packages/stim-cli/src/commands/gc.ts b/packages/stim-cli/src/commands/gc.ts index 4bbd590e..2f42c231 100644 --- a/packages/stim-cli/src/commands/gc.ts +++ b/packages/stim-cli/src/commands/gc.ts @@ -4,7 +4,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'; import chalk from 'chalk'; import { InvalidArgumentError, type Command } from 'commander'; import { clearDevice, getConfigDir, loadConfig } from '../config.ts'; -import { formatBytes, isOnMountedVolume, listMountedVolumes, volumeRootFor } from '../fs-util.ts'; +import { directorySize, formatBytes, isOnMountedVolume, listMountedVolumes, volumeRootFor } from '../fs-util.ts'; import { listBuildLocks, readBuildLock } from '../engine/build-lock.ts'; import { listBuildSlots, readBuildSlot } from '../engine/build-slots.ts'; import { @@ -25,7 +25,7 @@ import { detectIsExpo, findProjectRoot } from '../project.ts'; import { reclaimProject } from '../reclaim.ts'; import { listAllIosSims, type IosSimRecord } from '../sim/ios.ts'; import { teardownOwnedIosSim, teardownOwnedAvd } from '../teardown.ts'; -import { listAvds } from '../sim/android.ts'; +import { listAvds, ownedAvdDirectory } from '../sim/android.ts'; import { declaredCachePaths, discoverCaches, pruneCache, sizeCaches, type CacheDescriptor } from '../caches.ts'; import { workspaceDir, workspaceStateFile } from '../paths.ts'; import { withRemoteSessionLock } from '../engine/device-remote.ts'; @@ -44,6 +44,7 @@ interface StaleProjectDevice { name: string; project: string; idleDays: number; + bytes?: number; } interface StaleDeviceRecord { @@ -124,10 +125,13 @@ interface GcDependencies { easLedgerRoot?: string; removeEasSessionClaim?: typeof removeEasSessionClaim; precollectedEasSessionSweep?: EasSessionSweep; + avdDirectory?: typeof ownedAvdDirectory; + directorySize?: typeof directorySize; } // simctl and emulator listings can exceed 10 seconds on loaded hosts; 30 seconds still bounds hangs. const DEVICE_LIST_TIMEOUT_MS = 30000; +const AVD_SIZE_TIMEOUT_MS = 5000; const EAS_OPERATION_TIMEOUT_MS = 30000; const EAS_COLLECTION_TIMEOUT_MS = 60000; const EAS_MAX_LIST_PAGES = 100; @@ -640,13 +644,13 @@ export function formatGcReport({ if (orphanedDevices.length) { lines.push(`Orphaned devices (${orphanedDevices.length}):`); - for (const d of orphanedDevices) lines.push(` ${d.kind} ${d.name} (${d.id})`); + for (const d of orphanedDevices) lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`); } if (staleDevices.length) { lines.push(`Stale owned devices (${staleDevices.length}) - project untouched for ${olderThan ?? '?'}d or more:`); for (const d of staleDevices) { - lines.push(` ${d.kind} ${d.name} (${d.id})`); + lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`); lines.push(` ${d.project} (idle ${d.idleDays}d)`); } } @@ -731,6 +735,30 @@ export function formatGcReport({ return lines; } +function deviceSizeSuffix(device: { kind: 'ios' | 'android'; bytes?: number }): string { + return device.kind === 'android' && device.bytes !== undefined ? ` - ${formatBytes(device.bytes)} on disk` : ''; +} + +function withAndroidAvdSizes( + devices: T[], + { + avdDirectory = ownedAvdDirectory, + size = directorySize, + }: { avdDirectory?: typeof ownedAvdDirectory; size?: typeof directorySize } = {}, +): T[] { + return devices.map((device) => { + if (device.kind !== 'android') return device; + const dir = avdDirectory(device.id); + if (!dir) return device; + try { + const bytes = size(dir, { timeoutMs: AVD_SIZE_TIMEOUT_MS }); + return bytes > 0 ? { ...device, bytes } : device; + } catch { + return device; + } + }); +} + function projectLastTouched(path: string): number { try { return statSync(path).mtimeMs; @@ -920,7 +948,13 @@ export async function collectGcReport( } const isMounted = (path: string) => isOnMountedVolume(path, mountedVolumes); - orphanedDevices = findOrphanedDevices({ sims, avds, config: cfg, isMounted, deadProjects }).orphaned; + orphanedDevices = withAndroidAvdSizes( + findOrphanedDevices({ sims, avds, config: cfg, isMounted, deadProjects }).orphaned, + { + avdDirectory: deps.avdDirectory ?? ownedAvdDirectory, + size: deps.directorySize ?? directorySize, + }, + ); staleDeviceRecords = findStaleDeviceRecords({ config: cfg, sims, @@ -930,15 +964,21 @@ export async function collectGcReport( avdsChecked, }); if (olderThan !== null) { - staleDevices = findStaleProjectDevices({ - config: cfg, - sims, - avds, - olderThanDays: olderThan, - now, - lastTouched, - deadProjects, - }); + staleDevices = withAndroidAvdSizes( + findStaleProjectDevices({ + config: cfg, + sims, + avds, + olderThanDays: olderThan, + now, + lastTouched, + deadProjects, + }), + { + avdDirectory: deps.avdDirectory ?? ownedAvdDirectory, + size: deps.directorySize ?? directorySize, + }, + ); } } diff --git a/packages/stim-cli/src/commands/guide.ts b/packages/stim-cli/src/commands/guide.ts index 80e5af96..240798ea 100644 --- a/packages/stim-cli/src/commands/guide.ts +++ b/packages/stim-cli/src/commands/guide.ts @@ -1162,7 +1162,10 @@ 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. + \`stop\` only shuts it down for reuse. stim-cli 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. 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/fs-util.ts b/packages/stim-cli/src/fs-util.ts index 4e36dcda..83392ed3 100644 --- a/packages/stim-cli/src/fs-util.ts +++ b/packages/stim-cli/src/fs-util.ts @@ -91,11 +91,13 @@ export function isOnMountedVolume(path: string, mountedVolumes?: string[]): bool return mounted.has(volume); } -const SHELL_METACHARS = /[`$"\\]/; - -export function directorySize(dir: string): number { - if (SHELL_METACHARS.test(dir)) return 0; - const out = getExecutor().runQuiet(`du -sk "${dir}"`); +export function directorySize(dir: string, { timeoutMs }: { timeoutMs?: number } = {}): number { + let out: string; + try { + out = getExecutor().runFile('du', ['-sk', dir], { timeoutMs }); + } catch { + return 0; + } if (!out) return 0; const kb = parseInt(out.split(/\s+/)[0] ?? '', 10); return isNaN(kb) ? 0 : kb * 1024; diff --git a/packages/stim-cli/src/sim/android.ts b/packages/stim-cli/src/sim/android.ts index 42ae251e..707ea8ab 100644 --- a/packages/stim-cli/src/sim/android.ts +++ b/packages/stim-cli/src/sim/android.ts @@ -1,6 +1,6 @@ -import { existsSync, mkdirSync, openSync, readdirSync } from 'fs'; +import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, statSync } from 'fs'; import { homedir } from 'os'; -import { dirname, join } from 'path'; +import { dirname, isAbsolute, join, resolve } from 'path'; import { type Executor, getExecutor } from '../exec.ts'; export interface SystemImage { @@ -265,10 +265,76 @@ export function headlessEmulatorArgs( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, ): string[] { + const args = ['-no-snapshot-save', '-no-snapshot-load']; if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY) { - return ['-no-window', '-noaudio', '-no-boot-anim', '-gpu', 'swiftshader_indirect']; + args.push('-no-window', '-noaudio', '-no-boot-anim', '-gpu', 'swiftshader_indirect'); } - return []; + return args; +} + +export function parseAvdRootIni(contents: string): { path: string | null; relativePath: string | null } { + let path: string | null = null; + let relativePath: string | null = null; + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || line.startsWith(';')) continue; + const separator = line.indexOf('='); + if (separator < 0) continue; + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + if (!value) continue; + if (key === 'path') path = value; + if (key === 'path.rel') relativePath = value; + } + return { path, relativePath }; +} + +export function ownedAvdDirectory( + avdName: string, + { + env = process.env, + home = homedir(), + readFile = (path: string) => readFileSync(path, 'utf8'), + realpath = realpathSync, + isDirectory = (path: string) => statSync(path).isDirectory(), + }: { + env?: NodeJS.ProcessEnv; + home?: string; + readFile?: (path: string) => string; + realpath?: (path: string) => string; + isDirectory?: (path: string) => boolean; + } = {}, +): string | null { + if (!/^stim-cli-[A-Za-z0-9._-]+$/.test(avdName)) return null; + const roots = [ + env.ANDROID_AVD_HOME, + env.ANDROID_SDK_HOME ? join(env.ANDROID_SDK_HOME, 'avd') : null, + join(home, '.android', 'avd'), + ]; + for (const root of new Set(roots.filter((value): value is string => Boolean(value)))) { + let ini: string; + try { + ini = readFile(join(root, `${avdName}.ini`)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') continue; + return null; + } + const parsed = parseAvdRootIni(ini); + const candidates = [ + parsed.relativePath && !isAbsolute(parsed.relativePath) ? resolve(dirname(root), parsed.relativePath) : null, + parsed.path && isAbsolute(parsed.path) ? parsed.path : null, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + try { + const canonical = realpath(candidate); + if (isDirectory(canonical)) return canonical; + } catch {} + } + return null; + } + return null; } export function bootAndroidEmulator( diff --git a/packages/stim-cli/src/types.ts b/packages/stim-cli/src/types.ts index 19156309..51b5360e 100644 --- a/packages/stim-cli/src/types.ts +++ b/packages/stim-cli/src/types.ts @@ -150,4 +150,5 @@ export interface OrphanedDevice { kind: 'ios' | 'android'; id: string; name: string; + bytes?: number; }