Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mode>` (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).
Expand All @@ -70,6 +74,12 @@ 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. 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
Expand Down Expand Up @@ -125,7 +135,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
Expand Down Expand Up @@ -215,7 +225,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
Expand Down Expand Up @@ -362,6 +372,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
Expand Down
18 changes: 17 additions & 1 deletion e2e/daemon-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -98,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",
});
});
});

Expand Down
1 change: 1 addition & 0 deletions src/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/contract/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,14 @@ export const configSchema = z.object({
bootTimeoutMs: z.number(),
}),
}),
android: z.object({
emulator: z.object({
headless: z.boolean(),
gpu: z.string().min(1),
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"]`). */
Expand Down
1 change: 1 addition & 0 deletions src/core/acquisition-planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions src/core/cleanup/idle-destroy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions src/core/cleanup/idle-shutdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
90 changes: 90 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", 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");
Expand Down Expand Up @@ -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({
Expand Down
33 changes: 33 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ConfigError,
integerInRange,
invalidValue,
nonEmptyString,
nonNegativeNumber,
numberAtLeast,
objectValidator,
Expand Down Expand Up @@ -185,6 +186,22 @@ 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 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: {
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`:
Expand Down Expand Up @@ -601,6 +618,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,
Expand Down Expand Up @@ -733,6 +758,14 @@ function configValidators(strategy: CapacityStrategyName): Record<string, Valida
bootTimeoutMs: positiveNumber,
}),
}),
android: objectValidator({
emulator: objectValidator({
headless: booleanValue,
gpu: nonEmptyString,
audio: booleanValue,
bootAnimation: booleanValue,
}),
}),
stalledTransition: objectValidator({
thresholdMultiplier: numberAtLeast(1),
minimumThresholdMs: nonNegativeNumber,
Expand Down
1 change: 1 addition & 0 deletions src/core/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1962,5 +1962,6 @@ function config(stalledTransitionOverrides: Partial<Config["stalledTransition"]>
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 } },
};
}
1 change: 1 addition & 0 deletions src/core/lease-acquisition-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/core/lease-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function config(overrides: Partial<Config["lease"]> = {}): 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,
Expand Down
1 change: 1 addition & 0 deletions src/core/lease-health-monitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function config(overrides: Partial<Config["health"]> = {}): 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 } },
};
}

Expand Down
1 change: 1 addition & 0 deletions src/core/nuke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/core/reaper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
8 changes: 8 additions & 0 deletions src/core/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading