Files
software-mansion__argent/packages/tool-server/test/boot-device-hotboot.test.ts
Ignacy Łątka 1706fb5c2a fix(boot-device): don't tear down a healthy-but-slow emulator on the final boot attempt (#305)
## Problem

On Linux (headless, software-GPU) I hit `boot-device` repeatedly failing
with
`PackageManager did not respond ... Emulator has been terminated`, even
though
the emulator had booted fine. Root cause: **Stage 5** of `attemptBoot`
ran a
single `pm path android` probe with a 10 s timeout and, on failure,
killed the
emulator and threw.

On a loaded host (or a freshly `-wipe-data`'d image still finishing its
first-boot package scan), `pm` can take well over 10 s to answer while
the guest
is perfectly healthy — it already reached `sys.boot_completed` and
registered
with adb. On the **final cold attempt** there is no fallback, so a
momentarily
slow PackageManager turned a usable emulator into a hard boot failure
(and
destroyed it, so the next try started from a cold boot again).

## Fix

- Stage 5 now **retries** `pm path android` within a budget instead of a
single
  10 s window.
- Threads a `tearDownIfUnready` flag through `attemptBoot`:
- **hot-boot attempt** — tight ~10 s budget, tear down on failure so we
still
    fall through to the cold boot.
- **final cold attempt** — retry up to 45 s and, if PM is still slow,
**return
the emulator as booted rather than destroying it**. gRPC
screenshots/gestures
work without PM, and killing it just guarantees failure with nothing to
fall
back to. A confirmed QEMU crash (mid-probe or via the early-exit racer)
still
    tears down and rethrows the real cause.

Also adds a note to the `argent-android-emulator-setup` skill:
hard-killing qemu
(`pkill -9`) leaves the userdata image dirty — the usual cause of a cold
boot
that hangs for minutes with runaway writes — and `-wipe-data` resets it.

## Tests

`packages/tool-server/test/boot-device-hotboot.test.ts` — added a
regression
test asserting the final cold attempt returns the emulator as `booted`
(and does
**not** issue `adb emu kill`) when PM stays slow. All 28 boot-device
tests pass.

## Out of scope / follow-ups (found during the same Linux session)

- `simulator-server` (Rust) **panics** (`android_emulator.rs:104 "should
be
running"`) when asked to attach to a pre-existing emulator launched with
`-read-only` that never registered gRPC discovery. Better to return a
clear
"can't attach, reboot via Argent" error. Separate (cross-language)
change.
- `screenshot-diff` with `captureCurrent: true` fails reproducibly with
  `"server response missing url or path"` while plain `screenshot` and
  file-vs-file diff both work.
- `react-profiler-component-source` returns `found: false` for valid
top-level
  `export function` components.
2026-06-03 22:23:29 +02:00

781 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Registry } from "@argent/registry";
// Parametrize platform-dependent assertions so a single CI runner exercises
// both branches. Without this, macOS CI never tests the linux branch and
// linux CI never tests the darwin branch — a regression that swapped them
// would pass on both runners. See selectGpuMode in boot-device.ts.
const PLATFORMS: ReadonlyArray<readonly [NodeJS.Platform, string]> = [
["linux", "swiftshader"],
["darwin", "auto"],
];
const originalPlatform = process.platform;
function setPlatform(value: NodeJS.Platform) {
Object.defineProperty(process, "platform", { value, configurable: true });
}
const execFileMock = vi.fn();
const spawnMock = vi.fn();
const hasSnapshotMock = vi.fn();
const probeMock = 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: "" });
},
spawn: (cmd: string, args: string[], opts: unknown) => spawnMock(cmd, args, opts),
};
});
// Stub the two filesystem/probe helpers so tests don't depend on a real AVD or
// a real `emulator -check-snapshot-loadable` spawn.
vi.mock("../src/utils/adb", async () => {
const actual = await vi.importActual<typeof import("../src/utils/adb")>("../src/utils/adb");
return {
...actual,
hasDefaultBootSnapshot: (...a: unknown[]) => hasSnapshotMock(...a),
checkSnapshotLoadable: (...a: unknown[]) => probeMock(...a),
};
});
// `boot-device` now goes through `resolveAndroidBinary` for both ensureDep
// and the spawn path. Stub the resolver to return the bare name so existing
// `cmd === "adb" / "emulator"` and `spawnMock("emulator", ...)` matchers fire
// regardless of host SDK install state.
vi.mock("../src/utils/android-binary", () => ({
resolveAndroidBinary: vi.fn(async (name: "adb" | "emulator") => name),
__resetAndroidBinaryCacheForTesting: () => {},
}));
import {
__resetInFlightBootsForTesting,
createBootDeviceTool,
} from "../src/tools/devices/boot-device";
const registry: Registry = { resolveService: async () => ({}) } as unknown as Registry;
interface FakeChild extends EventEmitter {
unref: () => void;
kill: (sig?: string) => void;
exitCode: number | null;
signalCode: string | null;
}
function fakeChild(): FakeChild {
const proc = new EventEmitter() as FakeChild;
proc.unref = () => {};
proc.kill = () => {};
proc.exitCode = null;
proc.signalCode = null;
return proc;
}
beforeEach(() => {
execFileMock.mockReset();
spawnMock.mockReset();
hasSnapshotMock.mockReset();
probeMock.mockReset();
spawnMock.mockImplementation(() => fakeChild());
// Per-AVD in-flight coalescing carries leaked promises across tests; reset
// so each test starts with an empty boot map. (See note in adjacent
// boot-device-hardening.test.ts.)
__resetInFlightBootsForTesting();
});
afterEach(() => {
// Restore process.platform after every test, even ones that don't pin it,
// so a forgotten cleanup in one test can't leak into the next.
setPlatform(originalPlatform);
});
/**
* Common happy-path mock: AVD exists, adb is healthy, `adb devices` reveals
* one new emulator after spawn, wait-for-device succeeds, getprop returns 1,
* pm path answers. Used to isolate the branch-selection logic under test.
*/
function mockHappyBootChain(newSerial = "emulator-5554") {
let devicesCalls = 0;
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "emulator" && args[0] === "-list-avds") {
return { stdout: "Pixel_7_API_34\n", stderr: "" };
}
if (cmd === "adb" && args[0] === "version") {
return { stdout: "Android Debug Bridge\n", stderr: "" };
}
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
devicesCalls += 1;
const emuLine = devicesCalls >= 2 ? `${newSerial}\tdevice\n` : "";
return { stdout: `List of devices attached\n${emuLine}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device") {
return { stdout: "", stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shellCmd = args[3] ?? "";
if (shellCmd.startsWith("getprop sys.boot_completed")) {
return { stdout: "1\n", stderr: "" };
}
if (shellCmd.startsWith("getprop")) return { stdout: "unknown\n", stderr: "" };
if (shellCmd === "pm path android") {
return { stdout: "package:/system/framework/framework-res.apk\n", stderr: "" };
}
if (shellCmd.startsWith("screencap")) return { stdout: "1\n", stderr: "" };
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
}
describe("boot-device Android — hot-boot with cold-boot fallback", () => {
it.each(PLATFORMS)(
"picks the hot-boot spawn args + `-gpu %s` on %s when default_boot probes Loadable",
async (platform, expectedGpu) => {
setPlatform(platform);
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: true, reason: null });
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
const result = await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(result).toMatchObject({
platform: "android",
serial: "emulator-5554",
avdName: "Pixel_7_API_34",
booted: true,
});
// Exactly one emulator spawn and it is the hot-boot arg set.
expect(spawnMock).toHaveBeenCalledTimes(1);
const hotArgs = spawnMock.mock.calls[0]![1];
expect(hotArgs).toContain("-force-snapshot-load");
expect(hotArgs).toContain("-no-snapshot-save");
expect(hotArgs).not.toContain("-no-snapshot-load");
// Window is visible by default — `-no-window` only appears when the
// user opts in via `ARGENT_EMULATOR_NO_WINDOW`. The opt-in path is
// exercised by a separate test below.
expect(hotArgs).not.toContain("-no-window");
// `-gpu` arg must be present and platform-appropriate. Linux uses
// `swiftshader` for universal compatibility (sidesteps the host GL
// stack, which silently fails on Optimus / dual-GPU / Wayland-with-
// NVIDIA setups); every other host uses `auto`. See `selectGpuMode`.
const gpuIdx = hotArgs.indexOf("-gpu");
expect(gpuIdx).toBeGreaterThanOrEqual(0);
expect(hotArgs[gpuIdx + 1]).toBe(expectedGpu);
}
);
it.each(PLATFORMS)(
"hands `-gpu %s` to both probe and hot-boot spawn on %s",
async (platform, expectedGpu) => {
// Sibling test of the assertion above, focused on parity: the probe
// argv and the spawn argv must agree on every renderer-affecting flag,
// or the emulator's `-check-snapshot-loadable` resolves a different
// renderer than the boot does and rejects perfectly loadable snapshots.
// The bug this guards against is "every boot is cold on Linux even
// with a fresh snapshot on disk" — caught only end-to-end because both
// unit-mocked halves test green in isolation.
setPlatform(platform);
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: true, reason: null });
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(probeMock).toHaveBeenCalledTimes(1);
const [, , probeOptions] = probeMock.mock.calls[0]!;
// Renderer args (`-gpu`) AND launch-hardening args (`-noaudio`,
// `-netfast`) all change qemu device topology — any one of them
// differing between probe and boot is enough to reject a perfectly
// loadable snapshot. Assert both halves arrive in the probe's extraArgs
// so the parity test catches a regression in either group.
expect(probeOptions).toMatchObject({
extraArgs: expect.arrayContaining(["-gpu", expectedGpu, "-noaudio", "-netfast"]),
});
expect(spawnMock).toHaveBeenCalledTimes(1);
const hotArgs = spawnMock.mock.calls[0]![1] as string[];
const gpuIdx = hotArgs.indexOf("-gpu");
expect(gpuIdx).toBeGreaterThanOrEqual(0);
expect(hotArgs[gpuIdx + 1]).toBe(expectedGpu);
}
);
it("passes launch-hardening flags on the hot-boot spawn", async () => {
// `-noaudio`, `-no-boot-anim`, `-netfast` are the perf cuts; the consent
// dialogs that `-crash-report-mode never` and `-no-metrics` suppress are
// what block MCP-driven boots on emulator crash or first run. All five
// must reach the spawn or argent loses the qemu device parity the probe
// relies on (and the dialog protection). See LAUNCH_HARDENING_ARGS in
// boot-device.ts for the per-flag rationale.
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: true, reason: null });
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(spawnMock).toHaveBeenCalledTimes(1);
const hotArgs = spawnMock.mock.calls[0]![1] as string[];
expect(hotArgs).toEqual(
expect.arrayContaining([
"-noaudio",
"-no-boot-anim",
"-netfast",
"-crash-report-mode",
"never",
"-no-metrics",
])
);
});
it("passes the same launch-hardening flags on the cold-boot spawn", async () => {
// Cold boot WRITES a snapshot (no `-no-snapshot-save`); the next hot boot
// reads it. If cold-boot's qemu config differs from hot-boot's, the saved
// snapshot is unloadable and argent re-enters cold-boot forever.
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(spawnMock).toHaveBeenCalledTimes(1);
const coldArgs = spawnMock.mock.calls[0]![1] as string[];
expect(coldArgs).toEqual(
expect.arrayContaining([
"-noaudio",
"-no-boot-anim",
"-netfast",
"-crash-report-mode",
"never",
"-no-metrics",
])
);
});
it("honors ARGENT_EMULATOR_GPU_MODE env override", async () => {
// Escape hatch for power users with verified-working `-gpu host`.
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const prev = process.env.ARGENT_EMULATOR_GPU_MODE;
process.env.ARGENT_EMULATOR_GPU_MODE = "host";
try {
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
const args = spawnMock.mock.calls[0]![1];
const gpuIdx = args.indexOf("-gpu");
expect(args[gpuIdx + 1]).toBe("host");
} finally {
if (prev === undefined) delete process.env.ARGENT_EMULATOR_GPU_MODE;
else process.env.ARGENT_EMULATOR_GPU_MODE = prev;
}
});
it("appends -no-window when ARGENT_EMULATOR_NO_WINDOW is set", async () => {
// Opt-in for CI / containers / Wayland-only sessions where the emulator
// can't open a Qt window. See selectExtraEmulatorArgs.
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const prev = process.env.ARGENT_EMULATOR_NO_WINDOW;
process.env.ARGENT_EMULATOR_NO_WINDOW = "1";
try {
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
const args = spawnMock.mock.calls[0]![1];
expect(args).toContain("-no-window");
} finally {
if (prev === undefined) delete process.env.ARGENT_EMULATOR_NO_WINDOW;
else process.env.ARGENT_EMULATOR_NO_WINDOW = prev;
}
});
it.each([
["empty", ""],
["whitespace", " "],
["zero", "0"],
["false", "false"],
["no", "no"],
])("treats ARGENT_EMULATOR_NO_WINDOW=%s as off", async (_label, value) => {
// `export FOO=` is a common shell mis-setting; `0`/`false`/`no` all
// read as "disable" — only `1`, `true`, `yes` activate -no-window.
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const prev = process.env.ARGENT_EMULATOR_NO_WINDOW;
process.env.ARGENT_EMULATOR_NO_WINDOW = value;
try {
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
const args = spawnMock.mock.calls[0]![1];
expect(args).not.toContain("-no-window");
} finally {
if (prev === undefined) delete process.env.ARGENT_EMULATOR_NO_WINDOW;
else process.env.ARGENT_EMULATOR_NO_WINDOW = prev;
}
});
it.each(PLATFORMS)(
"ignores empty/whitespace ARGENT_EMULATOR_GPU_MODE, falls through to `%s` default on %s",
async (platform, expectedGpu) => {
// `export FOO=` foot-gun: fall through to platform default, don't crash.
setPlatform(platform);
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const prev = process.env.ARGENT_EMULATOR_GPU_MODE;
process.env.ARGENT_EMULATOR_GPU_MODE = " ";
try {
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
const args = spawnMock.mock.calls[0]![1];
const gpuIdx = args.indexOf("-gpu");
expect(args[gpuIdx + 1]).toBe(expectedGpu);
} finally {
if (prev === undefined) delete process.env.ARGENT_EMULATOR_GPU_MODE;
else process.env.ARGENT_EMULATOR_GPU_MODE = prev;
}
}
);
it("throws on an unknown ARGENT_EMULATOR_GPU_MODE rather than letting emulator -gpu fail later", async () => {
// A typoed override (`ARGENT_EMULATOR_GPU_MODE=hsot`) used to be passed
// through verbatim and the emulator binary rejected it mid-launch — that
// path burns the full hot-boot budget before surfacing the error. We
// validate against the whitelist at boot-start so the user sees the
// mistake immediately.
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const prev = process.env.ARGENT_EMULATOR_GPU_MODE;
process.env.ARGENT_EMULATOR_GPU_MODE = "hsot";
try {
const tool = createBootDeviceTool(registry);
await expect(tool.execute!({}, { avdName: "Pixel_7_API_34" })).rejects.toThrow(
/ARGENT_EMULATOR_GPU_MODE=.*not a known emulator -gpu value/
);
} finally {
if (prev === undefined) delete process.env.ARGENT_EMULATOR_GPU_MODE;
else process.env.ARGENT_EMULATOR_GPU_MODE = prev;
}
});
it.each(PLATFORMS)(
"skips hot-boot and cold-boots with `-gpu %s` on %s when no snapshot exists",
async (platform, expectedGpu) => {
setPlatform(platform);
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(probeMock).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(1);
const args = spawnMock.mock.calls[0]![1];
expect(args).toContain("-no-snapshot-load");
expect(args).not.toContain("-force-snapshot-load");
expect(args).not.toContain("-no-window");
// Cold boot must also pass the platform-appropriate `-gpu` value so the
// snapshot it eventually saves matches the renderer the next launch's
// probe will resolve. Without this, the cold-boot fallback bakes a
// renderer mismatch into the saved snapshot and re-enters the "every
// boot is cold" cycle.
expect(args).toEqual(expect.arrayContaining(["-gpu", expectedGpu]));
}
);
it("skips the hot-boot attempt and cold-boots when -check-snapshot-loadable rejects", async () => {
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: false, reason: "different renderer configured" });
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
// One spawn, and it is cold-boot args (no -force-snapshot-load).
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0]![1]).toContain("-no-snapshot-load");
});
it("throws after cold boot when SurfaceFlinger never composites a real frame", async () => {
// Cold-boot post-condition: under Linux + Weston-headless + SwiftShader
// the lockscreen composite lags sys.boot_completed by 5–60 s. boot-device
// must not return `booted:true` until `screencap` reports at least one
// non-zero pixel byte, else callers chaining boot → screenshot get a
// silent all-black PNG. Here we keep screencap stuck on "0" forever and
// assert the cold-boot path eventually surfaces the timeout instead of
// returning a serial whose screenshots would all be blank.
vi.useFakeTimers();
try {
hasSnapshotMock.mockResolvedValue(false);
let devicesCalls = 0;
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
devicesCalls += 1;
const emuLine = devicesCalls >= 2 ? "emulator-5554\tdevice\n" : "";
return { stdout: `List of devices attached\n${emuLine}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device")
return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shellCmd = args[3] ?? "";
if (shellCmd.startsWith("getprop sys.boot_completed"))
return { stdout: "1\n", stderr: "" };
if (shellCmd === "pm path android")
return { stdout: "package:/system/framework/framework-res.apk\n", stderr: "" };
if (shellCmd.startsWith("screencap")) return { stdout: "0\n", stderr: "" };
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
const tool = createBootDeviceTool(registry);
const resultP = tool.execute!({}, { avdName: "Pixel_7_API_34" }).then(
(v) => ({ ok: true, value: v }) as const,
(e) => ({ ok: false, error: e as Error }) as const
);
// 90 s firstRealFrame budget + the cold-boot stages all park on
// setTimeout. 200 s of fake time is ample to drain the deadline.
await vi.advanceTimersByTimeAsync(200_000);
const outcome = await resultP;
expect(outcome.ok).toBe(false);
if (!outcome.ok) {
expect(outcome.error.message).toMatch(/SurfaceFlinger did not composite a real frame/);
}
} finally {
vi.useRealTimers();
}
});
it("falls back to cold boot when hot-boot child exits early (ram.bin corruption class)", async () => {
// Fake timers: the impl parks the stage-2 adb-register loop on a real
// 1s setTimeout while it waits for the hot child to crash. Driving it
// with fake timers keeps the behavior identical (crash at 10ms, loop
// observes earlyExitError, falls back to cold boot) but collapses ~1s of
// real wall time. adb is module-mocked above, so the only async left is
// microtasks + the faked timers — fully deterministic.
vi.useFakeTimers();
try {
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: true, reason: null });
// First spawn crashes immediately. Second spawn is healthy.
let spawnCount = 0;
spawnMock.mockImplementation(() => {
const child = fakeChild();
spawnCount += 1;
if (spawnCount === 1) {
setTimeout(() => child.emit("exit", 134, null), 10);
}
return child;
});
// Device-list mock must be spawn-aware: while the first (crashing) hot
// attempt is in flight, `adb devices` shows no new emulator so the inner
// boot loop stays in the wait and observes earlyExitError. Once the
// second (cold) spawn happens, the new serial appears.
let coldSerialVisible = false;
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
if (spawnCount >= 2) coldSerialVisible = true;
const line = coldSerialVisible ? "emulator-5554\tdevice\n" : "";
return { stdout: `List of devices attached\n${line}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device") {
return { stdout: "", stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shellCmd = args[3] ?? "";
if (shellCmd.startsWith("getprop sys.boot_completed"))
return { stdout: "1\n", stderr: "" };
if (shellCmd === "pm path android")
return { stdout: "package:/system/framework/framework-res.apk\n", stderr: "" };
if (shellCmd.startsWith("screencap")) return { stdout: "1\n", stderr: "" };
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
const tool = createBootDeviceTool(registry);
const resultP = tool.execute!({}, { avdName: "Pixel_7_API_34" });
// t=10 hot child crashes; t=1000 the stage-2 poll wakes, sees the
// earlyExitError and falls back to cold boot (which then resolves fast
// since the serial is immediately visible). 5s of fake time is ample.
await vi.advanceTimersByTimeAsync(5_000);
const result = await resultP;
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock.mock.calls[0]![1]).toContain("-force-snapshot-load");
expect(spawnMock.mock.calls[1]![1]).toContain("-no-snapshot-load");
expect(spawnMock.mock.calls[1]![1]).not.toContain("-force-snapshot-load");
expect(result).toMatchObject({ serial: "emulator-5554" });
} finally {
vi.useRealTimers();
}
});
it("falls back to cold boot when hot-restore leaves screencap returning a blank frame", async () => {
// assertScreencapAlive polls within firstRealFrameHot (8 s) before
// declaring a sticky-blank state — uses fake timers so we don't wall-clock
// through the budget. Without this the test would real-time wait 8 s.
vi.useFakeTimers();
try {
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: true, reason: null });
// First spawn (hot) boots cleanly but screencap returns a blank frame —
// the SurfaceFlinger composite-restore artefact. Second spawn (cold) is
// fully healthy. Each spawn registers a distinct serial so the cold-boot
// poll picks up a genuinely new emulator.
let spawnCount = 0;
spawnMock.mockImplementation(() => {
spawnCount += 1;
return fakeChild();
});
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
let lines = "";
if (spawnCount >= 1) lines += "emulator-5554\tdevice\n";
if (spawnCount >= 2) lines += "emulator-5556\tdevice\n";
return { stdout: `List of devices attached\n${lines}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device")
return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const serial = args[1];
const shellCmd = args[3] ?? "";
if (shellCmd.startsWith("getprop sys.boot_completed"))
return { stdout: "1\n", stderr: "" };
if (shellCmd.startsWith("getprop")) return { stdout: "unknown\n", stderr: "" };
if (shellCmd === "pm path android")
return { stdout: "package:/system/framework/framework-res.apk\n", stderr: "" };
if (shellCmd.startsWith("screencap")) {
return { stdout: serial === "emulator-5554" ? "0\n" : "1\n", stderr: "" };
}
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
const tool = createBootDeviceTool(registry);
const resultP = tool.execute!({}, { avdName: "Pixel_7_API_34" });
// Drain the 8 s polling budget + a margin for the post-fail cold-boot
// chain (also parks on setTimeout).
await vi.advanceTimersByTimeAsync(15_000);
const result = await resultP;
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock.mock.calls[0]![1]).toContain("-force-snapshot-load");
expect(spawnMock.mock.calls[1]![1]).toContain("-no-snapshot-load");
expect(result).toMatchObject({ serial: "emulator-5556" });
} finally {
vi.useRealTimers();
}
});
it("returns the already-running emulator without spawning when the AVD is live and its framebuffer is healthy", async () => {
// adb devices reports the AVD already attached; getprop answers with the
// matching AVD name. screencap returns a healthy frame ("1") so the
// wedged-framebuffer guard passes. No snapshot probe, no spawn.
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
return { stdout: "List of devices attached\nemulator-5554\tdevice\n", stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shellCmd = args[3] ?? "";
if (shellCmd === "getprop ro.boot.qemu.avd_name")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (shellCmd.startsWith("getprop")) return { stdout: "unknown\n", stderr: "" };
if (shellCmd.startsWith("screencap")) return { stdout: "1\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
const tool = createBootDeviceTool(registry);
const result = await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(result).toMatchObject({
platform: "android",
serial: "emulator-5554",
avdName: "Pixel_7_API_34",
booted: true,
});
expect(spawnMock).not.toHaveBeenCalled();
expect(hasSnapshotMock).not.toHaveBeenCalled();
expect(probeMock).not.toHaveBeenCalled();
});
it("kills the running AVD and respawns when its framebuffer is wedged on the reuse path", async () => {
// The fast-path's BUG GUARD: if a long-running emulator drifts into the
// sticky-blank screencap state, returning that serial unchanged would
// hand the caller a device whose screenshots are silently all-zero.
// The guard kills the wedged emulator and falls through to a fresh boot.
// Fake timers: assertScreencapAlive polls for firstRealFrameHot (8 s)
// before declaring a wedge, then the cold-boot fallback parks on its own
// setTimeout cadence. Without fake timers this would real-time wait.
vi.useFakeTimers();
try {
hasSnapshotMock.mockResolvedValue(false); // force the cold-boot path post-kill
let killed = false;
let spawned = false;
spawnMock.mockImplementation(() => {
spawned = true;
return fakeChild();
});
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
// Pre-kill: wedged emulator-5554 is listed. After kill but before
// the cold-boot spawn registers: empty (so emulator-5556 is *new*
// when it appears). Post-spawn: emulator-5556 is listed.
let line = "";
if (!killed) line = "emulator-5554\tdevice\n";
else if (spawned) line = "emulator-5556\tdevice\n";
return { stdout: `List of devices attached\n${line}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device")
return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "-s" && args.includes("emu") && args.includes("kill")) {
killed = true;
return { stdout: "OK\n", stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const serial = args[1];
const shellCmd = args[3] ?? "";
if (shellCmd === "getprop ro.boot.qemu.avd_name")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (shellCmd.startsWith("getprop sys.boot_completed"))
return { stdout: "1\n", stderr: "" };
if (shellCmd.startsWith("getprop")) return { stdout: "unknown\n", stderr: "" };
if (shellCmd === "pm path android")
return { stdout: "package:/system/framework/framework-res.apk\n", stderr: "" };
if (shellCmd.startsWith("screencap")) {
// Wedged frame on the original serial; healthy on the respawn.
return { stdout: serial === "emulator-5554" ? "0\n" : "1\n", stderr: "" };
}
}
return { stdout: "", stderr: "" };
});
const tool = createBootDeviceTool(registry);
const resultP = tool.execute!({}, { avdName: "Pixel_7_API_34" });
await vi.advanceTimersByTimeAsync(15_000);
const result = await resultP;
expect(killed).toBe(true);
// Exactly one fresh spawn (the cold-boot fallback after the kill).
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0]![1]).toContain("-no-snapshot-load");
expect(result).toMatchObject({ serial: "emulator-5556" });
} finally {
vi.useRealTimers();
}
});
it("returns the cold-booted emulator as booted when PackageManager stays slow on the final attempt (does not tear it down)", async () => {
// Regression: a slow-but-alive guest (pm never answers within the probe
// window even though sys.boot_completed=1 and adb registered the serial)
// must NOT be torn down on the final cold attempt — there is no fallback
// left and the device is usable via gRPC. Previously a single 10 s pm
// probe failure killed the emulator and failed the whole boot.
hasSnapshotMock.mockResolvedValue(false); // skip hot boot -> straight to cold
let killed = false;
let devicesCalls = 0;
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
devicesCalls += 1;
const emuLine = devicesCalls >= 2 ? "emulator-5554\tdevice\n" : "";
return { stdout: `List of devices attached\n${emuLine}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device")
return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "-s" && args.includes("emu") && args.includes("kill")) {
killed = true;
return { stdout: "OK\n", stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shellCmd = args[3] ?? "";
if (shellCmd.startsWith("getprop sys.boot_completed")) return { stdout: "1\n", stderr: "" };
if (shellCmd.startsWith("getprop")) return { stdout: "unknown\n", stderr: "" };
// pm never answers — simulate the adb call being SIGKILLed on timeout.
if (shellCmd === "pm path android")
return new Error(
"Command failed: adb -s emulator-5554 shell pm path (killed=true signal=SIGKILL)"
);
// screencap returns a real (non-zero) frame so awaitFirstRealFrame passes.
if (shellCmd.startsWith("screencap")) return { stdout: "1\n", stderr: "" };
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
vi.useFakeTimers();
try {
const tool = createBootDeviceTool(registry);
const resultP = tool.execute!({}, { avdName: "Pixel_7_API_34" });
// Drive past the ~45 s PM-probe retry budget instantly. The guest is
// alive (screencap returns a real frame, so awaitFirstRealFrame passes),
// so the final cold attempt returns it as booted instead of killing it.
await vi.advanceTimersByTimeAsync(50_000);
const result = await resultP;
expect(result).toMatchObject({
platform: "android",
serial: "emulator-5554",
booted: true,
});
// The healthy-but-slow guest must be left running, not killed.
expect(killed).toBe(false);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0]![1]).toContain("-no-snapshot-load");
} finally {
vi.useRealTimers();
}
});
});