From 87f1bb299f7074ac71848f7d1e7591514184033e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:12:43 +0000 Subject: [PATCH 1/4] feat(config,android): configure emulator launch options from the config file (#148) Adds an operator-only `android.emulator` block (headless, gpu, audio, bootAnimation) that the daemon hands to the Android driver unread. The driver maps each key to one fixed emulator flag after -no-snapshot-save; headless and gpu enter the clean-baseline hash so the snapshot rebuilds when either changes. Defaults launch exactly as before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PvsMWpwkPBL4Zo2ppLTxDA --- docs/CONFIGURATION.md | 48 +++- e2e/daemon-lifecycle.test.ts | 11 + src/cli/index.test.ts | 1 + src/contract/operations.test.ts | 1 + src/contract/schemas.ts | 8 + src/core/acquisition-planner.test.ts | 1 + src/core/cleanup/idle-destroy.test.ts | 1 + src/core/cleanup/idle-shutdown.test.ts | 1 + src/core/config.test.ts | 90 ++++++++ src/core/config.ts | 32 +++ src/core/doctor.test.ts | 1 + .../lease-acquisition-coordinator.test.ts | 1 + src/core/lease-engine.test.ts | 1 + src/core/lease-health-monitor.test.ts | 1 + src/core/nuke.test.ts | 1 + src/core/reaper.test.ts | 1 + src/core/validation.ts | 8 + src/core/warm-pool-coordinator.test.ts | 1 + src/daemon/dispatcher.test.ts | 20 ++ src/daemon/main.test.ts | 71 +++++- src/daemon/main.ts | 8 + src/daemon/server.test.ts | 1 + src/drivers/android/index.test.ts | 213 +++++++++++++++++- src/drivers/android/index.ts | 94 +++++++- src/gateway/dispatcher.test.ts | 1 + src/http/test-fakes.ts | 1 + 26 files changed, 606 insertions(+), 12 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b9deb2df..e4e233b6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -53,6 +53,10 @@ a warning. Inspect the effective, merged configuration at any time with | `ios.slim.enabled` | Master switch for slim mode: disables iOS simulator daemon categories to cut RAM and CPU overhead per device. | `false` | | `ios.slim.categories` | Which daemon categories to disable when slim mode is on. Omitted means every category the driver knows. | every known category | | `ios.slim.bootTimeoutMs` | Boot deadline used while slim mode is on, in place of the normal boot timeout. | `10 minutes` | +| `android.emulator.headless` | Launch emulators without a window (`-no-window`). Needed on a host with no display, such as a Linux CI runner. See [Android emulator launch options](#android-emulator-launch-options). | `false` | +| `android.emulator.gpu` | The emulator's GPU mode, passed as `-gpu ` (for example `host`, `swiftshader_indirect`, `guest`). `auto` passes nothing and leaves the emulator's own choice. | `auto` | +| `android.emulator.audio` | `false` launches emulators without audio (`-no-audio`). | `true` | +| `android.emulator.bootAnimation` | `false` launches emulators without the boot animation (`-no-boot-anim`). | `true` | All limit values must be positive integers; all durations and byte sizes must be non-negative numbers (milliseconds and bytes, respectively). @@ -70,6 +74,10 @@ integer in `1`-`65535`. numbers. `ios.slim.enabled` is a boolean, `ios.slim.categories` an array of non-empty strings, and `ios.slim.bootTimeoutMs` a positive number. +`android.emulator.headless`, `android.emulator.audio`, and +`android.emulator.bootAnimation` are booleans, and `android.emulator.gpu` a +non-empty string. Any other value is rejected at load, and the error names the +key. `mode` must be exactly `"worker"` or `"gateway"`. `gateway.url` must be an absolute `ws`/`wss` URL — `http`/`https` are rejected — and `gateway.token` a non-empty string; **in `mode: "worker"`**, setting either without the other @@ -125,7 +133,7 @@ off by default. **A gateway reads a deliberately small slice of this file**: `mode`, `http.*`, `log.*`, `lease.*`, `eventBuffer.*` and `gateway.*`. Every other key — -capacity, drivers, downloads, idle, warmPool, health, ios, stalledTransition — +capacity, drivers, downloads, idle, warmPool, health, ios, android, stalledTransition — configures devices, which a gateway does not have; each one present in a gateway's config is reported with a warning and ignored, the same treatment an unknown key gets. The worker-side `gateway.url`/`token`/`label` are warned @@ -215,7 +223,7 @@ the mismatch changes on a later refresh) — loud enough to catch the misconfiguration without silently overriding it. Everything else — `capacity.*`, `idle.*`, `warmPool.*`, `health.*`, -`stalledTransition.*`, `drivers.*`, `ios.slim.*`, `diskPressure.*`, +`stalledTransition.*`, `drivers.*`, `ios.slim.*`, `android.emulator.*`, `diskPressure.*`, `downloads.*`, and the worker-side `gateway.url`/`gateway.token`/ `gateway.label`/`exec.timeoutMs` — is **ignored with a warning**, exactly as an unknown key is. That is deliberately the softer treatment: a gateway's @@ -362,6 +370,42 @@ first boot and a second, slower one -- which is why `ios.slim.bootTimeoutMs` defaults higher than the normal boot timeout, especially on slower CI runners. +## Android emulator launch options + +`android.emulator` sets how this machine's Android emulators are launched. +By default each one opens a window, uses the emulator's default GPU mode, +plays audio, and shows the boot animation. Set it once, in the config file; +no lease request, MCP call, or HTTP request can change it. + +```json +{ + "android": { + "emulator": { + "headless": true, + "gpu": "swiftshader_indirect", + "audio": false, + "bootAnimation": false + } + } +} +``` + +**A change applies at a device's next boot.** A running emulator keeps the +flags it started with; restart the daemon to load the new config, and each +device picks it up the next time it boots. + +**Changing `headless` or `gpu` rebuilds a device's clean snapshot.** Simlock +resets an Android device between leases by loading a clean snapshot, and a +snapshot taken under one window or GPU mode does not load cleanly under +another. So on the next boot after either key changes, Simlock wipes the +device and captures a fresh snapshot, instead of letting every later reset +fall back to a full wipe. Changing `audio` or `bootAnimation` keeps the +snapshot. + +Only these four keys exist. There is no way to pass other emulator +arguments from config, so nothing here can move an emulator's port or AVD +home. + ## Capacity strategies How many devices Simlock lets exist and run at once is decided by a capacity diff --git a/e2e/daemon-lifecycle.test.ts b/e2e/daemon-lifecycle.test.ts index 6a9b00a5..15def536 100644 --- a/e2e/daemon-lifecycle.test.ts +++ b/e2e/daemon-lifecycle.test.ts @@ -30,6 +30,17 @@ describe("daemon lifecycle & recovery", () => { expect(countOccurrences(after, "Daemon started")).toBe(startedCountBefore); }); + it("simlock config renders the android.emulator block with its defaults", async () => { + const env = await withDaemon({ mode: "running" }); + + const config = await env.cli(["config"]); + + expect(config.code).toBe(0); + expect((config.json as { android?: unknown }).android).toEqual({ + emulator: { audio: true, bootAnimation: true, gpu: "auto", headless: false }, + }); + }); + it("recovers from a kill -9 that leaves a stale socket behind", async () => { const env = await withDaemon({ mode: "running" }); expect(existsSync(env.socketPath)).toBe(true); diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 61a76d22..97f99d3e 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -3326,6 +3326,7 @@ function testConfig(): Config { downloads: { policy: "on-request", acceptAndroidLicenses: false, timeoutMs: 1_200_000 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, idle: { deleteAfterMs: 60_000, shutdownAfterMs: 10_000 }, lease: { defaultTtlMs: 60_000, diff --git a/src/contract/operations.test.ts b/src/contract/operations.test.ts index 891088ab..039e9f46 100644 --- a/src/contract/operations.test.ts +++ b/src/contract/operations.test.ts @@ -409,6 +409,7 @@ describe("operation input/output round trips", () => { maxConcurrentRecoveries: 1, }, ios: { slim: { enabled: false, bootTimeoutMs: 1 } }, + android: { emulator: { headless: true, gpu: "host", audio: false, bootAnimation: false } }, stalledTransition: { thresholdMultiplier: 1, minimumThresholdMs: 1 }, }; expect(OPERATIONS["config.get"].output.parse(config)).toBeDefined(); diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index df40a379..7c1d8cc7 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -468,6 +468,14 @@ export const configSchema = z.object({ bootTimeoutMs: z.number(), }), }), + android: z.object({ + emulator: z.object({ + headless: z.boolean(), + gpu: z.string(), + audio: z.boolean(), + bootAnimation: z.boolean(), + }), + }), /** ADR 0005 §3/§6. `url`/`token`/`label` are the worker's half, `disconnectedRetentionMs` * the gateway's; a daemon carries the whole block whichever mode it runs in, and simply * reads the half that applies (see `Config["gateway"]`). */ diff --git a/src/core/acquisition-planner.test.ts b/src/core/acquisition-planner.test.ts index 9b01e553..4716a75c 100644 --- a/src/core/acquisition-planner.test.ts +++ b/src/core/acquisition-planner.test.ts @@ -33,6 +33,7 @@ const config: Config = { downloads: { policy: "on-request", acceptAndroidLicenses: false, timeoutMs: 1_200_000 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, idle: { deleteAfterMs: 60_000, shutdownAfterMs: 10_000 }, warmPool: { quarantine: { diff --git a/src/core/cleanup/idle-destroy.test.ts b/src/core/cleanup/idle-destroy.test.ts index 8ec0e685..a5b81804 100644 --- a/src/core/cleanup/idle-destroy.test.ts +++ b/src/core/cleanup/idle-destroy.test.ts @@ -29,6 +29,7 @@ const config: Config = { downloads: { policy: "on-request", acceptAndroidLicenses: false, timeoutMs: 1_200_000 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, idle: { deleteAfterMs: 30_000, shutdownAfterMs: 10_000 }, warmPool: { quarantine: { diff --git a/src/core/cleanup/idle-shutdown.test.ts b/src/core/cleanup/idle-shutdown.test.ts index 7a1189fc..e087ecb7 100644 --- a/src/core/cleanup/idle-shutdown.test.ts +++ b/src/core/cleanup/idle-shutdown.test.ts @@ -27,6 +27,7 @@ const config: Config = { downloads: { policy: "on-request", acceptAndroidLicenses: false, timeoutMs: 1_200_000 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, idle: { deleteAfterMs: 30_000, shutdownAfterMs: 10_000 }, warmPool: { quarantine: { diff --git a/src/core/config.test.ts b/src/core/config.test.ts index f9a5dc32..ef8ac0a6 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -764,6 +764,82 @@ describe("loadConfig", () => { expect(warn).toHaveBeenCalledWith('Unknown config key: "ios.slim.turboMode"'); }); + it("defaults android.emulator to a windowed launch with the emulator's GPU, audio, and boot animation", async () => { + const config = await loadConfig({ + configPath, + filesystem: new MemoryFilesystem(), + systemStats: createStats(), + }); + + expect(config.android.emulator).toEqual({ + audio: true, + bootAnimation: true, + gpu: "auto", + headless: false, + }); + }); + + it("applies a file-level android.emulator override", async () => { + const filesystem = new MemoryFilesystem(); + await filesystem.mkdirp("/home/agent/.simlock"); + await filesystem.writeFileAtomic( + configPath, + JSON.stringify({ + android: { + emulator: { + audio: false, + bootAnimation: false, + gpu: "swiftshader_indirect", + headless: true, + }, + }, + }), + ); + + const config = await loadConfig({ configPath, filesystem, systemStats: createStats() }); + expect(config.android.emulator).toEqual({ + audio: false, + bootAnimation: false, + gpu: "swiftshader_indirect", + headless: true, + }); + }); + + it.each([ + [{ android: { emulator: { headless: "yes" } } }, "android.emulator.headless"], + [{ android: { emulator: { audio: 0 } } }, "android.emulator.audio"], + [{ android: { emulator: { bootAnimation: "false" } } }, "android.emulator.bootAnimation"], + [{ android: { emulator: { gpu: "" } } }, "android.emulator.gpu"], + [{ android: { emulator: { gpu: true } } }, "android.emulator.gpu"], + ])("rejects a malformed android.emulator key at load, naming it", async (contents, path) => { + const filesystem = new MemoryFilesystem(); + await filesystem.mkdirp("/home/agent/.simlock"); + await filesystem.writeFileAtomic(configPath, JSON.stringify(contents)); + + await expect( + loadConfig({ configPath, filesystem, systemStats: createStats() }), + ).rejects.toThrow(`Invalid config value for "${path}"`); + }); + + it("warns about and drops an unknown key under android.emulator, so no free-form launch argument reaches the driver", async () => { + const filesystem = new MemoryFilesystem(); + const warn = vi.fn(); + await filesystem.mkdirp("/home/agent/.simlock"); + await filesystem.writeFileAtomic( + configPath, + JSON.stringify({ android: { emulator: { launchArgs: ["-port", "5554"] } } }), + ); + + const config = await loadConfig({ configPath, filesystem, systemStats: createStats(), warn }); + expect(warn).toHaveBeenCalledWith('Unknown config key: "android.emulator.launchArgs"'); + expect(Object.keys(config.android.emulator).sort()).toEqual([ + "audio", + "bootAnimation", + "gpu", + "headless", + ]); + }); + it("applies a file-level stalledTransition override", async () => { const filesystem = new MemoryFilesystem(); await filesystem.mkdirp("/home/agent/.simlock"); @@ -1113,6 +1189,20 @@ describe("loadConfig modes (ADR 0005)", () => { expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Ignoring "lease"')); }); + it("warns about android.emulator on a gateway without refusing the config", async () => { + const warn = vi.fn(); + const config = await load( + { mode: "gateway", android: { emulator: { headless: true } } }, + { warn }, + ); + + expect(warn).toHaveBeenCalledWith( + 'Ignoring "android": it configures a worker, and this daemon runs in gateway mode.', + ); + // Ignored, not rejected: the gateway still starts. + expect(config.mode).toBe("gateway"); + }); + it("warns about the worker-side gateway.* keys on a gateway, but not the gateway's own", async () => { const warn = vi.fn(); const config = await load({ diff --git a/src/core/config.ts b/src/core/config.ts index f49f2afe..8482e70f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -17,6 +17,7 @@ import { ConfigError, integerInRange, invalidValue, + nonEmptyString, nonNegativeNumber, numberAtLeast, objectValidator, @@ -185,6 +186,21 @@ export interface Config { readonly bootTimeoutMs: number; }; }; + /** + * How this machine's Android emulators are launched. Operator configuration of the Android + * driver, the same way `ios.slim` configures the iOS one: the core hands the block to the + * driver unread, and no lease request can set it. Which emulator flag a key becomes is the + * driver's business. Applies at a device's next boot. + */ + readonly android: { + readonly emulator: { + readonly headless: boolean; + /** An emulator GPU mode; `"auto"` leaves the emulator's own default. */ + readonly gpu: string; + readonly audio: boolean; + readonly bootAnimation: boolean; + }; + }; /** * ADR 0005 §3/§6. Both sides of the fleet live under one key, and which half is read depends * on `mode`: @@ -601,6 +617,14 @@ function defaultConfig( bootTimeoutMs: 600_000, }, }, + android: { + emulator: { + headless: false, + gpu: "auto", + audio: true, + bootAnimation: true, + }, + }, gateway: { disconnectedRetentionMs: DEFAULT_DISCONNECTED_RETENTION_MS, execTimeoutMs: DEFAULT_GATEWAY_EXEC_TIMEOUT_MS, @@ -733,6 +757,14 @@ function configValidators(strategy: CapacityStrategyName): Record downloads: { policy: "on-request", acceptAndroidLicenses: false, timeoutMs: 1_200_000 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, }; } diff --git a/src/core/lease-acquisition-coordinator.test.ts b/src/core/lease-acquisition-coordinator.test.ts index f4e6d7cc..0f1c03f1 100644 --- a/src/core/lease-acquisition-coordinator.test.ts +++ b/src/core/lease-acquisition-coordinator.test.ts @@ -39,6 +39,7 @@ function config(maxDevices = 1): Config { eventBuffer: { capacity: 100 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, health: { enabled: true, maxConcurrentRecoveries: 1, diff --git a/src/core/lease-engine.test.ts b/src/core/lease-engine.test.ts index 02016557..0fa789cd 100644 --- a/src/core/lease-engine.test.ts +++ b/src/core/lease-engine.test.ts @@ -36,6 +36,7 @@ function config(overrides: Partial = {}): Config { eventBuffer: { capacity: 100 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, health: { enabled: true, maxConcurrentRecoveries: 1, diff --git a/src/core/lease-health-monitor.test.ts b/src/core/lease-health-monitor.test.ts index b0b223bb..78382724 100644 --- a/src/core/lease-health-monitor.test.ts +++ b/src/core/lease-health-monitor.test.ts @@ -63,6 +63,7 @@ function config(overrides: Partial = {}): Config { downloads: { policy: "on-request", acceptAndroidLicenses: false, timeoutMs: 1_200_000 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, }; } diff --git a/src/core/nuke.test.ts b/src/core/nuke.test.ts index 9a7e6695..fe617030 100644 --- a/src/core/nuke.test.ts +++ b/src/core/nuke.test.ts @@ -168,6 +168,7 @@ function config(): Config { downloads: { policy: "on-request", acceptAndroidLicenses: false, timeoutMs: 1_200_000 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, idle: { deleteAfterMs: 10, shutdownAfterMs: 5 }, lease: { defaultTtlMs: 60_000, diff --git a/src/core/reaper.test.ts b/src/core/reaper.test.ts index 7f0d34f2..4721640e 100644 --- a/src/core/reaper.test.ts +++ b/src/core/reaper.test.ts @@ -67,6 +67,7 @@ function config(): Config { downloads: { policy: "on-request", acceptAndroidLicenses: false, timeoutMs: 1_200_000 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, idle: { deleteAfterMs: 30_000, shutdownAfterMs: 10_000 }, lease: { defaultTtlMs: 100, maxTtlMs: 100, identity: { ios: "reusable", android: "reusable" } }, capacity: { diff --git a/src/core/validation.ts b/src/core/validation.ts index 197c4825..52b62baf 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -87,6 +87,14 @@ export function stringValue(value: unknown, path: string): string { return value; } +export function nonEmptyString(value: unknown, path: string): string { + if (typeof value !== "string" || value.length === 0) { + throw invalidValue(path, "a non-empty string"); + } + + return value; +} + export function numberAtLeast(minimum: number): Validator { return (value: unknown, path: string) => { if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) { diff --git a/src/core/warm-pool-coordinator.test.ts b/src/core/warm-pool-coordinator.test.ts index 170444fe..f5c27db2 100644 --- a/src/core/warm-pool-coordinator.test.ts +++ b/src/core/warm-pool-coordinator.test.ts @@ -42,6 +42,7 @@ const config: Config = { downloads: { policy: "on-request", acceptAndroidLicenses: false, timeoutMs: 1_200_000 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, idle: { deleteAfterMs: 60_000, shutdownAfterMs: 10_000 }, warmPool: { quarantine: { diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 749f767d..254402c3 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -11,6 +11,7 @@ import { Registry, type Config, } from "../core/index.js"; +import { OPERATIONS } from "../contract/index.js"; import type { CatalogReader, PassthroughResolver } from "../core/lease-ports.js"; import { CryptoTokenSecrets, @@ -203,6 +204,24 @@ describe("Dispatcher: parsing", () => { ).rejects.toMatchObject({ code: "BAD_REQUEST" }); }); + it.each([ + ["android", { emulator: { headless: true } }], + ["emulator", { headless: true }], + ])( + "rejects a lease.request carrying an %s field with BAD_REQUEST: emulator launch options are config-only", + async (field, value) => { + const { dispatcher } = await buildDispatcher(); + const valid = { model: "Pixel 8", osVersion: "34", platform: "android" }; + // The same request without the field is well-formed, so the refusal below is about the + // field and nothing else. + expect(OPERATIONS["lease.request"].input.safeParse(valid).success).toBe(true); + + await expect( + dispatcher.dispatch("lease.request", { ...valid, [field]: value }, session()), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }, + ); + it("rejects a ttlMs above lease.maxTtlMs on a request, rather than clamping it", async () => { // ADR 0004 §4: the cap is enforced here rather than in the contract schema, because // `lease.maxTtlMs` is a daemon config value the contract module cannot see -- and every @@ -1333,6 +1352,7 @@ function testConfig( downloads: { policy: downloadsPolicy, acceptAndroidLicenses: false, timeoutMs: 1_200_000 }, http: { enabled: false, host: "127.0.0.1", port: 4700 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, idle: { deleteAfterMs: 60_000, shutdownAfterMs: 10_000 }, lease: { defaultTtlMs: 60_000, diff --git a/src/daemon/main.test.ts b/src/daemon/main.test.ts index 701e092c..b158c7d4 100644 --- a/src/daemon/main.test.ts +++ b/src/daemon/main.test.ts @@ -27,6 +27,7 @@ import { emitSlimDiagnostic, startDaemon, wireComponentInstallLogging, + type DriverDiscoveryContext, type StartDaemonOptions, } from "./main.js"; import type { DaemonServer } from "./server.js"; @@ -41,7 +42,13 @@ afterEach(async () => { ); }); -async function start(overrides: Partial = {}) { +/** `drivers: undefined` is spelled out by a caller that wants real (module-substituted) + * discovery rather than the fake driver below. */ +async function start( + overrides: Partial> & { + readonly drivers?: StartDaemonOptions["drivers"] | undefined; + } = {}, +) { const directory = await mkdtemp(join(tmpdir(), "simlock-main-")); temporaryDirectories.push(directory); const sink = new MemoryLogSink(); @@ -667,6 +674,45 @@ describe("discoverDrivers on a host with an Android SDK", () => { ); }); + it("launches the discovered Android driver's emulators with the android.emulator options it was handed", async () => { + const filesystem = await androidSdk(); + await filesystem.mkdirp(SIMLOCK_HOME); + await filesystem.writeFileAtomic( + join(SIMLOCK_HOME, "adb-server.json"), + JSON.stringify({ pid: 4242, port: 5038, startedAt: 1 }), + ); + // No scripted process at all: the first one the driver starts is the emulator launch, which + // the runner records and then refuses, so the boot stops right after showing its argv. + const processRunner = new ScriptedProcessRunner([]); + + const { drivers } = await discoverAndroid(filesystem, new FakeTcpProbe([5038]), [4242], { + androidEmulator: { audio: true, bootAnimation: true, gpu: "host", headless: true }, + processRunner, + }); + const android = drivers.find((driver) => driver.platform === "android"); + await expect( + android?.makeReady({ + address: "emulator-5586", + deviceId: "simlock_one", + driverData: { avdName: "simlock_one", configHash: "", port: 5586, serial: "emulator-5586" }, + }), + ).rejects.toThrow(/Unexpected process invocation/); + + expect(processRunner.calls.map((call) => call.args)).toEqual([ + [ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + "-no-window", + "-gpu", + "host", + "-no-snapshot-load", + ], + ]); + }); + /** The minimum layout `discoverSdk` accepts, in memory. */ async function androidSdk(): Promise { const filesystem = new MemoryFilesystem(); @@ -687,8 +733,10 @@ describe("discoverDrivers on a host with an Android SDK", () => { filesystem: MemoryFilesystem, tcpProbe: FakeTcpProbe, livePids: readonly number[] = [], + overrides: Partial> = {}, ) { return discoverDrivers({ + ...overrides, clock: new FakeClock(), driversConfig: {}, eventBus: new EventBus(new FakeClock()), @@ -701,7 +749,7 @@ describe("discoverDrivers on a host with an Android SDK", () => { level: "debug", sink: new MemoryLogSink(), }), - processRunner: new ScriptedProcessRunner([]), + processRunner: overrides.processRunner ?? new ScriptedProcessRunner([]), processSupervisor: new FakeProcessSupervisor(livePids), simlockHome: SIMLOCK_HOME, tcpProbe, @@ -1085,6 +1133,25 @@ describe("discoverDrivers with SIMLOCK_DRIVERS_MODULE", () => { ); }); + it("hands config's android.emulator block to driver discovery when the daemon starts", async () => { + const key = "__simlockDiscoveredAndroidEmulator"; + process.env.SIMLOCK_DRIVERS_MODULE = await writeModule( + `export function createDrivers(context) { + globalThis.${key} = context.androidEmulator; + return []; + }`, + ); + const emulator = { audio: false, bootAnimation: false, gpu: "host", headless: true }; + + try { + await start({ configOverrides: { android: { emulator } }, drivers: undefined }); + + expect((globalThis as Record)[key]).toEqual(emulator); + } finally { + delete (globalThis as Record)[key]; + } + }); + it("supports a synchronous createDrivers returning an array directly", async () => { process.env.SIMLOCK_DRIVERS_MODULE = await writeModule( `export function createDrivers() { return []; }`, diff --git a/src/daemon/main.ts b/src/daemon/main.ts index 078cb8f8..5aae95f1 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -24,6 +24,7 @@ import { ANDROID_PASSTHROUGH_TOOL, SdkMissingError, type AndroidDriverDiagnostic, + type AndroidEmulatorLaunchOptions, } from "../drivers/android/index.js"; import type { ComponentInstallDiagnostic } from "../drivers/diagnostics.js"; import { IOS_PASSTHROUGH_TOOL, IosSimctlDriver, type SlimmedFact } from "../drivers/ios/index.js"; @@ -190,6 +191,7 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise { }); }); + describe("android.emulator launch options", () => { + const defaults: AndroidEmulatorLaunchOptions = { + audio: true, + bootAnimation: true, + gpu: "auto", + headless: false, + }; + const emulatorLaunches = (runner: ScriptedProcessRunner) => + runner.calls + .filter((call) => call.command === binaries.emulator && call.args.includes("-avd")) + .map((call) => call.args); + + it("launches with the same arguments as before these options existed under the default config", async () => { + for (const emulator of [undefined, defaults]) { + const harness = await provisionedHarness(emulator === undefined ? {} : { emulator }); + + await harness.driver.makeReady(harness.device); + + expect(emulatorLaunches(harness.runner)).toEqual([ + ["-avd", "simlock_one", "-port", "5586", "-no-snapshot-save", "-no-snapshot-load"], + [ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + "-snapshot", + "simlock_clean_baseline", + ], + ]); + } + }); + + it("adds -no-window for headless, -no-audio for audio: false, and -no-boot-anim for bootAnimation: false", async () => { + const flags = ["-no-window", "-no-audio", "-no-boot-anim"]; + const harness = await provisionedHarness({ + emulator: { ...defaults, audio: false, bootAnimation: false, headless: true }, + emulatorFlags: flags, + }); + + await harness.driver.makeReady(harness.device); + + const launches = emulatorLaunches(harness.runner); + expect(launches).toHaveLength(2); + for (const args of launches) { + expect(args.slice(0, 5 + flags.length)).toEqual([ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + ...flags, + ]); + } + }); + + it("passes a gpu mode as -gpu , and passes nothing for auto", async () => { + const harness = await provisionedHarness({ + emulator: { ...defaults, gpu: "swiftshader_indirect" }, + emulatorFlags: ["-gpu", "swiftshader_indirect"], + }); + await harness.driver.makeReady(harness.device); + expect(emulatorLaunches(harness.runner)[0]).toEqual([ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + "-gpu", + "swiftshader_indirect", + "-no-snapshot-load", + ]); + + const auto = await provisionedHarness({ emulator: { ...defaults, gpu: "auto" } }); + await auto.driver.makeReady(auto.device); + for (const args of emulatorLaunches(auto.runner)) { + expect(args).not.toContain("-gpu"); + } + }); + + /** + * A settings change reaches the driver through a daemon restart, so each case captures a + * baseline under the default launch and then boots the device from a driver constructed with + * the changed setting, reading the persisted baseline metadata the way a restarted daemon does. + */ + const bootAfterRestartWith = async ( + emulator: AndroidEmulatorLaunchOptions, + expectations: (flags: readonly string[]) => ScriptedProcessExpectation[], + flags: readonly string[], + ) => { + const harness = await provisionedHarness(); + await harness.driver.makeReady(harness.device); + await harness.filesystem.mkdirp( + `${avdDirectory}/simlock_one.avd/snapshots/simlock_clean_baseline`, + ); + const runner = new ScriptedProcessRunner([ + processResult(binaries.emulator, ["-version"], "Android emulator version 36.1.9"), + ...expectations(flags), + markWriteExpectation("emulator-5586", "device-0"), + ]); + const driver = await createDriver(harness.filesystem, runner, { emulator }); + await driver.makeReady(harness.device); + return { filesystem: harness.filesystem, runner }; + }; + const rebuild = (flags: readonly string[]) => + baselineBuildExpectations({ + emulatorFlags: flags, + launchArgs: ["-wipe-data", "-no-snapshot-load"], + }); + const restore = (flags: readonly string[]): ScriptedProcessExpectation[] => [ + { + hangs: true, + match: { + args: [ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + ...flags, + "-snapshot", + "simlock_clean_baseline", + ], + command: binaries.emulator, + }, + }, + processResult( + binaries.adb, + ["-s", "emulator-5586", "shell", "getprop", "sys.boot_completed"], + "1\n", + ), + processResult( + binaries.adb, + ["-s", "emulator-5586", "shell", "getprop", "init.svc.bootanim"], + "", + ), + ]; + + it.each([ + ["headless", { ...defaults, headless: true }, ["-no-window"]], + ["gpu", { ...defaults, gpu: "swiftshader_indirect" }, ["-gpu", "swiftshader_indirect"]], + ] as const)( + "rebuilds the clean baseline on the next boot after %s changes", + async (_key, emulator, flags) => { + const { filesystem, runner } = await bootAfterRestartWith(emulator, rebuild, flags); + + expect(emulatorLaunches(runner)[0]).toEqual([ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + ...flags, + "-wipe-data", + "-no-snapshot-load", + ]); + await expect( + filesystem.exists(`${avdDirectory}/simlock_one.avd/snapshots/simlock_clean_baseline`), + ).resolves.toBe(false); + expect(runner.calls.filter((call) => call.args.includes("save"))).toHaveLength(1); + }, + ); + + it.each([ + ["audio", { ...defaults, audio: false }, ["-no-audio"]], + ["bootAnimation", { ...defaults, bootAnimation: false }, ["-no-boot-anim"]], + ] as const)( + "keeps the clean baseline and boots from it after %s changes", + async (_key, emulator, flags) => { + const { filesystem, runner } = await bootAfterRestartWith(emulator, restore, flags); + + expect(emulatorLaunches(runner)).toEqual([ + [ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + ...flags, + "-snapshot", + "simlock_clean_baseline", + ], + ]); + await expect( + filesystem.exists(`${avdDirectory}/simlock_one.avd/snapshots/simlock_clean_baseline`), + ).resolves.toBe(true); + }, + ); + }); + it("shuts down and deletes only the provisioned simlock AVD", async () => { const filesystem = await androidFilesystem({ config: "hw.ramSize=2048\n" }); const runner = new ScriptedProcessRunner([ @@ -2237,6 +2428,9 @@ live( async function provisionedHarness( options: { readonly bootCompleted?: string; + readonly emulator?: AndroidEmulatorLaunchOptions; + /** The flags `emulator` is expected to add to every launch. */ + readonly emulatorFlags?: readonly string[]; readonly forBaselineReclaim?: boolean; readonly forFullCleanBoot?: boolean; readonly forReclaim?: boolean; @@ -2285,6 +2479,7 @@ async function provisionedHarness( expectations.push( ...baselineBuildExpectations({ bootCompleted: options.bootCompleted, + emulatorFlags: options.emulatorFlags, initialAdbFailures: options.initialAdbFailures, launchArgs: ["-no-snapshot-load"], }), @@ -2319,6 +2514,7 @@ async function provisionedHarness( const runner = new ScriptedProcessRunner(expectations); const driver = await createDriver(filesystem, runner, { clock, + ...(options.emulator === undefined ? {} : { emulator: options.emulator }), ids: ["one"], ...(options.readinessTimeoutMs === undefined ? {} @@ -2350,6 +2546,7 @@ async function createDriver( readonly driverConfig?: Readonly>; readonly diskSpaceGuard?: DiskSpaceGuard; readonly downloadTimeoutMs?: number; + readonly emulator?: AndroidEmulatorLaunchOptions; readonly ids?: readonly string[]; readonly onDiagnostic?: (diagnostic: AndroidDriverDiagnostic) => void; readonly readinessTimeoutMs?: number; @@ -2390,6 +2587,7 @@ function onlyProvided(options: { readonly acceptAndroidLicenses?: boolean; readonly diskSpaceGuard?: DiskSpaceGuard; readonly downloadTimeoutMs?: number; + readonly emulator?: AndroidEmulatorLaunchOptions; readonly onDiagnostic?: (diagnostic: AndroidDriverDiagnostic) => void; readonly readinessTimeoutMs?: number; }) { @@ -2401,6 +2599,7 @@ function onlyProvided(options: { ...(options.downloadTimeoutMs === undefined ? {} : { downloadTimeoutMs: options.downloadTimeoutMs }), + ...(options.emulator === undefined ? {} : { emulator: options.emulator }), ...(options.onDiagnostic === undefined ? {} : { onDiagnostic: options.onDiagnostic }), ...(options.readinessTimeoutMs === undefined ? {} @@ -2491,15 +2690,26 @@ async function androidFilesystem( function baselineBuildExpectations(options: { readonly bootCompleted?: string | undefined; + /** What `android.emulator` adds to every launch, right after `-no-snapshot-save`. */ + readonly emulatorFlags?: readonly string[] | undefined; readonly initialAdbFailures?: number | undefined; readonly launchArgs: readonly string[]; }): ScriptedProcessExpectation[] { const bootCompleted = options.bootCompleted ?? "1\n"; + const emulatorFlags = options.emulatorFlags ?? []; const expectations: ScriptedProcessExpectation[] = [ { hangs: bootCompleted.trim() !== "1", match: { - args: ["-avd", "simlock_one", "-port", "5586", "-no-snapshot-save", ...options.launchArgs], + args: [ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + ...emulatorFlags, + ...options.launchArgs, + ], command: binaries.emulator, }, }, @@ -2555,6 +2765,7 @@ function baselineBuildExpectations(options: { "-port", "5586", "-no-snapshot-save", + ...emulatorFlags, "-snapshot", "simlock_clean_baseline", ], diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index dd0d8fa7..9072680a 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -117,6 +117,31 @@ const CLEAN_BASELINE = "simlock_clean_baseline"; const DURABLE_MARK_KEY = "simlock.mark"; const ERASABLE_MARK_PATH = "/data/local/tmp/simlock-mark.json"; +/** + * How this machine's emulators are launched (`android.emulator` in config). Operator-only: it + * reaches the driver at construction and never from a lease request. Only these four settings + * exist, each mapped to one fixed emulator flag by `emulatorLaunchFlags`, so config cannot add + * `-port`, a different AVD home, or any other argument that would break containment. + */ +export interface AndroidEmulatorLaunchOptions { + /** `true` adds `-no-window`. */ + readonly headless: boolean; + /** Passed as `-gpu `; `"auto"` passes nothing. */ + readonly gpu: string; + /** `false` adds `-no-audio`. */ + readonly audio: boolean; + /** `false` adds `-no-boot-anim`. */ + readonly bootAnimation: boolean; +} + +/** Equal to `android.emulator`'s config defaults: the launch this driver always made. */ +const DEFAULT_EMULATOR_LAUNCH: AndroidEmulatorLaunchOptions = { + audio: true, + bootAnimation: true, + gpu: "auto", + headless: false, +}; + export interface AndroidDriverOptions { /** * Explicit legal consent for Android SDK licenses (`downloads.acceptAndroidLicenses`), @@ -160,6 +185,12 @@ export interface AndroidDriverOptions { readonly tcpProbe: TcpProbe; /** `process.getuid?.()`; `undefined` skips the root's ownership check. */ readonly uid?: number; + /** + * Launch options applied at each emulator boot; a running emulator keeps the flags it + * started with. Omitted means `DEFAULT_EMULATOR_LAUNCH`, the same launch as before these + * options existed. + */ + readonly emulator?: AndroidEmulatorLaunchOptions | undefined; } export type AndroidDriverDiagnostic = @@ -406,6 +437,10 @@ export class AndroidDriver implements Driver { readonly #legacyAvdHome: string; readonly #diskSpaceGuard: DiskSpaceGuard; readonly #downloadTimeoutMs: number; + /** `android.emulator` as emulator flags, fixed for this driver's lifetime. */ + readonly #emulatorFlags: readonly string[]; + /** The part of `android.emulator` a clean baseline depends on; see `baselineLaunchInputs`. */ + readonly #baselineLaunchInputs: readonly string[]; readonly #installLocks = new Map>(); readonly #locks = new Map>(); readonly #onDiagnostic: ((diagnostic: AndroidDriverDiagnostic) => void) | undefined; @@ -433,6 +468,8 @@ export class AndroidDriver implements Driver { this.#deviceRoot = deviceRoot; this.#diskSpaceGuard = options.diskSpaceGuard ?? new DiskSpaceGuard(); this.#downloadTimeoutMs = options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS; + this.#emulatorFlags = emulatorLaunchFlags(options.emulator); + this.#baselineLaunchInputs = baselineLaunchInputs(options.emulator); this.#filesystem = options.filesystem; this.#hostAbi = options.hostAbi ?? hostAbiFor(process.arch); this.#idGenerator = options.idGenerator ?? new SequentialIdGenerator(); @@ -1304,19 +1341,20 @@ export class AndroidDriver implements Driver { } async #configHash(avdName: string, image: SystemImage): Promise { - const [emulatorVersion, config] = await Promise.all([ - this.#emulatorVersion(), - this.#avdConfig(avdName), - ]); - return stableHash([`${image.path}@${image.version}`, emulatorVersion, config]); + return this.#currentConfigHash(avdName, `${image.path}@${image.version}`); } + /** + * What a clean baseline snapshot depends on. A mismatch against the hash stored with the + * baseline rebuilds it on the next boot rather than loading a snapshot the emulator would + * refuse, which would degrade every later reclaim to a full wipe. + */ async #currentConfigHash(avdName: string, imageIdentity: string): Promise { const [emulatorVersion, config] = await Promise.all([ this.#emulatorVersion(), this.#avdConfig(avdName), ]); - return stableHash([imageIdentity, emulatorVersion, config]); + return stableHash([imageIdentity, emulatorVersion, config, ...this.#baselineLaunchInputs]); } async #emulatorVersion(): Promise { @@ -1495,7 +1533,15 @@ export class AndroidDriver implements Driver { // long as any emulator ran. const handle = this.#processRunner.spawn( this.#sdk.emulator, - ["-avd", data.avdName, "-port", String(data.port), "-no-snapshot-save", ...launchArgs], + [ + "-avd", + data.avdName, + "-port", + String(data.port), + "-no-snapshot-save", + ...this.#emulatorFlags, + ...launchArgs, + ], { env: this.#env(), stdio: "ignore" }, ); handle.unref(); @@ -1972,6 +2018,40 @@ function containsLineBreak(value: string): boolean { return /[\r\n]/.test(value); } +/** + * The one place an `android.emulator` setting becomes an emulator flag. Every value maps to a + * fixed flag; `gpu` is the only one carrying a value, and it is always the argument of `-gpu`. + */ +function emulatorLaunchFlags( + launch: AndroidEmulatorLaunchOptions = DEFAULT_EMULATOR_LAUNCH, +): string[] { + return [ + ...(launch.headless ? ["-no-window"] : []), + ...(launch.gpu === DEFAULT_EMULATOR_LAUNCH.gpu ? [] : ["-gpu", launch.gpu]), + ...(launch.audio ? [] : ["-no-audio"]), + ...(launch.bootAnimation ? [] : ["-no-boot-anim"]), + ]; +} + +/** + * The launch settings a baseline snapshot depends on: the window and the GPU mode change the + * emulator's graphics state, so a baseline taken under one does not load cleanly under the + * other. Audio and the boot animation do not, and stay out. Nothing is added while both are at + * their defaults, so a baseline captured before these settings existed keeps its hash and is + * not rebuilt (with a data wipe) on the first boot after an upgrade. + */ +function baselineLaunchInputs( + launch: AndroidEmulatorLaunchOptions = DEFAULT_EMULATOR_LAUNCH, +): string[] { + if ( + launch.headless === DEFAULT_EMULATOR_LAUNCH.headless && + launch.gpu === DEFAULT_EMULATOR_LAUNCH.gpu + ) { + return []; + } + return [`headless=${String(launch.headless)}`, `gpu=${launch.gpu}`]; +} + function stableHash(parts: readonly string[]): string { let hash = 0x811c9dc5; for (const character of parts.join("\u0000")) { diff --git a/src/gateway/dispatcher.test.ts b/src/gateway/dispatcher.test.ts index b911e809..b8e89a50 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -43,6 +43,7 @@ const gatewayConfig = { http: { enabled: true, host: "127.0.0.1", port: 4700 }, idle: { deleteAfterMs: 1, shutdownAfterMs: 1 }, ios: { slim: { bootTimeoutMs: 1, enabled: false } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, lease: { defaultTtlMs: 900_000, maxTtlMs: 3_600_000, diff --git a/src/http/test-fakes.ts b/src/http/test-fakes.ts index 71c5327b..793e55f5 100644 --- a/src/http/test-fakes.ts +++ b/src/http/test-fakes.ts @@ -47,6 +47,7 @@ export function testConfig( http: { enabled: true, host: "127.0.0.1", port: 4700 }, idle: { deleteAfterMs: 60_000, shutdownAfterMs: 10_000 }, ios: { slim: { enabled: false, bootTimeoutMs: 600_000 } }, + android: { emulator: { headless: false, gpu: "auto", audio: true, bootAnimation: true } }, lease: { defaultTtlMs: 900_000, maxTtlMs: 14_400_000, From 99ccf374bcede9962f2ee4eaaf32cea421e7ec53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:40:57 +0000 Subject: [PATCH 2/4] fix(android): boot only on recovery, pin the baseline-hash upgrade path, tighten gpu docs (#148) `makeReady` now honours `purpose: "recover"`: a recovery boot is a cold boot from disk with no baseline-hash check, no snapshot load, no wipe, and no baseline capture, so a changed `android.emulator.headless`/`gpu` (or any other hash input) can never erase a device that is still leased. The baseline decision moves into `#reconcileBaseline` and `prepareLaunchArgs`. Tests now pin what the launch-option hash inputs promise: a baseline captured before `android.emulator` existed keeps its hash under the default launch, a change between two explicit gpu modes rebuilds the baseline, an audio change under non-default headless keeps it, and recovery boots cold. Narrowed one test title to what its body proves. Docs say the emulator, not Simlock, validates a gpu mode. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PvsMWpwkPBL4Zo2ppLTxDA --- docs/CONFIGURATION.md | 4 +- src/core/config.test.ts | 2 +- src/drivers/android/index.test.ts | 146 +++++++++++++++++++++++++++++- src/drivers/android/index.ts | 78 +++++++++++----- 4 files changed, 201 insertions(+), 29 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e4e233b6..af66704f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -77,7 +77,9 @@ non-empty strings, and `ios.slim.bootTimeoutMs` a positive number. `android.emulator.headless`, `android.emulator.audio`, and `android.emulator.bootAnimation` are booleans, and `android.emulator.gpu` a non-empty string. Any other value is rejected at load, and the error names the -key. +key. Simlock does not check that a `gpu` string names a mode the emulator +knows: a mode the emulator rejects fails that device's next boot, not the +config load. `mode` must be exactly `"worker"` or `"gateway"`. `gateway.url` must be an absolute `ws`/`wss` URL — `http`/`https` are rejected — and `gateway.token` a non-empty string; **in `mode: "worker"`**, setting either without the other diff --git a/src/core/config.test.ts b/src/core/config.test.ts index ef8ac0a6..024e4e11 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -821,7 +821,7 @@ describe("loadConfig", () => { ).rejects.toThrow(`Invalid config value for "${path}"`); }); - it("warns about and drops an unknown key under android.emulator, so no free-form launch argument reaches the driver", async () => { + it("warns about and drops an unknown key under android.emulator", async () => { const filesystem = new MemoryFilesystem(); const warn = vi.fn(); await filesystem.mkdirp("/home/agent/.simlock"); diff --git a/src/drivers/android/index.test.ts b/src/drivers/android/index.test.ts index f15e06d6..4ed387d0 100644 --- a/src/drivers/android/index.test.ts +++ b/src/drivers/android/index.test.ts @@ -600,19 +600,27 @@ describe("AndroidDriver", () => { emulator: AndroidEmulatorLaunchOptions, expectations: (flags: readonly string[]) => ScriptedProcessExpectation[], flags: readonly string[], + capturedUnder: { + readonly emulator?: AndroidEmulatorLaunchOptions; + readonly emulatorFlags?: readonly string[]; + } = {}, + purpose?: "prepare" | "recover", ) => { - const harness = await provisionedHarness(); + const harness = await provisionedHarness(capturedUnder); await harness.driver.makeReady(harness.device); await harness.filesystem.mkdirp( `${avdDirectory}/simlock_one.avd/snapshots/simlock_clean_baseline`, ); + // A recovery boot never reads the baseline hash, so it never asks the emulator's version. const runner = new ScriptedProcessRunner([ - processResult(binaries.emulator, ["-version"], "Android emulator version 36.1.9"), + ...(purpose === "recover" + ? [] + : [processResult(binaries.emulator, ["-version"], "Android emulator version 36.1.9")]), ...expectations(flags), markWriteExpectation("emulator-5586", "device-0"), ]); const driver = await createDriver(harness.filesystem, runner, { emulator }); - await driver.makeReady(harness.device); + await driver.makeReady(harness.device, purpose === undefined ? undefined : { purpose }); return { filesystem: harness.filesystem, runner }; }; const rebuild = (flags: readonly string[]) => @@ -699,6 +707,138 @@ describe("AndroidDriver", () => { ).resolves.toBe(true); }, ); + + it("rebuilds the clean baseline on the next boot after gpu changes from one explicit mode to another", async () => { + const flags = ["-gpu", "swiftshader_indirect"]; + const { filesystem, runner } = await bootAfterRestartWith( + { ...defaults, gpu: "swiftshader_indirect" }, + rebuild, + flags, + { emulator: { ...defaults, gpu: "host" }, emulatorFlags: ["-gpu", "host"] }, + ); + + expect(emulatorLaunches(runner)[0]).toEqual([ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + ...flags, + "-wipe-data", + "-no-snapshot-load", + ]); + await expect( + filesystem.exists(`${avdDirectory}/simlock_one.avd/snapshots/simlock_clean_baseline`), + ).resolves.toBe(false); + }); + + it("keeps the clean baseline after audio changes while headless stays on", async () => { + const flags = ["-no-window", "-no-audio"]; + const { filesystem, runner } = await bootAfterRestartWith( + { ...defaults, audio: false, headless: true }, + restore, + flags, + { emulator: { ...defaults, headless: true }, emulatorFlags: ["-no-window"] }, + ); + + expect(emulatorLaunches(runner)).toEqual([ + [ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + ...flags, + "-snapshot", + "simlock_clean_baseline", + ], + ]); + await expect( + filesystem.exists(`${avdDirectory}/simlock_one.avd/snapshots/simlock_clean_baseline`), + ).resolves.toBe(true); + }); + + it("keeps a clean baseline captured before android.emulator existed, so an upgrade does not wipe the device", async () => { + // The hash the driver wrote for this exact fixture (image, emulator version, config.ini) + // before `android.emulator` existed. Under the default launch the hash must still be + // this value; otherwise every device's first boot after the upgrade is a data wipe. + const hashBeforeLaunchOptionsExisted = "4e9b7a98"; + const harness = await provisionedHarness(); + await harness.driver.makeReady(harness.device); + const metadataPath = `${avdDirectory}/simlock_one.avd/simlock-clean-baseline.json`; + const written = JSON.parse(await harness.filesystem.readFile(metadataPath)) as { + readonly configHash: string; + }; + expect(written.configHash).toBe(hashBeforeLaunchOptionsExisted); + + await harness.filesystem.mkdirp( + `${avdDirectory}/simlock_one.avd/snapshots/simlock_clean_baseline`, + ); + const runner = new ScriptedProcessRunner([ + processResult(binaries.emulator, ["-version"], "Android emulator version 36.1.9"), + ...restore([]), + markWriteExpectation("emulator-5586", "device-0"), + ]); + const driver = await createDriver(harness.filesystem, runner, { emulator: defaults }); + await driver.makeReady(harness.device); + + expect(emulatorLaunches(runner)[0]).toContain("simlock_clean_baseline"); + expect(emulatorLaunches(runner)[0]).not.toContain("-wipe-data"); + }); + + it("recovering a leased device after a restart boots it cold, never wiping it or loading a snapshot, even when headless changed", async () => { + const flags = ["-no-window"]; + const coldBoot = (launchFlags: readonly string[]): ScriptedProcessExpectation[] => [ + { + hangs: true, + match: { + args: [ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + ...launchFlags, + "-no-snapshot-load", + ], + command: binaries.emulator, + }, + }, + processResult( + binaries.adb, + ["-s", "emulator-5586", "shell", "getprop", "sys.boot_completed"], + "1\n", + ), + processResult( + binaries.adb, + ["-s", "emulator-5586", "shell", "getprop", "init.svc.bootanim"], + "", + ), + ]; + const { filesystem, runner } = await bootAfterRestartWith( + { ...defaults, headless: true }, + coldBoot, + flags, + {}, + "recover", + ); + + expect(emulatorLaunches(runner)).toEqual([ + [ + "-avd", + "simlock_one", + "-port", + "5586", + "-no-snapshot-save", + ...flags, + "-no-snapshot-load", + ], + ]); + await expect( + filesystem.exists(`${avdDirectory}/simlock_one.avd/snapshots/simlock_clean_baseline`), + ).resolves.toBe(true); + expect(runner.calls.filter((call) => call.args.includes("save"))).toHaveLength(0); + }); }); it("shuts down and deletes only the provisioned simlock AVD", async () => { diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index 9072680a..dfb1cf08 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -819,8 +819,22 @@ export class AndroidDriver implements Driver { return { address: serialFor(port), deviceId: avdName, driverData }; } - /** Returns the device with its address re-read: see `Driver.makeReady` for why it is read here. */ - async makeReady(device: DriverDevice): Promise { + /** + * Returns the device with its address re-read: see `Driver.makeReady` for why it is read here. + * + * `options.purpose === "recover"` boots and nothing else. The clean-baseline check below can + * decide to wipe the device (a baseline hash that no longer matches: an emulator upgrade, a + * config.ini change, or a changed `android.emulator.headless`/`gpu`) or to capture a fresh + * baseline from whatever is on the device -- and either would run against a device that is + * still leased, whose data is the agent's. So a recovery boot skips the baseline logic + * entirely: a cold boot from disk, never a snapshot load and never a wipe. Whatever the + * baseline check would have decided is decided instead by the next `reclaim`, once the + * lease is over. + */ + async makeReady( + device: DriverDevice, + options?: { readonly purpose: "prepare" | "recover" }, + ): Promise { const data = this.#dataFor(device); return this.#withDeviceLock(data.avdName, async () => { const state = this.#stateFor(data); @@ -834,30 +848,14 @@ export class AndroidDriver implements Driver { return { address: data.serial, deviceId: device.deviceId, driverData: data }; } - const baselineHash = await this.#baselineHash(data.avdName); - if (!state.needsWipe && baselineHash !== undefined) { - const currentHash = await this.#currentConfigHash(data.avdName, state.imageIdentity); - if (baselineHash === currentHash) { - state.baselineCaptured = true; - state.snapshotExpected = true; - } else { - await this.#filesystem.rm(`${this.#deviceRoot}/${data.avdName}.avd/snapshots`); - state.baselineCaptured = false; - state.needsWipe = true; - state.snapshotExpected = false; - } + if (options?.purpose === "recover") { + await this.#startEmulator(data, state, ["-no-snapshot-load"], false); + await this.#writeMark(data); + return { address: data.serial, deviceId: device.deviceId, driverData: data }; } - await this.#startEmulator( - data, - state, - state.needsWipe - ? ["-wipe-data", "-no-snapshot-load"] - : state.snapshotExpected - ? ["-snapshot", CLEAN_BASELINE] - : ["-no-snapshot-load"], - state.snapshotExpected, - ); + await this.#reconcileBaseline(data, state); + await this.#startEmulator(data, state, prepareLaunchArgs(state), state.snapshotExpected); state.needsWipe = false; state.snapshotExpected = false; if (!state.baselineCaptured) { @@ -1567,6 +1565,30 @@ export class AndroidDriver implements Driver { } } + /** + * Decides, from the persisted baseline metadata, whether the coming boot can load the clean + * baseline or must wipe and rebuild it: a stored hash that still matches means load it; one + * that no longer matches (emulator upgrade, config.ini change, a changed + * `android.emulator.headless`/`gpu`) means the snapshot directory goes and the boot wipes. + * Never called on a recovery boot -- see `makeReady`. + */ + async #reconcileBaseline(data: AndroidDriverData, state: DeviceState): Promise { + const baselineHash = await this.#baselineHash(data.avdName); + if (state.needsWipe || baselineHash === undefined) { + return; + } + const currentHash = await this.#currentConfigHash(data.avdName, state.imageIdentity); + if (baselineHash === currentHash) { + state.baselineCaptured = true; + state.snapshotExpected = true; + return; + } + await this.#filesystem.rm(`${this.#deviceRoot}/${data.avdName}.avd/snapshots`); + state.baselineCaptured = false; + state.needsWipe = true; + state.snapshotExpected = false; + } + async #captureBaseline(data: AndroidDriverData, state: DeviceState): Promise { await this.#runOrThrow(this.#sdk.adb, [ "-s", @@ -2052,6 +2074,14 @@ function baselineLaunchInputs( return [`headless=${String(launch.headless)}`, `gpu=${launch.gpu}`]; } +/** The boot a `prepare`-purpose `makeReady` runs, from what `#reconcileBaseline` decided. */ +function prepareLaunchArgs(state: DeviceState): readonly string[] { + if (state.needsWipe) { + return ["-wipe-data", "-no-snapshot-load"]; + } + return state.snapshotExpected ? ["-snapshot", CLEAN_BASELINE] : ["-no-snapshot-load"]; +} + function stableHash(parts: readonly string[]): string { let hash = 0x811c9dc5; for (const character of parts.join("\u0000")) { From d2a9f929633dfc17a82257456b54f561218868cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:01:09 +0000 Subject: [PATCH 3/4] test(android): assert the recovery boot re-marks the device; align gpu schema (#148) The recover-purpose test now asserts the mark write, so dropping it goes red. The contract schema's `gpu` requires a non-empty string, matching the loader. The core config comment no longer claims the block is handed over unread. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PvsMWpwkPBL4Zo2ppLTxDA --- src/contract/schemas.ts | 2 +- src/core/config.ts | 7 ++++--- src/drivers/android/index.test.ts | 5 +++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 7c1d8cc7..9b3a2886 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -471,7 +471,7 @@ export const configSchema = z.object({ android: z.object({ emulator: z.object({ headless: z.boolean(), - gpu: z.string(), + gpu: z.string().min(1), audio: z.boolean(), bootAnimation: z.boolean(), }), diff --git a/src/core/config.ts b/src/core/config.ts index 8482e70f..c1d0d71c 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -188,9 +188,10 @@ export interface Config { }; /** * How this machine's Android emulators are launched. Operator configuration of the Android - * driver, the same way `ios.slim` configures the iOS one: the core hands the block to the - * driver unread, and no lease request can set it. Which emulator flag a key becomes is the - * driver's business. Applies at a device's next boot. + * driver, the same way `ios.slim` configures the iOS one: the core validates each value's + * type and hands the block to the driver without interpreting it, and no lease request can + * set it. Which emulator flag a key becomes is the driver's business. Applies at a device's + * next boot. */ readonly android: { readonly emulator: { diff --git a/src/drivers/android/index.test.ts b/src/drivers/android/index.test.ts index 4ed387d0..595e6b98 100644 --- a/src/drivers/android/index.test.ts +++ b/src/drivers/android/index.test.ts @@ -838,6 +838,11 @@ describe("AndroidDriver", () => { filesystem.exists(`${avdDirectory}/simlock_one.avd/snapshots/simlock_clean_baseline`), ).resolves.toBe(true); expect(runner.calls.filter((call) => call.args.includes("save"))).toHaveLength(0); + // A recovered device is re-marked like any other ready device: the mark is what proves + // the device is still Simlock's on the next reconcile. + expect( + runner.calls.filter((call) => call.args.some((arg) => arg.includes("simlock-mark.json"))), + ).toHaveLength(1); }); }); From 8faafcdc0affbea4ef842db9365d34bd4c5be7eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:08:33 +0000 Subject: [PATCH 4/4] test(e2e): wait for the fresh daemon.log after rotation instead of a one-shot check (#148) Rotation renames daemon.log away and then opens a new one, and with a 200-byte cap nearly every startup line rotates, so a check from another process can land between the two. CI hit that gap once the startup config line grew by the android.emulator block. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PvsMWpwkPBL4Zo2ppLTxDA --- e2e/daemon-lifecycle.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/e2e/daemon-lifecycle.test.ts b/e2e/daemon-lifecycle.test.ts index 15def536..9c28cfcb 100644 --- a/e2e/daemon-lifecycle.test.ts +++ b/e2e/daemon-lifecycle.test.ts @@ -109,7 +109,12 @@ describe("daemon lifecycle & recovery", () => { timeout: 15_000, label: "daemon.log.1 created after low rotateBytes", }); - expect(existsSync(env.logPath)).toBe(true); + // Rotation renames daemon.log away and then opens a fresh one; with a 200-byte cap + // nearly every startup line rotates, so a one-shot check can land in that gap. + await waitFor(() => existsSync(env.logPath), { + timeout: 15_000, + label: "a fresh daemon.log opened after rotation", + }); }); });