Files
software-mansion__argent/packages/tool-server/test/emulator-supports-flag.test.ts
Ignacy Łątka 43b7d79a2a fix(boot-device): stop passing -crash-report-mode to emulators that reject it (#557)
## Problem

`boot-device` cannot launch an Android emulator on builds that do not
accept
`-crash-report-mode`. The emulator rejects the unknown option and exits
before
the device ever comes up:

```
$ argent run boot-device --args '{"avdName":"Pixel_6_API_34"}'
emulator binary exited with code 1 before the device booted
```

## Cause

`-crash-report-mode` is undocumented and missing from many emulator
builds, so
#306 added a feature-detect gate:

```ts
const crashReportArgs = (await emulatorSupportsFlag("-crash-report-mode"))
  ? ["-crash-report-mode", "never"]
  : [];
```

That commit was a pure addition. It never removed the flag from the
shared
`LAUNCH_HARDENING_ARGS` constant, which still reads:

```ts
const LAUNCH_HARDENING_ARGS = [
  "-no-boot-anim", "-netfast",
  "-crash-report-mode", "never",
  "-no-metrics",
] as const;
```

So the gate is dead code. Every launch gets the flag unconditionally
through
the constant, and builds that do support it are handed it twice.

## Proof

On Android emulator 36.1.9.0, whose `-help` does not list the flag:

```
$ emulator -version
Android emulator version 36.1.9.0 (build_id 13823996)

$ emulator -help | grep -c -- -crash-report-mode
0
$ emulator -help | grep -c -- -no-metrics
1
```

`-list-avds` and an unknown `-avd` both short-circuit before option
validation, so the rejection only surfaces once a real AVD resolves.
Against
one, with everything else held equal:

```
$ emulator -avd Pixel_6_API_34 -check-snapshot-loadable default_boot \
      -crash-report-mode never
INFO    | Found systemPath .../android-34/google_apis/arm64-v8a/
unknown option: -crash-report-mode
please use -help for a list of valid options       # exits, never boots

$ emulator -avd Pixel_6_API_34 -check-snapshot-loadable default_boot
INFO    |      Ok: System requirements to run avd: `Pixel_6_API_34` are met
```

The launch dies at option parsing, after the AVD resolves and before any
device work. `hardeningArgs` reaches the snapshot probe, the hot boot
and the
cold boot alike, so on such a build all three spawns abort.

## Fix

- Drop `-crash-report-mode never` from `LAUNCH_HARDENING_ARGS`, leaving
the
  `crashReportArgs` gate as the only source of the flag.
- Spread `crashReportArgs` into the `-check-snapshot-loadable` probe's
`extraArgs`. The probe is itself an emulator spawn, so it gets the same
crash-dialog suppression as the two boots; once the flag left the
constant
it would otherwise have been the only site missing it. This one is not a
  snapshot-parity requirement - unlike `-noaudio` and `-netfast`,
`-crash-report-mode` selects crash reporting rather than qemu devices,
so a
  mismatch would not invalidate the saved snapshot.
- Leave the gate where it is, after the already-running reuse fast path,
so
the `emulator -help` probe is still skipped when nothing will be
spawned.
- Fix the prose that had drifted: the comment above
`LAUNCH_HARDENING_ARGS`
still listed `-crash-report-mode` among its flags and claimed they all
need
no detection, and both it and the `emulatorSupportsFlag` docstring said
the
flag is "only present in newer emulator releases (~36.x and late 35.x)"
-
  which 36.1.9.0 not having it contradicts.

`bootAndroidImpl` is the only emulator launch path in the repo, and all
three
of its spread sites are covered.

Making the gate load-bearing put weight on a probe that was only ever
advisory, so `emulator -help` detection is hardened alongside it:

- `emulatorSupportsFlag` memoized its verdict for the process lifetime
even
when the probe learned nothing - a transient spawn failure, a timeout,
or a
silent zero-exit run pinned "unsupported" until restart. An empty
capture is
  now reported to stderr and left uncached, so the next boot retries.
- A killed probe is no verdict at all: the flag may sit in the part of
the
listing the child never transmitted, so partial output from a timeout is
  discarded rather than read as absence.
- The three emulator probe spawns (`-help`, `-list-avds`,
`-check-snapshot-loadable`) pass `killSignal: "SIGKILL"`, matching
`runAdb`.
  `execFile`'s default SIGTERM can be ignored by an emulator wedged in
Qt/graphics init, which leaves the promise unsettled past every caller's
  deadline.

## Tests

A new `-crash-report-mode feature gate` block in
`packages/tool-server/test/boot-device-hotboot.test.ts` asserts the real
spawned argv and the probe's `extraArgs`:

- gate false: zero occurrences at the probe, hot-boot and cold-boot
sites;
- gate true: exactly one occurrence at each, always followed by `never`.
The
count matters - asserting mere presence passes on the double-pass code
and
  would guard nothing;
- plus a test that `-help` detection is skipped entirely on the
already-running
  reuse fast path, pinning why the computation sits where it does.

`emulatorSupportsFlag` is stubbed in that file's existing
`vi.mock("../src/utils/adb")` factory, because the real one memoizes its
`-help` result per binary for the process lifetime and both branches
could not
otherwise be exercised in one file. The two pre-existing
launch-hardening
assertions were updated to the four remaining flags.

`packages/tool-server/test/emulator-supports-flag.test.ts` is new and
covers
the detection hardening: which outcomes are memoized (a verdict read
from a
non-zero exit's captured listing, and a normal zero-exit run) and which
are
not (spawn failure, timeout with empty streams, zero exit with no
listing,
timeout with partial output), the stderr warning, and `SIGKILL` on all
three
spawns.

Discrimination check - both source changes reverted (flag back in
`LAUNCH_HARDENING_ARGS`, `crashReportArgs` dropped from the probe's
`extraArgs`), tests kept:

```
Tests  5 failed | 26 passed (31)
  x omits the flag from the snapshot probe and the hot-boot spawn when the emulator rejects it
  x omits the flag from the cold-boot spawn when the emulator rejects it
  x passes the flag exactly once to the snapshot probe and the hot-boot spawn when supported
  x passes the flag exactly once to the cold-boot spawn when supported
  x keeps exactly one flag occurrence on the probe-reject cold fallback when supported

  AssertionError: expected 1 to be +0   (x2 - the flag reaches a site that must not see it)
  AssertionError: expected 2 to be 1    (x3 - the flag arrives twice)
```

Fix restored: `Tests 31 passed (31)`.

## Verification

- `npm run build` (`tsc --build`): clean.
- `npx vitest run` in `packages/tool-server`: clean.
- `npm run typecheck:tests --workspaces --if-present`: clean. This is
the CI
  gate that plain `tsc` + vitest does not cover.
- `npx prettier --check .` and `npm run lint` (`eslint . --max-warnings
0`):
  clean.
- End to end on a real emulator (36.1.9.0, the build that rejects the
flag).
  The gate reads the binary correctly:

  ```
  emulatorSupportsFlag(-crash-report-mode) = false
  emulatorSupportsFlag(-no-metrics)        = true
  emulatorSupportsFlag(-no-boot-anim)      = true
  ```

  and `boot-device` against `Pixel_6_API_34` returns
`{"platform":"android","serial":"emulator-5554","booted":true}` in 4.8
s.
  The live process argv shows the flag dropped and the four remaining
  hardening flags intact:

  ```
qemu-system-aarch64 -avd Pixel_6_API_34 -force-snapshot-load
-no-snapshot-save \
    -gpu auto -noaudio -no-boot-anim -netfast -no-metrics
  ```

`-force-snapshot-load` means the hot-boot path ran, so the snapshot
probe
  accepted its flag-free argv too.

## Docs

No docs update needed: `-crash-report-mode` is an internal launch
argument
that no reference page, config key or tool parameter exposes.
2026-08-28 12:49:10 +02:00

192 lines
7.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
const execFileMock = vi.fn();
const execFileOpts: Record<string, unknown>[] = [];
// Callback-style double, matching the pattern in adb-binary-failure-buffer.test.ts:
// adb.ts builds `promisify(execFile)` at module-eval time, so the mock must be a
// plain callback function — attaching `promisify.custom` here races module load
// order and leaves the generic wrapper awaiting a callback that never fires.
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
) => {
execFileOpts.push(opts as Record<string, unknown>);
const callback = typeof opts === "function" ? opts : cb!;
const result = execFileMock(cmd, args);
if (result instanceof Error) {
// Real execFile attaches the captured streams to the rejected error;
// replicate that here since the promisify wrapper does not.
const e = result as Error & { stdout?: string; stderr?: string };
e.stdout = e.stdout ?? "";
e.stderr = e.stderr ?? "";
callback(e, { stdout: e.stdout, stderr: e.stderr });
} else {
callback(null, result ?? { stdout: "", stderr: "" });
}
},
};
});
// `emulatorSupportsFlag` resolves the emulator binary before probing; stub the
// resolver to a bare name so the mock above matches 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, emulatorSupportsFlag, listAvds } from "../src/utils/adb";
beforeEach(() => {
execFileMock.mockReset();
execFileOpts.length = 0;
});
describe("emulatorSupportsFlag — failed `-help` probes are not memoized", () => {
// A transient failure (timeout under load, spawn error, silent exit) produces
// no listing and therefore no verdict. Memoizing it would pin `false` for the
// process lifetime and silently drop crash-dialog suppression on every later
// boot. The cache is keyed `${binaryPath}|${flag}`, so each case uses a
// distinct flag to stay isolated from the other cases in this file.
it("retries on the next call after a failed spawn that produced no output", async () => {
execFileMock.mockImplementationOnce(
() => new Error("spawn emulator ENOENT") as Error & { stdout?: string; stderr?: string }
);
execFileMock.mockImplementationOnce(() => ({
stdout: "usage: emulator [options]\n -retry-flag never\n",
stderr: "",
}));
expect(await emulatorSupportsFlag("-retry-flag")).toBe(false);
expect(await emulatorSupportsFlag("-retry-flag")).toBe(true);
expect(execFileMock).toHaveBeenCalledTimes(2);
});
it("does not memoize a timeout either (execFile kills with empty streams)", async () => {
execFileMock.mockImplementationOnce(() => {
const err = new Error("command timed out after 10s") as Error & {
stdout?: string;
stderr?: string;
killed?: boolean;
};
err.killed = true;
return err;
});
expect(await emulatorSupportsFlag("-timeout-flag")).toBe(false);
expect(execFileMock).toHaveBeenCalledTimes(1);
await emulatorSupportsFlag("-timeout-flag");
expect(execFileMock).toHaveBeenCalledTimes(2);
});
it("does not memoize a zero-exit run that printed no listing", async () => {
execFileMock.mockImplementationOnce(() => ({ stdout: "", stderr: "" }));
execFileMock.mockImplementationOnce(() => ({
stdout: " -silent-success-flag never\n",
stderr: "",
}));
expect(await emulatorSupportsFlag("-silent-success-flag")).toBe(false);
expect(await emulatorSupportsFlag("-silent-success-flag")).toBe(true);
expect(execFileMock).toHaveBeenCalledTimes(2);
});
it("does not memoize a timeout that captured only part of the listing", async () => {
execFileMock.mockImplementationOnce(() => {
const err = new Error("command timed out after 10s") as Error & {
stdout?: string;
stderr?: string;
killed?: boolean;
};
err.killed = true;
// Partial capture that happens not to contain the probed flag: a
// memoized verdict from it could miss a flag further down the listing.
err.stdout = "usage: emulator [options]\n -no-metrics\n";
return err;
});
execFileMock.mockImplementationOnce(() => ({
stdout: " -partial-timeout-flag never\n",
stderr: "",
}));
expect(await emulatorSupportsFlag("-partial-timeout-flag")).toBe(false);
expect(await emulatorSupportsFlag("-partial-timeout-flag")).toBe(true);
expect(execFileMock).toHaveBeenCalledTimes(2);
});
it("still memoizes a real verdict read from a non-zero exit's captured listing", async () => {
execFileMock.mockImplementation(() => {
const err = new Error("exit 1") as Error & { stdout?: string; stderr?: string };
// Some builds list usage on stderr while exiting non-zero.
err.stderr = "-captured-exit-flag never\n";
return err;
});
expect(await emulatorSupportsFlag("-captured-exit-flag")).toBe(true);
expect(await emulatorSupportsFlag("-captured-exit-flag")).toBe(true);
expect(execFileMock).toHaveBeenCalledTimes(1);
});
it("memoizes a normal zero-exit verdict too", async () => {
execFileMock.mockImplementation(() => ({ stdout: " -captured-ok-flag never\n", stderr: "" }));
expect(await emulatorSupportsFlag("-captured-ok-flag")).toBe(true);
expect(await emulatorSupportsFlag("-captured-ok-flag")).toBe(true);
expect(execFileMock).toHaveBeenCalledTimes(1);
});
it("kills a hung probe with SIGKILL so the timeout cannot be ignored", async () => {
// Node's default kill signal is SIGTERM; an emulator wedged in init can
// ignore it and the probe promise never settles, hanging boot-device.
execFileMock.mockImplementation(() => ({ stdout: " -sigkill-flag never\n", stderr: "" }));
await emulatorSupportsFlag("-sigkill-flag");
expect(execFileOpts[0]?.killSignal).toBe("SIGKILL");
});
it("warns on stderr when a probe fails without producing a verdict", async () => {
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
execFileMock.mockImplementationOnce(() => new Error("EMFILE: too many open files"));
await emulatorSupportsFlag("-warn-flag");
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('assuming "-warn-flag" unsupported for this boot')
);
stderrSpy.mockRestore();
});
});
// Same SIGKILL wiring as the -help probe above, pinned for the other two
// emulator spawns so a dropped killSignal cannot silently reintroduce the
// never-settling hang on the snapshot-probe or AVD-listing paths.
describe("emulator probe spawns are SIGKILL-protected", () => {
it("listAvds kills a hung `-list-avds` with SIGKILL", async () => {
execFileMock.mockImplementation(() => ({ stdout: "Pixel_7_API_34\n", stderr: "" }));
await listAvds();
expect(execFileOpts[0]?.killSignal).toBe("SIGKILL");
expect(execFileOpts[0]?.timeout).toBe(5_000);
});
it("checkSnapshotLoadable kills a hung probe with SIGKILL", async () => {
execFileMock.mockImplementation(() => ({ stdout: "Loadable\n", stderr: "" }));
const result = await checkSnapshotLoadable("Pixel_7_API_34");
expect(result.loadable).toBe(true);
expect(execFileOpts[0]?.killSignal).toBe("SIGKILL");
});
});