diff --git a/e2e/slow-android-smoke.test.ts b/e2e/slow-android-smoke.test.ts index ed46c3556..10a42d08f 100644 --- a/e2e/slow-android-smoke.test.ts +++ b/e2e/slow-android-smoke.test.ts @@ -1,7 +1,9 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { homedir, userInfo } from "node:os"; +import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; @@ -30,6 +32,30 @@ async function adbDevices(serverPort: number): Promise { .map((line) => line.split(/\s+/)[0] as string); } +/** + * why: the emulator keeps crash dumps in `/tmp/android-/emu-crash-.db`, shared with + * Android Studio, and `-crash-report-mode never` deletes the ones in the database it reads. + * Returns the shared `completed/` folder of the installed emulator. + */ +async function sharedCrashCompletedDir(): Promise { + const emulator = join(ANDROID_HOME, "emulator", "emulator"); + const { stdout } = await execFileAsync(emulator, ["-version"]); + const version = /emulator version (\d+\.\d+\.\d+)/.exec(stdout)?.[1]; + if (version === undefined) { + throw new Error(`could not read the emulator version from: ${stdout}`); + } + return join("/tmp", `android-${userInfo().username}`, `emu-crash-${version}.db`, "completed"); +} + +/** why: the test removes only the folders it created, so it needs the topmost one that was missing. */ +function firstMissingAncestor(path: string): string | undefined { + let missing: string | undefined; + for (let dir = path; !existsSync(dir); dir = dirname(dir)) { + missing = dir; + } + return missing; +} + async function avdManagerList(): Promise { const avdmanager = join(ANDROID_HOME, "cmdline-tools", "latest", "bin", "avdmanager"); const binary = existsSync(avdmanager) @@ -86,7 +112,7 @@ describe.skipIf(!hasAndroidSdk)( { tags: ["slow", "android"] }, () => { it( - "catalog lists Simlock's Pixel 9 first, a cold lease of Pixel 9 boots a real emulator with the library screen size, and a shut-down emulator reports erasableReadable:false with no crash or false provenance finding", + "catalog lists Simlock's Pixel 9 first, a cold lease of Pixel 9 boots a real emulator with the library screen size and leaves a dump in the user's shared crash database alone, and a shut-down emulator reports erasableReadable:false with no crash or false provenance finding", { timeout: 420_000 }, async () => { const env = await withDaemon({ @@ -98,7 +124,26 @@ describe.skipIf(!hasAndroidSdk)( if (adbServerPort === undefined) { throw new Error("the real-SDK lane must allocate its own adb server port"); } + const completedDir = await sharedCrashCompletedDir(); + // why: crashpad skips a report file without its database xattrs, so a plain file would never be read, let alone deleted. + const dumpId = randomUUID(); + const planted = join(completedDir, `${dumpId}.dmp`); + const createdRoot = firstMissingAncestor(completedDir); try { + await mkdir(completedDir, { recursive: true }); + await writeFile(planted, "simlock slow-lane crash dump marker"); + await execFileAsync("xattr", [ + "-w", + "org.chromium.crashpad.database.uuid", + dumpId, + planted, + ]); + await execFileAsync("xattr", [ + "-w", + "org.chromium.crashpad.database.creation_time", + String(Math.floor(Date.now() / 1000)), + planted, + ]); const catalog = await env.cli(["catalog", "--json", "--platform", "android"]); expect(catalog.code).toBe(0); checkCatalog(catalog.json as AndroidCatalogOutput); @@ -143,6 +188,12 @@ describe.skipIf(!hasAndroidSdk)( const density = await env.cli(["adb", "shell", "wm", "density"]); expect(density.stdout).toContain("Physical density: 420"); + // why: the lease is up, so no dialog blocked the boot, and the launch must have left the user's shared crash database alone. + expect( + existsSync(planted), + "the lease deleted a dump in the user's shared crash db", + ).toBe(true); + const refused = await env.cli(["adb", "kill-server"]); expect(refused.code, "simlock adb kill-server must be refused, not run").toBe(2); expect(refused.error?.code).toBe("USAGE"); @@ -210,6 +261,10 @@ describe.skipIf(!hasAndroidSdk)( const list = await env.cli(["list", "--devices"]); expect(list.code).toBe(0); } finally { + await rm(planted, { force: true }); + if (createdRoot !== undefined) { + await rm(createdRoot, { recursive: true, force: true }); + } await env .cli(["nuke", "--delete-devices", "--yes"], { timeout: 120_000 }) .catch(() => undefined); diff --git a/src/daemon/main.test.ts b/src/daemon/main.test.ts index 65aae48fc..fc524c26d 100644 --- a/src/daemon/main.test.ts +++ b/src/daemon/main.test.ts @@ -2419,6 +2419,8 @@ describe("discoverDrivers on a host with an Android SDK", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", "-no-window", "-gpu", "host", diff --git a/src/drivers/android/AGENTS.md b/src/drivers/android/AGENTS.md index 6da184cae..92028473f 100644 --- a/src/drivers/android/AGENTS.md +++ b/src/drivers/android/AGENTS.md @@ -61,7 +61,7 @@ Provision — `provision`: Boot — `makeReady` (per-device lock): 1. Baseline: stored hash matches → `-snapshot simlock_clean_baseline`; differs → delete - the snapshots, `-wipe-data`; none → cold boot. Always `-no-snapshot-save`, and `-grpc -grpc-use-token` (without `-grpc`, `-port` leaves the emulator with no gRPC endpoint and no `avd/running` discovery file). + the snapshots, `-wipe-data`; none → cold boot. Always `-no-snapshot-save`, and `-grpc -grpc-use-token` (without `-grpc`, `-port` leaves the emulator with no gRPC endpoint and no `avd/running` discovery file), and `-crash-report-mode never` (a stale crash dump otherwise opens a modal consent dialog that blocks the boot; `never` deletes the dumps in the database it reads, so the spawn env sets `ANDROID_EMU_CRASH_REPORTING_DATABASE` to `/emu-crash.db`, never the user's shared `/tmp/android-/emu-crash-.db`). 2. Console port reserved at the first `prepare` boot in this process: even 5586–5682, not in `adb devices`, not reserved, port, port+1 and port+3000 (gRPC) not listening. 3. Ready = `sys.boot_completed` 1, `init.svc.bootanim` stopped or unset, `adb emu avd path` diff --git a/src/drivers/android/index.test.ts b/src/drivers/android/index.test.ts index 9dd019c38..7ce7984d0 100644 --- a/src/drivers/android/index.test.ts +++ b/src/drivers/android/index.test.ts @@ -60,7 +60,10 @@ const scopedEnv = { } as const; const scopedOptions = { env: scopedEnv } as const; // why: The emulator outlives the daemon and is never read from, so it is spawned detached. -const emulatorOptions = { env: scopedEnv, stdio: "ignore" } as const; +const emulatorOptions = { + env: { ...scopedEnv, ANDROID_EMU_CRASH_REPORTING_DATABASE: `${avdDirectory}/emu-crash.db` }, + stdio: "ignore", +} as const; const binaries = { adb: `${sdk}/platform-tools/adb`, avdmanager: `${sdk}/cmdline-tools/latest/bin/avdmanager`, @@ -513,6 +516,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", "-no-snapshot-load", ], command: binaries.emulator, @@ -552,6 +557,50 @@ describe("AndroidDriver", () => { ); }); + it("starts the cold boot and the restart from the baseline with -crash-report-mode never and a crash database under the Simlock root, so a stale crash dump asks no one and the user's shared database is never read", async () => { + const filesystem = await androidFilesystem(); + const host = new EmulatorHost(filesystem); + const launches: (readonly string[])[] = []; + const crashDatabases: (string | undefined)[] = []; + const hostRunner = host.runner(); + const runner: ProcessRunner = { + ...hostRunner, + spawn: (command, args, options) => { + launches.push(args); + crashDatabases.push(options?.env?.ANDROID_EMU_CRASH_REPORTING_DATABASE); + return hostRunner.spawn(command, args, options); + }, + }; + const driver = await createDriver(filesystem, runner, { + ids: ["one"], + tcpProbe: new FakeTcpProbe([adbServerPort]), + }); + const spec = { + physical: false, + model: "Pixel 5", + osVersion: "34", + platform: "android", + } as const; + await driver.resolveSpec(spec); + + await driver.makeReady(await driver.provision(spec)); + + expect(crashDatabases).toEqual([ + `${avdDirectory}/emu-crash.db`, + `${avdDirectory}/emu-crash.db`, + ]); + const crashReportMode = (args: readonly string[]) => { + const flag = args.indexOf("-crash-report-mode"); + return flag === -1 ? undefined : args[flag + 1]; + }; + expect( + launches.map((args) => ({ boot: args.at(-1), crashReportMode: crashReportMode(args) })), + ).toEqual([ + { boot: "-no-snapshot-load", crashReportMode: "never" }, + { boot: "simlock_clean_baseline", crashReportMode: "never" }, + ]); + }); + /** * why: The half of the daemon-stop fix that argv cannot show. `stdio: "ignore"` alone does not * release the event loop -- the ChildProcess handle does -- so without the `unref` a @@ -673,6 +722,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", "-snapshot", "simlock_clean_baseline", ], @@ -790,6 +841,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", "-wipe-data", "-no-snapshot-load", ], @@ -1101,6 +1154,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", "-snapshot", "simlock_clean_baseline", ], @@ -1153,6 +1208,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", "-snapshot", "simlock_clean_baseline", ], @@ -1214,6 +1271,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", "-no-snapshot-load", ], [ @@ -1225,6 +1284,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", "-snapshot", "simlock_clean_baseline", ], @@ -1244,7 +1305,7 @@ describe("AndroidDriver", () => { const launches = emulatorLaunches(harness.runner); expect(launches).toHaveLength(2); for (const args of launches) { - expect(args.slice(0, 8 + flags.length)).toEqual([ + expect(args.slice(0, 10 + flags.length)).toEqual([ "-avd", "simlock_one", "-port", @@ -1253,6 +1314,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", ...flags, ]); } @@ -1273,6 +1336,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", "-gpu", "swiftshader_indirect", "-no-snapshot-load", @@ -1336,6 +1401,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", ...flags, "-snapshot", "simlock_clean_baseline", @@ -1378,6 +1445,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", ...flags, "-wipe-data", "-no-snapshot-load", @@ -1407,6 +1476,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", ...flags, "-snapshot", "simlock_clean_baseline", @@ -1436,6 +1507,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", ...flags, "-wipe-data", "-no-snapshot-load", @@ -1464,6 +1537,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", ...flags, "-snapshot", "simlock_clean_baseline", @@ -1517,6 +1592,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", ...launchFlags, "-no-snapshot-load", ], @@ -1558,6 +1635,8 @@ describe("AndroidDriver", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", ...flags, "-no-snapshot-load", ], @@ -5414,6 +5493,8 @@ describe("AndroidDriver console port reservations", () => { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", "-no-snapshot-load", ], command: binaries.emulator, @@ -7214,6 +7295,8 @@ function baselineBuildExpectations(options: { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", ...emulatorFlags, ...options.launchArgs, ], @@ -7304,6 +7387,8 @@ function baselineBuildExpectations(options: { "-grpc", "8586", "-grpc-use-token", + "-crash-report-mode", + "never", ...emulatorFlags, "-snapshot", "simlock_clean_baseline", diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index 9ba50d3ae..f0d6e071c 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -935,6 +935,18 @@ export class AndroidDriver implements Driver { }; } + /** + * why: `-crash-report-mode never` deletes every dump in the crash database the emulator reads, and + * by default that is `/tmp/android-/emu-crash-.db`, shared with Android Studio and + * the user's own emulators. A database inside Simlock's own root keeps those dumps untouched. + */ + #emulatorEnv(): NodeJS.ProcessEnv { + return { + ...this.#env(), + ANDROID_EMU_CRASH_REPORTING_DATABASE: join(this.#deviceRoot, "emu-crash.db"), + }; + } + /** * why: Announces every console port Simlock may have an emulator on to the server that was * just started or adopted -- deliberately doing, for Simlock's own range only, what adb's @@ -2235,10 +2247,13 @@ export class AndroidDriver implements Driver { "-grpc", String(data.port + GRPC_PORT_OFFSET), "-grpc-use-token", + // why: a dump an earlier crash left in a crash database makes the emulator open a modal consent dialog at launch, which blocks its main thread before any port opens; `never` skips the dialog and deletes the dumps in the database it reads, so `#emulatorEnv` points that at Simlock's own root and never at the user's shared one. + "-crash-report-mode", + "never", ...this.#emulatorFlags, ...launchArgs, ], - { env: this.#env(), stdio: "ignore" }, + { env: this.#emulatorEnv(), stdio: "ignore" }, ); handle.unref(); state.handle = handle; diff --git a/src/drivers/android/physical-handler.test.ts b/src/drivers/android/physical-handler.test.ts index 15d7100e4..d1c71434a 100644 --- a/src/drivers/android/physical-handler.test.ts +++ b/src/drivers/android/physical-handler.test.ts @@ -1375,7 +1375,7 @@ describe("Android physical handler: independence from emulator calls", () => { { hangs: true, match: { - args: [/^-avd$/, "simlock_one", /.*/, /.*/, /.*/, /.*/, /.*/, /.*/, /.*/], + args: [/^-avd$/, "simlock_one", /.*/, /.*/, /.*/, /.*/, /.*/, /.*/, /.*/, /.*/, /.*/], command: `${sdk}/emulator/emulator`, }, },