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.
235 lines
8.4 KiB
TypeScript
235 lines
8.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const execFileMock = vi.fn();
|
|
|
|
vi.mock("node:child_process", async () => {
|
|
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
return {
|
|
...actual,
|
|
execFile: (
|
|
cmd: string,
|
|
args: readonly string[],
|
|
opts: unknown,
|
|
cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void
|
|
) => {
|
|
const callback = typeof opts === "function" ? opts : cb!;
|
|
const options = typeof opts === "function" ? undefined : opts;
|
|
const result = execFileMock(cmd, args, options);
|
|
if (result instanceof Error) callback(result, { stdout: "", stderr: "" });
|
|
else callback(null, result ?? { stdout: "", stderr: "" });
|
|
},
|
|
};
|
|
});
|
|
|
|
// `runAdb` / `listAvds` now resolve adb / emulator to an absolute path before
|
|
// spawning, so a bare `cmd === "adb"` matcher would never fire on real hosts.
|
|
// Stub the resolver to return the bare name so existing test mocks keep
|
|
// working, regardless of whether the host has the SDK installed.
|
|
vi.mock("../src/utils/android-binary", () => ({
|
|
resolveAndroidBinary: vi.fn(async (name: "adb" | "emulator") => name),
|
|
__resetAndroidBinaryCacheForTesting: () => {},
|
|
}));
|
|
|
|
import { checkSnapshotLoadable, listAndroidDevices, listAvds } from "../src/utils/adb";
|
|
|
|
beforeEach(() => {
|
|
execFileMock.mockReset();
|
|
});
|
|
|
|
describe("readAvdName — modern property, legacy fallback (review #3)", () => {
|
|
/**
|
|
* Emulator release 30 (Android 11+) moved the AVD name from
|
|
* `ro.kernel.qemu.avd_name` to `ro.boot.qemu.avd_name`. Reading only the
|
|
* old property makes modern images report `avdName: null`, which in turn
|
|
* breaks `findSerialByAvdName` disambiguation when two emulators boot
|
|
* concurrently.
|
|
*
|
|
* The fix probes the new prop first and falls back to the old one. These
|
|
* tests pin both paths.
|
|
*/
|
|
|
|
function mockAdbGetProps(
|
|
serial: string,
|
|
props: Partial<{
|
|
"ro.product.model": string;
|
|
"ro.build.version.sdk": string;
|
|
"ro.boot.qemu.avd_name": string;
|
|
"ro.kernel.qemu.avd_name": string;
|
|
}>
|
|
): void {
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "adb" && args[0] === "devices" && args.length === 1) {
|
|
return { stdout: `List of devices attached\n${serial}\tdevice\n`, stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
const shell = args[3] ?? "";
|
|
for (const [prop, value] of Object.entries(props)) {
|
|
if (shell === `getprop ${prop}`) return { stdout: `${value}\n`, stderr: "" };
|
|
}
|
|
return { stdout: "\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
}
|
|
|
|
it("reads ro.boot.qemu.avd_name on modern images (Android 11+)", async () => {
|
|
mockAdbGetProps("emulator-5554", {
|
|
"ro.product.model": "sdk_gphone64",
|
|
"ro.build.version.sdk": "34",
|
|
"ro.boot.qemu.avd_name": "Pixel_7_API_34",
|
|
"ro.kernel.qemu.avd_name": "",
|
|
});
|
|
|
|
const devices = await listAndroidDevices();
|
|
expect(devices).toHaveLength(1);
|
|
expect(devices[0]!.avdName).toBe("Pixel_7_API_34");
|
|
});
|
|
|
|
it("falls back to ro.kernel.qemu.avd_name on legacy images", async () => {
|
|
mockAdbGetProps("emulator-5554", {
|
|
"ro.product.model": "sdk_gphone",
|
|
"ro.build.version.sdk": "29",
|
|
"ro.boot.qemu.avd_name": "",
|
|
"ro.kernel.qemu.avd_name": "Pixel_3a_API_29",
|
|
});
|
|
|
|
const devices = await listAndroidDevices();
|
|
expect(devices[0]!.avdName).toBe("Pixel_3a_API_29");
|
|
});
|
|
|
|
it("prefers the modern property when both are present (some images double-set)", async () => {
|
|
mockAdbGetProps("emulator-5554", {
|
|
"ro.product.model": "sdk_gphone64",
|
|
"ro.build.version.sdk": "34",
|
|
"ro.boot.qemu.avd_name": "Pixel_7_API_34",
|
|
"ro.kernel.qemu.avd_name": "Pixel_7_API_34_stale",
|
|
});
|
|
|
|
const devices = await listAndroidDevices();
|
|
expect(devices[0]!.avdName).toBe("Pixel_7_API_34");
|
|
});
|
|
|
|
it("returns null when neither property is set (physical device)", async () => {
|
|
mockAdbGetProps("R5CT12345678", {
|
|
"ro.product.model": "SM-G990B",
|
|
"ro.build.version.sdk": "33",
|
|
});
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stdout: `List of devices attached\nR5CT12345678\tdevice\n`, stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
const shell = args[3] ?? "";
|
|
if (shell === "getprop ro.product.model") return { stdout: "SM-G990B\n", stderr: "" };
|
|
if (shell === "getprop ro.build.version.sdk") return { stdout: "33\n", stderr: "" };
|
|
return { stdout: "\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const devices = await listAndroidDevices();
|
|
expect(devices[0]!.avdName).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("listAvds noise filter (review #9)", () => {
|
|
/**
|
|
* Old filter was prefix-only — any AVD name starting with INFO/HAX was
|
|
* silently dropped. Real `emulator -list-avds` noise is diagnostic
|
|
* header/footer lines that contain whitespace or colons (e.g.
|
|
* `INFO | Android emulator version ...`), while AVD names are
|
|
* identifier-only. The new filter accepts identifier-shaped lines only.
|
|
*/
|
|
|
|
it("accepts an AVD name that happens to start with HAX (e.g. HAX-Pixel-6)", async () => {
|
|
execFileMock.mockImplementation((cmd: string) => {
|
|
if (cmd === "emulator") {
|
|
return { stdout: "HAX-Pixel-6\nINFO_BuildBot_Pixel7\nPixel_7_API_34\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
const avds = await listAvds();
|
|
expect(avds.map((a) => a.name)).toEqual([
|
|
"HAX-Pixel-6",
|
|
"INFO_BuildBot_Pixel7",
|
|
"Pixel_7_API_34",
|
|
]);
|
|
});
|
|
|
|
it("filters out genuine noise lines with whitespace / pipe characters", async () => {
|
|
// Real emulator output on at least some installs prints a log-format header.
|
|
execFileMock.mockImplementation((cmd: string) => {
|
|
if (cmd === "emulator") {
|
|
return {
|
|
stdout: [
|
|
"INFO | Android emulator version 33.1.11.0",
|
|
"HAX is working and emulator runs in fast virt mode.",
|
|
"Pixel_7_API_34",
|
|
"Pixel_3a_API_29",
|
|
"",
|
|
].join("\n"),
|
|
stderr: "",
|
|
};
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
const avds = await listAvds();
|
|
expect(avds.map((a) => a.name)).toEqual(["Pixel_7_API_34", "Pixel_3a_API_29"]);
|
|
});
|
|
});
|
|
|
|
describe("checkSnapshotLoadable extraArgs (renderer-args parity)", () => {
|
|
/**
|
|
* The probe must receive the same renderer-affecting flags as the actual
|
|
* boot spawn, otherwise it resolves a different renderer than the boot
|
|
* does and falsely reports `Not loadable | Reason: different renderer
|
|
* configured` for a snapshot the boot would have happily loaded.
|
|
* `boot-device` defines a single `RENDERER_ARGS` constant and threads it
|
|
* through both call sites; these tests pin the argv shape end-to-end.
|
|
*/
|
|
|
|
it("inserts extraArgs between -avd <name> and -check-snapshot-loadable <snap>", async () => {
|
|
let observedArgs: readonly string[] | null = null;
|
|
execFileMock.mockImplementation((cmd: string, args: readonly string[]) => {
|
|
if (cmd === "emulator" && args.includes("-check-snapshot-loadable")) {
|
|
observedArgs = args;
|
|
return { stdout: "snapshot name default_boot\nLoadable\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await checkSnapshotLoadable("Pixel_7_API_34", "default_boot", {
|
|
extraArgs: ["-gpu", "auto"],
|
|
});
|
|
|
|
expect(result).toEqual({ loadable: true, reason: null });
|
|
expect(observedArgs).toEqual([
|
|
"-avd",
|
|
"Pixel_7_API_34",
|
|
"-gpu",
|
|
"auto",
|
|
"-check-snapshot-loadable",
|
|
"default_boot",
|
|
]);
|
|
});
|
|
|
|
it("omits extraArgs entirely when none are passed", async () => {
|
|
let observedArgs: readonly string[] | null = null;
|
|
execFileMock.mockImplementation((cmd: string, args: readonly string[]) => {
|
|
if (cmd === "emulator" && args.includes("-check-snapshot-loadable")) {
|
|
observedArgs = args;
|
|
return { stdout: "Loadable\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
await checkSnapshotLoadable("Pixel_7_API_34");
|
|
expect(observedArgs).toEqual([
|
|
"-avd",
|
|
"Pixel_7_API_34",
|
|
"-check-snapshot-loadable",
|
|
"default_boot",
|
|
]);
|
|
});
|
|
});
|