mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
43b7d79a2a
## 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.
192 lines
7.6 KiB
TypeScript
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");
|
|
});
|
|
});
|