mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
1706fb5c2a
## 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.
781 lines
34 KiB
TypeScript
781 lines
34 KiB
TypeScript
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();
|
||
}
|
||
});
|
||
});
|