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
16 changes: 11 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ MCP client ──spawns──> stdio MCP ┘
## Core vs. drivers

The core is platform-agnostic and written once: lease table, fair wait queue,
managed-device registry, device-limit and RAM capacity accounting (RAM is the
binding constraint for Android emulators), the device state machine, the
managed-device registry, capacity accounting behind a pluggable strategy
(the default derives limits from the machine and treats RAM as the binding
constraint for Android emulators), the device state machine, the
cleanup reaper, the leased-device health monitor, the event bus, and
warm-pool *policy*.

Expand All @@ -85,7 +86,12 @@ devices) must require **no core changes**. If it does, the interface leaked.
## Running capacity

Managed-device limits govern provisioning, while running limits govern any
operation that starts a device. The core accounts `ready`, `leased`,
operation that starts a device. Where those limits come from is a
`CapacityStrategy`, selected by config: `resource` derives them from the
machine and adds a RAM budget, `fixed` pins them to a configured number.
Each strategy lives behind one entry point in `core/capacity/strategies/`
and is registered in one map, so adding a policy touches neither the
coordinator nor its callers. The core accounts `ready`, `leased`,
`reclaiming`, and `quarantined` devices as running. A serialized,
platform-agnostic reservation covers provisioning and boots from `shutdown`
until the registry commits the resulting running or non-running state. Global
Expand Down Expand Up @@ -301,8 +307,8 @@ capacity coordinator into these direct transactional call chains:
A release passes its committed result directly to `WarmPoolCoordinator`,
which performs reclaim and warm-pool disposition — without the releasing
caller waiting on it (see "Release hands the purge off").
- `CapacityCoordinator` owns provisioning and running reservations while pure
capacity functions calculate limits. `DeviceOperationClaims` excludes
- `CapacityCoordinator` owns provisioning and running reservations while the
configured `CapacityStrategy` decides the limits. `DeviceOperationClaims` excludes
overlapping boot, eviction, cleanup, and nuke operations per device.
- `CleanupReaper` evaluates pure rules and directly calls
`CleanupActionExecutor`; the executor revalidates registry ownership,
Expand Down
9 changes: 6 additions & 3 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,9 +329,12 @@ shows the current file with the immediately preceding one prepended.
Show the effective configuration (defaults + config file + overrides):
managed and running capacity limits, idle tiers T1/T2/T3, TTLs, disk-pressure
threshold, and the daemon's log level/rotation cap (`log.level`,
`log.rotateBytes`). With no args, prints everything. Running capacity
uses `limits.maxRunning` globally and `limits.<platform>.maxRunning` for each
driver; both must have room before provisioning or booting a shutdown device.
`log.rotateBytes`). With no args, prints everything. The capacity numbers
come from the selected capacity strategy (`capacity.strategy`, configured
under `capacity.config` — see
[CONFIGURATION.md](CONFIGURATION.md#capacity-strategies)). Whichever strategy
is running, both a global and a per-platform running limit must have room
before Simlock provisions or boots a shutdown device.

## Environment variables

Expand Down
79 changes: 66 additions & 13 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,7 @@ a warning. Inspect the effective, merged configuration at any time with

| Property | Description | Default |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `limits.maxRunning` | Global cap on devices running at once, across both platforms. | Sum of `limits.ios.maxDevices` and `limits.android.maxDevices` |
| `limits.ios.maxDevices` | Max number of iOS simulators Simlock will manage at once. | `max(1, cpuCount / 2)` |
| `limits.ios.maxRunning` | Max number of iOS simulators running at once. | Same as `limits.ios.maxDevices` |
| `limits.android.maxDevices` | Max number of Android emulators Simlock will manage at once. | `max(1, min(cpuCount / 4, totalRamGb / 8))` |
| `limits.android.maxRunning` | Max number of Android emulators running at once. | Same as `limits.android.maxDevices` |
| `ramBudget.iosBytesPerDevice` | RAM reserved per iOS simulator when computing capacity. | `1.5 GiB` |
| `ramBudget.androidBytesPerDevice` | RAM reserved per Android emulator when computing capacity. | `4 GiB` |
| `capacity.strategy` | Which policy decides how many devices may exist and run at once: `resource` or `fixed`. The options under `capacity.config` are that strategy's own -- see [Capacity strategies](#capacity-strategies). | `resource` |
| `idle.shutdownAfterMs` | How long an unused device sits idle before Simlock shuts it down (tier 1, reclaims RAM). | `10 minutes` |
| `idle.deleteAfterMs` | How long a shut-down device sits idle before Simlock deletes it (tier 2, reclaims disk). | `1 hour` |
| `warmPool.quarantine.maxRetries` | Failed purge retries allowed on a quarantined device (after the triggering failure) before Simlock gives up and destroys it. | `3` |
Expand Down Expand Up @@ -42,19 +36,78 @@ must be non-negative numbers (milliseconds and bytes, respectively).
`health.maxConcurrentRecoveries` must be positive integers.
`stalledTransition.thresholdMultiplier` must be a number `>= 1`;
`stalledTransition.minimumThresholdMs` must be a non-negative number.
See [CLI.md](CLI.md#simlock-config-get-keyset-key-value) for the
`simlock config` command itself.

## Capacity strategies

How many devices Simlock lets exist and run at once is decided by a capacity
strategy. `capacity.strategy` picks one; `capacity.config` holds that
strategy's own options, so its shape depends on the strategy you selected.

### `resource` (default)

Device and running ceilings derived from the machine, with a RAM budget on
top: a device is only created if its budgeted RAM still fits under the
machine's total, minus 4 GiB left for the OS.

| Property | Description | Default |
| ----------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `capacity.config.limits.maxRunning` | Global cap on devices running at once, across both platforms. | Sum of the two `maxDevices` values |
| `capacity.config.limits.ios.maxDevices` | Max iOS simulators Simlock will manage at once. | `max(1, cpuCount / 2)` |
| `capacity.config.limits.ios.maxRunning` | Max iOS simulators running at once. | Same as `capacity.config.limits.ios.maxDevices` |
| `capacity.config.limits.android.maxDevices` | Max Android emulators Simlock will manage at once. | `max(1, min(cpuCount / 4, totalRamGb / 8))` |
| `capacity.config.limits.android.maxRunning` | Max Android emulators running at once. | Same as `capacity.config.limits.android.maxDevices` |
| `capacity.config.ramBudget.iosBytesPerDevice` | RAM reserved per iOS simulator when computing capacity. | `1.5 GiB` |
| `capacity.config.ramBudget.androidBytesPerDevice` | RAM reserved per Android emulator when computing capacity. | `4 GiB` |

Running limits are independent of managed-device limits — an omitted
`maxRunning` defaults to its corresponding `maxDevices` value (and, at the
global level, to their sum):

```json
{
"limits": {
"maxRunning": 3,
"ios": { "maxDevices": 4, "maxRunning": 2 },
"android": { "maxDevices": 2, "maxRunning": 2 }
"capacity": {
"strategy": "resource",
"config": {
"limits": {
"maxRunning": 3,
"ios": { "maxDevices": 4, "maxRunning": 2 },
"android": { "maxDevices": 2, "maxRunning": 2 }
}
}
}
}
```

See [CLI.md](CLI.md#simlock-config-get-keyset-key-value) for the
`simlock config` command itself.
### `fixed`

A pinned number of devices, with no machine inspection at all: no RAM
budget, and no CPU- or RAM-derived defaults. Use it when you want the
concurrency to be exactly the number you wrote down, on every machine.

| Property | Description | Default |
| ------------------------------------- | ---------------------------------------------------------- | ------------------------------------------ |
| `capacity.config.maxRunning` | Devices running at once, across both platforms. | `2` |
| `capacity.config.ios.maxRunning` | iOS simulators running at once. | `capacity.config.maxRunning` |
| `capacity.config.ios.maxDevices` | iOS simulators Simlock will manage at once. | `capacity.config.ios.maxRunning` |
| `capacity.config.android.maxRunning` | Android emulators running at once. | `capacity.config.maxRunning` |
| `capacity.config.android.maxDevices` | Android emulators Simlock will manage at once. | `capacity.config.android.maxRunning` |

`maxRunning` on its own is a complete configuration — the per-platform
blocks exist only to carve that budget up:

```json
{
"capacity": { "strategy": "fixed", "config": { "maxRunning": 4 } }
}
```

### Older config files

Before capacity strategies existed, the `resource` options were spelled as
top-level `limits` and `ramBudget` keys. Those still work exactly as they
did — a config file written against an older Simlock keeps its behaviour
without changes, and needs none. Setting them alongside an explicitly
selected non-`resource` strategy is the one case Simlock warns about, since
those settings would have no effect.
10 changes: 7 additions & 3 deletions e2e/helpers/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ function errorMessage(error: unknown): string {
* Capacity inputs the fake-driver lane pins so a flow's device budget comes from the
* flow, never from the machine running it. `defaultConfig` derives the per-platform
* device limits from `availableParallelism()` (a 2-core runner yields exactly one iOS
* device), and `capacity.ts` independently gates on `totalmem()` minus a 4 GiB OS
* reserve at 1.5 GiB per iOS device (a 7 GiB runner therefore admits two, whatever
* `limits` says). A flow needing more concurrent devices than that passes on a dev
* device), and the `resource` capacity strategy independently gates on `totalmem()`
* minus a 4 GiB OS reserve at 1.5 GiB per iOS device (a 7 GiB runner therefore admits
* two, whatever `limits` says). A flow needing more concurrent devices than that passes on a dev
* machine and wedges on a small CI runner -- the extra lease simply queues until the
* test's own timeout, which reads as a hang rather than as "capacity refused".
*
Expand All @@ -79,6 +79,10 @@ function errorMessage(error: unknown): string {
* wired. Flows that exercise capacity itself override `limits` on top of this. The real
* -SDK lane deliberately does not get this treatment: there the host's RAM is a real
* constraint and the production defaults are what should apply.
*
* These are deliberately written in the pre-`capacity.strategy` spelling: the whole e2e
* lane then doubles as end-to-end coverage that a config file written against an older
* Simlock still configures the `resource` strategy correctly.
*/
const FAKE_LANE_BASE_CONFIG: Record<string, unknown> = {
limits: {
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "simlock",
"version": "0.1.0",
"version": "0.2.0",
"description": "Control plane for iOS simulators and Android emulators.",
"keywords": [
"agents",
Expand All @@ -20,9 +20,15 @@
"bin": {
"simlock": "dist/cli/main.js"
},
"files": [
"dist",
"!dist/e2e",
"!dist/**/*.test.js",
"!dist/**/*.test.d.ts"
],
"type": "module",
"scripts": {
"build": "tsc",
"build": "tsc -p tsconfig.build.json",
"build:e2e": "tsc -p e2e/fake-driver/tsconfig.json",
"test": "vitest run --project unit",
"test:e2e": "pnpm run build && pnpm run build:e2e && vitest run --project e2e --tags-filter='!slow'",
Expand Down
15 changes: 10 additions & 5 deletions src/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1048,13 +1048,18 @@ function testConfig(): Config {
stalledTransition: { thresholdMultiplier: 3, minimumThresholdMs: 60_000 },
idle: { deleteAfterMs: 60_000, shutdownAfterMs: 10_000 },
lease: { detachedTtlMs: 60_000, heldTtlBackstopMs: 60_000, heartbeatIntervalMs: 15_000 },
limits: {
android: { maxDevices: 1, maxRunning: 1 },
ios: { maxDevices: 1, maxRunning: 1 },
maxRunning: 1 + 1,
capacity: {
strategy: "resource",
config: {
limits: {
android: { maxDevices: 1, maxRunning: 1 },
ios: { maxDevices: 1, maxRunning: 1 },
maxRunning: 1 + 1,
},
ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte },
},
},
log: { level: "info", rotateBytes: 5 * 1024 * 1024 },
ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte },
warmPool: {
quarantine: {
maxRetries: 3,
Expand Down
1 change: 1 addition & 0 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#!/usr/bin/env node
import { runCli } from "./index.js";

process.exitCode = await runCli(process.argv.slice(2));
31 changes: 19 additions & 12 deletions src/core/acquisition-planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";

import { FakeSystemStats } from "../ports/index.js";
import { AcquisitionPlanner } from "./acquisition-planner.js";
import { CapacityCoordinator } from "./capacity-coordinator.js";
import { CapacityCoordinator, createCapacityStrategy } from "./capacity/index.js";
import type { Config } from "./config.js";
import { DeviceOperationClaims } from "./device-operation-claims.js";
import type { DeviceRecord, DeviceSpec, LeaseRecord } from "./domain.js";
Expand Down Expand Up @@ -31,12 +31,17 @@ const config: Config = {
},
},
lease: { detachedTtlMs: 100, heldTtlBackstopMs: 100, heartbeatIntervalMs: 25 },
limits: {
android: { maxDevices: 2, maxRunning: 2 },
ios: { maxDevices: 1, maxRunning: 1 },
maxRunning: 1,
capacity: {
strategy: "resource",
config: {
limits: {
android: { maxDevices: 2, maxRunning: 2 },
ios: { maxDevices: 1, maxRunning: 1 },
maxRunning: 1,
},
ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte },
},
},
ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte },
log: { level: "info", rotateBytes: 5 * 1024 * 1024 },
};

Expand All @@ -58,12 +63,14 @@ function device(
function planner() {
const claims = new DeviceOperationClaims();
const capacity = new CapacityCoordinator(
config,
new FakeSystemStats({
cpuCount: 8,
freeRamBytes: 32 * gibibyte,
totalRamBytes: 32 * gibibyte,
}),
createCapacityStrategy(
config.capacity,
new FakeSystemStats({
cpuCount: 8,
freeRamBytes: 32 * gibibyte,
totalRamBytes: 32 * gibibyte,
}),
),
);
return { claims, planner: new AcquisitionPlanner(capacity, claims) };
}
Expand Down
2 changes: 1 addition & 1 deletion src/core/acquisition-planner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CapacityReservation, CapacityCoordinator } from "./capacity-coordinator.js";
import type { CapacityReservation, CapacityCoordinator } from "./capacity/index.js";
import type { DeviceOperationClaim, DeviceOperationClaims } from "./device-operation-claims.js";
import {
type DeviceRecord,
Expand Down
Loading