mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
324d8c6049
## Summary `hasDefaultBootSnapshot` enforced a 60s mtime-skew guard between `snapshot.pb` and `ram.bin`. In practice the emulator updates `snapshot.pb` metadata on every load (load count, last-loaded timestamp) even with `-no-snapshot-save`, so the two mtimes drift apart by hours or days after a few hot-boot sessions and the guard rejects every valid snapshot — forcing a cold boot every time. The bug hits macOS too; Linux just makes it painful because cold boot is slow there. Confirmed against my own `Pixel_6_API_34`, whose `snapshot.pb` is 4 days newer than `ram.bin` while the snapshot itself is fully usable. ## What changed - **Drop the mtime-skew guard.** `-check-snapshot-loadable` + the hot-boot spawn's `-force-snapshot-load` already make truncated / corrupt `ram.bin` fail loudly via `attemptBoot`'s `earlyExitError` race. The on-disk pre-check only needs to confirm both files exist and `ram.bin` is non-empty. - **Resolve the `.avd` folder via the AVD's `.ini` `path=` line**, matching what the emulator binary itself does (`external/qemu/android/android-emu/android/avd/util.cpp`). Snap-installed Studio on Linux and users who relocate AVDs to faster disks both put `.avd` outside the convention root, where the old `<root>/<name>.avd` assumption silently failed. - **Honor `$ANDROID_USER_HOME`, `$XDG_CONFIG_HOME`, `$ANDROID_SDK_HOME`** in priority order alongside `$ANDROID_AVD_HOME` and `$HOME/.android/avd`, mirroring the emulator binary's own lookup precedence. - **Reject whitespace-only and relative `path=` values.** The regex's non-greedy `(.+?)` still captures a single space for `path= `, which would have leaked past `if (!avdPath)` as truthy; a relative `path=` would have resolved against `process.cwd()` and silently mis-located snapshots. ## Test plan - [x] 18 new unit tests in `avd-snapshot.test.ts` covering: priority order across all five env-var candidates, relocated AVDs, drifted mtimes (4-day skew explicitly regression-tested), zero-length / missing / directory-shaped files, whitespace-only paths, relative paths, bad-then-good fallthrough. - [x] Full `npm run test -w @argent/tool-server` — 703 passing. - [x] `npm run typecheck:tests -w @argent/tool-server` — clean. - [x] `npm run build` from workspace root — clean. - [x] `npx prettier --check` on changed files — clean. - [x] End-to-end repro against a real local AVD: drifted `Pixel_3a_API_34`'s `snapshot.pb` mtime to 5 days newer than `ram.bin` (the exact pathology in the wild), drove `boot-device` through this branch's built code, observed `qemu-system-aarch64 ... -force-snapshot-load -no-snapshot-save` in the spawned process — i.e. the hot-boot path was taken with a snapshot the old check would have rejected. ## Risk Pre-check is now more lenient on disk shape. If `ram.bin` is non-empty but truncated/corrupt, the hot-boot spawn still catches it via `-force-snapshot-load` (loud early child-exit) and `attemptBoot` falls back to cold boot. So the worst case has not regressed; it just surfaces at spawn-time instead of pre-spawn.
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { mkdir, mkdtemp, rm, writeFile, utimes } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { hasDefaultBootSnapshot, resolveAvdPath } from "../src/utils/adb";
|
|
|
|
// Env vars `resolveAvdPath` consults. Snapshot them up-front so a failing
|
|
// assertion can't leak ANDROID_* state into adjacent suites (vitest reuses
|
|
// workers between files and a stray ANDROID_USER_HOME would silently retarget
|
|
// any AVD-aware test that runs next).
|
|
const ENV_KEYS = [
|
|
"HOME",
|
|
"ANDROID_USER_HOME",
|
|
"ANDROID_AVD_HOME",
|
|
"ANDROID_SDK_HOME",
|
|
"XDG_CONFIG_HOME",
|
|
] as const;
|
|
const originalEnv: Record<string, string | undefined> = {};
|
|
|
|
async function writeAvdIni(root: string, avdName: string, avdPath: string): Promise<void> {
|
|
await mkdir(root, { recursive: true });
|
|
await writeFile(
|
|
join(root, `${avdName}.ini`),
|
|
[
|
|
"avd.ini.encoding=UTF-8",
|
|
`path=${avdPath}`,
|
|
`path.rel=avd/${avdName}.avd`,
|
|
"target=android-34",
|
|
"",
|
|
].join("\n")
|
|
);
|
|
}
|
|
|
|
interface SnapshotOptions {
|
|
ramBytes?: number;
|
|
ramMtimeMs?: number;
|
|
snapshotMtimeMs?: number;
|
|
withoutSnapshotPb?: boolean;
|
|
withoutRamBin?: boolean;
|
|
}
|
|
|
|
async function writeDefaultBootSnapshot(
|
|
avdPath: string,
|
|
opts: SnapshotOptions = {}
|
|
): Promise<void> {
|
|
const dir = join(avdPath, "snapshots", "default_boot");
|
|
await mkdir(dir, { recursive: true });
|
|
if (!opts.withoutSnapshotPb) {
|
|
const pb = join(dir, "snapshot.pb");
|
|
await writeFile(pb, Buffer.alloc(1024));
|
|
if (opts.snapshotMtimeMs !== undefined) {
|
|
const t = opts.snapshotMtimeMs / 1000;
|
|
await utimes(pb, t, t);
|
|
}
|
|
}
|
|
if (!opts.withoutRamBin) {
|
|
const ram = join(dir, "ram.bin");
|
|
await writeFile(ram, Buffer.alloc(opts.ramBytes ?? 4096));
|
|
if (opts.ramMtimeMs !== undefined) {
|
|
const t = opts.ramMtimeMs / 1000;
|
|
await utimes(ram, t, t);
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("resolveAvdPath", () => {
|
|
let tmpRoot: string;
|
|
|
|
beforeEach(async () => {
|
|
for (const k of ENV_KEYS) originalEnv[k] = process.env[k];
|
|
tmpRoot = await mkdtemp(join(tmpdir(), "argent-avd-snapshot-"));
|
|
// Force HOME into the temp tree so a stray ~/.android on the host
|
|
// running the suite cannot accidentally satisfy a lookup.
|
|
process.env.HOME = join(tmpRoot, "home");
|
|
await mkdir(process.env.HOME, { recursive: true });
|
|
delete process.env.ANDROID_USER_HOME;
|
|
delete process.env.ANDROID_AVD_HOME;
|
|
delete process.env.ANDROID_SDK_HOME;
|
|
delete process.env.XDG_CONFIG_HOME;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const k of ENV_KEYS) {
|
|
if (originalEnv[k] === undefined) delete process.env[k];
|
|
else process.env[k] = originalEnv[k];
|
|
}
|
|
await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("resolves the path from $HOME/.android/avd/<name>.ini when no env vars are set", async () => {
|
|
const avdRoot = join(process.env.HOME!, ".android", "avd");
|
|
const avdPath = join(avdRoot, "Pixel_7.avd");
|
|
await mkdir(avdPath, { recursive: true });
|
|
await writeAvdIni(avdRoot, "Pixel_7", avdPath);
|
|
|
|
expect(await resolveAvdPath("Pixel_7")).toBe(avdPath);
|
|
});
|
|
|
|
it("reads the `path=` line even when the .avd folder lives outside the convention root", async () => {
|
|
// Studio lets users move AVDs onto a faster disk; the `.ini` stays in the
|
|
// convention root but `path=` points at the relocated `.avd`. The old
|
|
// code that assumed `<root>/<name>.avd` would miss the snapshot entirely.
|
|
const iniRoot = join(process.env.HOME!, ".android", "avd");
|
|
const avdPath = join(tmpRoot, "fast-disk", "Pixel_7.avd");
|
|
await mkdir(avdPath, { recursive: true });
|
|
await writeAvdIni(iniRoot, "Pixel_7", avdPath);
|
|
|
|
expect(await resolveAvdPath("Pixel_7")).toBe(avdPath);
|
|
});
|
|
|
|
it("prefers $ANDROID_USER_HOME/avd over $ANDROID_AVD_HOME and the default HOME path", async () => {
|
|
// Mirror the emulator binary's priority order so the resolver follows the
|
|
// same AVD the binary itself will load. If priority were inverted, our
|
|
// pre-check could read a stale snapshot from $HOME while the binary later
|
|
// loads a different (or absent) snapshot from $ANDROID_USER_HOME.
|
|
const userHomeRoot = join(tmpRoot, "user-home", "avd");
|
|
const avdHomeRoot = join(tmpRoot, "avd-home");
|
|
const homeRoot = join(process.env.HOME!, ".android", "avd");
|
|
|
|
await writeAvdIni(userHomeRoot, "Pixel_7", "/from/user-home");
|
|
await writeAvdIni(avdHomeRoot, "Pixel_7", "/from/avd-home");
|
|
await writeAvdIni(homeRoot, "Pixel_7", "/from/home");
|
|
|
|
process.env.ANDROID_USER_HOME = join(tmpRoot, "user-home");
|
|
process.env.ANDROID_AVD_HOME = avdHomeRoot;
|
|
|
|
expect(await resolveAvdPath("Pixel_7")).toBe("/from/user-home");
|
|
});
|
|
|
|
it("falls back to $ANDROID_AVD_HOME when ANDROID_USER_HOME is unset", async () => {
|
|
const avdHomeRoot = join(tmpRoot, "avd-home");
|
|
await writeAvdIni(avdHomeRoot, "Pixel_7", "/from/avd-home");
|
|
process.env.ANDROID_AVD_HOME = avdHomeRoot;
|
|
|
|
expect(await resolveAvdPath("Pixel_7")).toBe("/from/avd-home");
|
|
});
|
|
|
|
it("honors $XDG_CONFIG_HOME/Android/avd on Linux-style setups", async () => {
|
|
const xdg = join(tmpRoot, "xdg");
|
|
await writeAvdIni(join(xdg, "Android", "avd"), "Pixel_7", "/from/xdg");
|
|
process.env.XDG_CONFIG_HOME = xdg;
|
|
|
|
expect(await resolveAvdPath("Pixel_7")).toBe("/from/xdg");
|
|
});
|
|
|
|
it("honors the legacy $ANDROID_SDK_HOME/.android/avd path", async () => {
|
|
const sdkHome = join(tmpRoot, "sdk-home");
|
|
await writeAvdIni(join(sdkHome, ".android", "avd"), "Pixel_7", "/from/sdk-home");
|
|
process.env.ANDROID_SDK_HOME = sdkHome;
|
|
|
|
expect(await resolveAvdPath("Pixel_7")).toBe("/from/sdk-home");
|
|
});
|
|
|
|
it("returns null when no .ini is found in any candidate root", async () => {
|
|
expect(await resolveAvdPath("DoesNotExist")).toBeNull();
|
|
});
|
|
|
|
it("trims trailing whitespace from the `path=` value", async () => {
|
|
// Some Studio versions write a trailing \r on Windows; even on macOS/Linux
|
|
// a stray space at end-of-line can slip through hand-edited .inis. We
|
|
// don't want stat() failures because the resolved path has trailing
|
|
// whitespace baked in.
|
|
const avdRoot = join(process.env.HOME!, ".android", "avd");
|
|
await mkdir(avdRoot, { recursive: true });
|
|
await writeFile(
|
|
join(avdRoot, "Pixel_7.ini"),
|
|
["avd.ini.encoding=UTF-8", "path= /some/where/Pixel_7.avd ", ""].join("\n")
|
|
);
|
|
|
|
expect(await resolveAvdPath("Pixel_7")).toBe("/some/where/Pixel_7.avd");
|
|
});
|
|
|
|
it("rejects a whitespace-only `path=` value as if the .ini were missing", async () => {
|
|
// The non-greedy `(.+?)` in the regex still captures a single space for
|
|
// `path= ` because it must match at least one character. Without a
|
|
// post-match trim+absolute-path guard, callers would receive " " as a
|
|
// valid path — truthy in JS, so the `if (!avdPath)` short-circuit in
|
|
// `hasDefaultBootSnapshot` would not fire and a downstream stat would
|
|
// silently target the wrong location.
|
|
const avdRoot = join(process.env.HOME!, ".android", "avd");
|
|
await mkdir(avdRoot, { recursive: true });
|
|
await writeFile(
|
|
join(avdRoot, "Pixel_7.ini"),
|
|
["avd.ini.encoding=UTF-8", "path= ", ""].join("\n")
|
|
);
|
|
|
|
expect(await resolveAvdPath("Pixel_7")).toBeNull();
|
|
});
|
|
|
|
it("rejects a relative `path=` value (the emulator binary always writes an absolute path)", async () => {
|
|
// A relative path would `stat` against `process.cwd()` here, silently
|
|
// mis-locating the snapshot to wherever the tool-server happens to have
|
|
// been launched from. Real .ini files always carry an absolute path, so
|
|
// anything else is a corrupt/hand-edited file we should ignore in favor
|
|
// of the next candidate root.
|
|
const avdRoot = join(process.env.HOME!, ".android", "avd");
|
|
await mkdir(avdRoot, { recursive: true });
|
|
await writeFile(
|
|
join(avdRoot, "Pixel_7.ini"),
|
|
["avd.ini.encoding=UTF-8", "path=relative/Pixel_7.avd", ""].join("\n")
|
|
);
|
|
|
|
expect(await resolveAvdPath("Pixel_7")).toBeNull();
|
|
});
|
|
|
|
it("falls through to a later candidate when an earlier .ini has a bad path", async () => {
|
|
// The bad-path case should not poison the whole lookup — if a later
|
|
// root has a healthy .ini, we should still find the AVD there.
|
|
const userHomeRoot = join(tmpRoot, "user-home", "avd");
|
|
await writeAvdIni(userHomeRoot, "Pixel_7", "relative/oops");
|
|
const homeRoot = join(process.env.HOME!, ".android", "avd");
|
|
await writeAvdIni(homeRoot, "Pixel_7", "/from/home/Pixel_7.avd");
|
|
process.env.ANDROID_USER_HOME = join(tmpRoot, "user-home");
|
|
|
|
expect(await resolveAvdPath("Pixel_7")).toBe("/from/home/Pixel_7.avd");
|
|
});
|
|
});
|
|
|
|
describe("hasDefaultBootSnapshot", () => {
|
|
let tmpRoot: string;
|
|
let avdPath: string;
|
|
|
|
beforeEach(async () => {
|
|
for (const k of ENV_KEYS) originalEnv[k] = process.env[k];
|
|
tmpRoot = await mkdtemp(join(tmpdir(), "argent-avd-snapshot-"));
|
|
process.env.HOME = join(tmpRoot, "home");
|
|
const avdRoot = join(process.env.HOME, ".android", "avd");
|
|
avdPath = join(avdRoot, "Pixel_7.avd");
|
|
await mkdir(avdPath, { recursive: true });
|
|
await writeAvdIni(avdRoot, "Pixel_7", avdPath);
|
|
delete process.env.ANDROID_USER_HOME;
|
|
delete process.env.ANDROID_AVD_HOME;
|
|
delete process.env.ANDROID_SDK_HOME;
|
|
delete process.env.XDG_CONFIG_HOME;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const k of ENV_KEYS) {
|
|
if (originalEnv[k] === undefined) delete process.env[k];
|
|
else process.env[k] = originalEnv[k];
|
|
}
|
|
await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns true when both ram.bin and snapshot.pb exist and ram.bin is non-empty", async () => {
|
|
await writeDefaultBootSnapshot(avdPath);
|
|
expect(await hasDefaultBootSnapshot("Pixel_7")).toBe(true);
|
|
});
|
|
|
|
it("returns true even when snapshot.pb is days newer than ram.bin", async () => {
|
|
// Regression for the cold-boot-every-time bug: the emulator updates
|
|
// `snapshot.pb` on every load (load count, last-loaded timestamp), even
|
|
// with `-no-snapshot-save`. After a few hot-boot sessions the two mtimes
|
|
// drift by hours or days. The old 60s skew guard rejected every such
|
|
// (perfectly valid) snapshot and forced a cold boot. The `-check-
|
|
// snapshot-loadable` probe + `-force-snapshot-load` already validate the
|
|
// restore at spawn time, so the on-disk pre-check must be lenient here.
|
|
const now = Date.now();
|
|
await writeDefaultBootSnapshot(avdPath, {
|
|
ramMtimeMs: now - 4 * 24 * 60 * 60 * 1000, // 4 days old
|
|
snapshotMtimeMs: now,
|
|
});
|
|
expect(await hasDefaultBootSnapshot("Pixel_7")).toBe(true);
|
|
});
|
|
|
|
it("returns false when ram.bin is zero-length", async () => {
|
|
// OOM-killed mid-save: tiny `snapshot.pb` survives but `ram.bin` is
|
|
// truncated to zero. `-check-snapshot-loadable` only inspects metadata
|
|
// and would still say "Loadable", so the on-disk pre-check is the line
|
|
// of defense against this class of partial save.
|
|
await writeDefaultBootSnapshot(avdPath, { ramBytes: 0 });
|
|
expect(await hasDefaultBootSnapshot("Pixel_7")).toBe(false);
|
|
});
|
|
|
|
it("returns false when ram.bin is missing", async () => {
|
|
await writeDefaultBootSnapshot(avdPath, { withoutRamBin: true });
|
|
expect(await hasDefaultBootSnapshot("Pixel_7")).toBe(false);
|
|
});
|
|
|
|
it("returns false when snapshot.pb is missing", async () => {
|
|
await writeDefaultBootSnapshot(avdPath, { withoutSnapshotPb: true });
|
|
expect(await hasDefaultBootSnapshot("Pixel_7")).toBe(false);
|
|
});
|
|
|
|
it("returns false when the AVD .ini is missing entirely", async () => {
|
|
expect(await hasDefaultBootSnapshot("DoesNotExist")).toBe(false);
|
|
});
|
|
|
|
it("finds the snapshot when the .avd folder is relocated outside the convention root", async () => {
|
|
// The bug's other half: Linux setups (snap-installed Studio, AVDs moved
|
|
// to faster disk) put the `.avd` folder somewhere `<root>/<name>.avd`
|
|
// would never find. Reading `path=` from the `.ini` is what makes the
|
|
// pre-check robust to those layouts.
|
|
const relocated = join(tmpRoot, "fast-disk", "Pixel_7.avd");
|
|
await mkdir(relocated, { recursive: true });
|
|
await writeDefaultBootSnapshot(relocated);
|
|
// Overwrite the ini so it points at the relocated folder instead.
|
|
await writeAvdIni(join(process.env.HOME!, ".android", "avd"), "Pixel_7", relocated);
|
|
|
|
expect(await hasDefaultBootSnapshot("Pixel_7")).toBe(true);
|
|
});
|
|
});
|