@ishlabs/cli 0.26.1 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +4 -0
  2. package/dist/commands/doctor.js +21 -11
  3. package/dist/commands/iteration.js +13 -4
  4. package/dist/commands/study-run.js +12 -12
  5. package/dist/commands/study-screenshots.js +15 -12
  6. package/dist/commands/study.js +22 -3
  7. package/dist/lib/api-client.d.ts +1 -0
  8. package/dist/lib/docs.js +139 -7
  9. package/dist/lib/local-sim/adb.d.ts +35 -2
  10. package/dist/lib/local-sim/adb.js +107 -14
  11. package/dist/lib/local-sim/android.d.ts +5 -3
  12. package/dist/lib/local-sim/android.js +29 -11
  13. package/dist/lib/local-sim/device-pool.d.ts +85 -0
  14. package/dist/lib/local-sim/device-pool.js +316 -0
  15. package/dist/lib/local-sim/device.d.ts +29 -0
  16. package/dist/lib/local-sim/device.js +19 -1
  17. package/dist/lib/local-sim/emulator.d.ts +50 -0
  18. package/dist/lib/local-sim/emulator.js +189 -0
  19. package/dist/lib/local-sim/install.js +23 -3
  20. package/dist/lib/local-sim/ios.d.ts +31 -5
  21. package/dist/lib/local-sim/ios.js +80 -21
  22. package/dist/lib/local-sim/loop.js +199 -9
  23. package/dist/lib/local-sim/native-a11y.d.ts +24 -0
  24. package/dist/lib/local-sim/native-a11y.js +76 -14
  25. package/dist/lib/local-sim/screen-signature.d.ts +77 -0
  26. package/dist/lib/local-sim/screen-signature.js +170 -0
  27. package/dist/lib/local-sim/simctl-provision.d.ts +49 -0
  28. package/dist/lib/local-sim/simctl-provision.js +89 -0
  29. package/dist/lib/local-sim/simctl.d.ts +6 -4
  30. package/dist/lib/local-sim/simctl.js +18 -5
  31. package/dist/lib/local-sim/xcuitest.d.ts +22 -1
  32. package/dist/lib/local-sim/xcuitest.js +38 -6
  33. package/dist/lib/modality.js +7 -2
  34. package/dist/lib/paths.d.ts +1 -0
  35. package/dist/lib/paths.js +3 -0
  36. package/dist/lib/skill-content.js +5 -2
  37. package/dist/lib/upload.d.ts +27 -0
  38. package/dist/lib/upload.js +108 -11
  39. package/package.json +2 -2
@@ -14,8 +14,32 @@ import { execFile, execFileSync } from "node:child_process";
14
14
  import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { promisify } from "node:util";
17
+ import { AsyncLocalStorage } from "node:async_hooks";
17
18
  import { binDir, adbBin } from "../paths.js";
18
19
  const execFileAsync = promisify(execFile);
20
+ /**
21
+ * The adb serial to target for the current async call chain. A parallel run
22
+ * drives N emulators in ONE process; every adb call must hit the right device,
23
+ * but the CLI targets devices via the `adb -s <serial>` prefix, not a per-call
24
+ * argument threaded through ~25 functions. AsyncLocalStorage carries the serial
25
+ * implicitly through the call stack so `adb()` / `screencapPng()` pick it up,
26
+ * and two concurrent `withAdbSerial(A, …)` / `withAdbSerial(B, …)` chains stay
27
+ * isolated. Single-device runs leave the store empty and fall back to
28
+ * ANDROID_SERIAL / the one online device (unchanged behavior).
29
+ */
30
+ const serialStore = new AsyncLocalStorage();
31
+ /** Run `fn` with all its adb calls pinned to `serial` (parallel pool path). */
32
+ export function withAdbSerial(serial, fn) {
33
+ return serialStore.run(serial?.trim() || undefined, fn);
34
+ }
35
+ /** The serial in effect for this call chain: store → ANDROID_SERIAL → none. */
36
+ function activeSerial() {
37
+ return serialStore.getStore() ?? (process.env.ANDROID_SERIAL?.trim() || undefined);
38
+ }
39
+ /** `["-s", serial]` or `[]` — the device-targeting prefix. Pure (tested). */
40
+ export function serialArgs(serial) {
41
+ return serial ? ["-s", serial] : [];
42
+ }
19
43
  // Resolve adb without depending on the caller's PATH: ISH_ADB/ADB override → the
20
44
  // Android SDK → Homebrew → our own download cache → PATH. If none is found,
21
45
  // ensureAdb() fetches Google's standalone platform-tools (a small zip) into
@@ -105,11 +129,12 @@ export class AdbError extends Error {
105
129
  this.name = "AdbError";
106
130
  }
107
131
  }
108
- /** Run `adb <args>` and return trimmed stdout. Throws AdbError on failure. */
132
+ /** Run `adb [-s serial] <args>` and return trimmed stdout. Throws AdbError on failure. */
109
133
  export async function adb(args, timeoutMs = DEFAULT_TIMEOUT_MS) {
110
134
  const bin = await ensureAdb();
135
+ const full = [...serialArgs(activeSerial()), ...args];
111
136
  try {
112
- const { stdout } = await execFileAsync(bin, args, {
137
+ const { stdout } = await execFileAsync(bin, full, {
113
138
  timeout: timeoutMs,
114
139
  maxBuffer: 4 * 1024 * 1024,
115
140
  });
@@ -117,7 +142,7 @@ export async function adb(args, timeoutMs = DEFAULT_TIMEOUT_MS) {
117
142
  }
118
143
  catch (err) {
119
144
  const msg = err instanceof Error ? err.message : String(err);
120
- throw new AdbError(`adb ${args.join(" ")} failed: ${msg}`);
145
+ throw new AdbError(`adb ${full.join(" ")} failed: ${msg}`);
121
146
  }
122
147
  }
123
148
  /** Run `adb shell <args>` and return trimmed stdout. */
@@ -152,6 +177,38 @@ export async function appBuildFromDevice(pkg) {
152
177
  return null;
153
178
  }
154
179
  }
180
+ /**
181
+ * Pull `"pkg/activity"` out of `dumpsys activity activities`. The foreground
182
+ * activity surfaces as `topResumedActivity=ActivityRecord{... u0 pkg/activity
183
+ * t123}` (older builds: `mResumedActivity=...`); we take the `pkg/activity`
184
+ * token from whichever line is present. The activity may be a short `.Name`
185
+ * (relative to the package) — kept as-is, exactly what dumpsys reports. Returns
186
+ * "" when neither line is present.
187
+ */
188
+ export function parseTopActivity(out) {
189
+ const m = /topResumedActivity=ActivityRecord\{[^}]*\s(\S+\/\S+)/.exec(out) ??
190
+ /mResumedActivity:\s*ActivityRecord\{[^}]*\s(\S+\/\S+)/.exec(out) ??
191
+ /mResumedActivity=ActivityRecord\{[^}]*\s(\S+\/\S+)/.exec(out);
192
+ if (!m)
193
+ return "";
194
+ // The token can carry a trailing task id glued by the regex boundary? No —
195
+ // `\S+/\S+` stops at the first whitespace, so it is exactly `pkg/activity`.
196
+ return m[1];
197
+ }
198
+ /**
199
+ * The foreground `"pkg/activity"` from `dumpsys activity activities`, a coarse
200
+ * input for the screen signature. Best-effort: returns "" on any failure (the
201
+ * signature degrades to its package-only coarse token, and the run never
202
+ * depends on this read).
203
+ */
204
+ export async function currentActivity() {
205
+ try {
206
+ return parseTopActivity(await adbShell(["dumpsys", "activity", "activities"], 15_000));
207
+ }
208
+ catch {
209
+ return "";
210
+ }
211
+ }
155
212
  /**
156
213
  * Capture the current screen as raw PNG bytes via `adb exec-out screencap -p`.
157
214
  * `exec-out` (not `shell`) avoids the CRLF translation that corrupts binary
@@ -159,8 +216,9 @@ export async function appBuildFromDevice(pkg) {
159
216
  */
160
217
  export async function screencapPng() {
161
218
  const bin = await ensureAdb();
219
+ const full = [...serialArgs(activeSerial()), "exec-out", "screencap", "-p"];
162
220
  try {
163
- const { stdout } = await execFileAsync(bin, ["exec-out", "screencap", "-p"], {
221
+ const { stdout } = await execFileAsync(bin, full, {
164
222
  timeout: SCREENCAP_TIMEOUT_MS,
165
223
  maxBuffer: SCREENCAP_MAX_BUFFER,
166
224
  encoding: "buffer",
@@ -172,27 +230,62 @@ export async function screencapPng() {
172
230
  throw new AdbError(`adb exec-out screencap failed: ${msg}`);
173
231
  }
174
232
  }
175
- /** Assert exactly one device/emulator is in the `device` state. */
233
+ /**
234
+ * Parse `adb devices` output into {serial, state} rows. Pure (tested). Skips the
235
+ * "List of devices attached" header and blank lines.
236
+ */
237
+ export function parseAdbDevices(out) {
238
+ return out
239
+ .split("\n")
240
+ .slice(1)
241
+ .map((l) => l.trim())
242
+ .filter(Boolean)
243
+ .map((l) => {
244
+ const [serial, state] = l.split("\t");
245
+ return { serial: serial ?? "", state: state ?? "" };
246
+ })
247
+ .filter((d) => d.serial);
248
+ }
249
+ /** `adb devices` WITHOUT a serial prefix (the list is global, not per-device). */
250
+ async function devicesRaw() {
251
+ const bin = await ensureAdb();
252
+ const { stdout } = await execFileAsync(bin, ["devices"], {
253
+ timeout: DEFAULT_TIMEOUT_MS,
254
+ maxBuffer: 1024 * 1024,
255
+ });
256
+ return stdout.trim();
257
+ }
258
+ /** List online (state==="device") serials. */
259
+ export async function listOnlineSerials() {
260
+ return parseAdbDevices(await devicesRaw())
261
+ .filter((d) => d.state === "device")
262
+ .map((d) => d.serial);
263
+ }
264
+ /**
265
+ * Assert the target device is online. With a serial in effect (pool path or
266
+ * ANDROID_SERIAL), confirm THAT serial is online. Otherwise require exactly one.
267
+ */
176
268
  export async function requireOneDevice() {
177
- let out;
269
+ let online;
178
270
  try {
179
- out = await adb(["devices"]);
271
+ online = await listOnlineSerials();
180
272
  }
181
273
  catch (err) {
182
274
  const msg = err instanceof Error ? err.message : String(err);
183
275
  throw new AdbError(`Could not run adb (looked for "${findAdb() ?? "adb"}"). Run \`ish check android\` to check your setup. ${msg}`);
184
276
  }
185
- // Output: "List of devices attached\n<serial>\tdevice\n..."
186
- const online = out
187
- .split("\n")
188
- .slice(1)
189
- .map((l) => l.trim())
190
- .filter((l) => l && l.endsWith("\tdevice"));
191
277
  if (online.length === 0) {
192
278
  throw new AdbError("No Android device/emulator online. Run `ish check android` to check your setup and how to boot one.");
193
279
  }
280
+ const pinned = activeSerial();
281
+ if (pinned) {
282
+ if (online.includes(pinned))
283
+ return;
284
+ throw new AdbError(`Android device ${pinned} is not online. Online: ${online.join(", ") || "none"}.`);
285
+ }
194
286
  if (online.length > 1) {
195
- throw new AdbError(`Expected exactly one Android device, found ${online.length}. Stop the extras (the sim drives a single device).`);
287
+ throw new AdbError(`Expected exactly one Android device, found ${online.length}. ` +
288
+ `Stop the extras, or run with --parallel to pool them.`);
196
289
  }
197
290
  }
198
291
  // --- Input gestures (all in screencap pixel space) ---
@@ -66,9 +66,11 @@ export declare class AndroidDevice implements SimulationDevice {
66
66
  private refreshDimensions;
67
67
  observe(): Promise<DeviceObservation>;
68
68
  /**
69
- * Dump + serialize the uiautomator a11y tree. Any failure (dump retries
70
- * exhausted, parse error) degrades to an empty tree so the backend falls back
71
- * to the vision path a missing tree must never abort the observation.
69
+ * Dump + serialize the uiautomator a11y tree. Returns the serialized tree, the
70
+ * node map, the FLAT parsed nodes (for the screen signature) and the
71
+ * foreground package read off the dump. Any failure (dump retries exhausted,
72
+ * parse error) degrades to an empty tree so the backend falls back to the
73
+ * vision path — a missing tree must never abort the observation.
72
74
  */
73
75
  private dumpTree;
74
76
  captureScreenshot(): Promise<string>;
@@ -19,10 +19,11 @@
19
19
  * - Vision path: px = round(x / 1000 * screencapWidth); same for y.
20
20
  */
21
21
  import { resolveTextValue } from "./actions.js";
22
- import { requireOneDevice, screencapPng, pngDimensions, dumpUiautomatorXml, inputTap, inputSwipe, inputDrag, inputLongPress, setUserRotation, forceStop, launchApp, installApk, isPackageInstalled, listPackages, isAdbKeyboardInstalled, enableAdbKeyboard, setIme, resetIme, currentIme, adbKeyboardType, adbKeyboardClear, pressKeyEvent, statusbarExpand, appBuildFromDevice, ADB_KEYBOARD_PKG, } from "./adb.js";
22
+ import { requireOneDevice, screencapPng, pngDimensions, dumpUiautomatorXml, inputTap, inputSwipe, inputDrag, inputLongPress, setUserRotation, forceStop, launchApp, installApk, isPackageInstalled, listPackages, isAdbKeyboardInstalled, enableAdbKeyboard, setIme, resetIme, currentIme, adbKeyboardType, adbKeyboardClear, pressKeyEvent, statusbarExpand, appBuildFromDevice, currentActivity, ADB_KEYBOARD_PKG, } from "./adb.js";
23
23
  import { isLocalPath } from "../upload.js";
24
24
  import { deNormalizePoint, deNormalizeDrag } from "./coordinates.js";
25
- import { parseUiautomatorXml, serializeNativeTree, boundsCenter } from "./native-a11y.js";
25
+ import { parseUiautomatorXml, serializeNativeTree, boundsCenter, androidPackage, } from "./native-a11y.js";
26
+ import { computeScreenSignature } from "./screen-signature.js";
26
27
  import { packageNameFromApk } from "./apk-manifest.js";
27
28
  // Let animations/IME transitions settle before the next observation so the
28
29
  // screenshot the LLM reasons over reflects the action's result.
@@ -175,14 +176,24 @@ export class AndroidDevice {
175
176
  return png;
176
177
  }
177
178
  async observe() {
178
- // Screencap and the a11y dump are independent reads run them in parallel.
179
- // The dump is wrapped so a failure degrades to the vision path (empty tree)
180
- // rather than aborting the observation.
181
- const [png, tree] = await Promise.all([
179
+ // Screencap, the a11y dump, and the foreground-activity read are independent
180
+ // — run them in parallel. The dump is wrapped so a failure degrades to the
181
+ // vision path (empty tree) rather than aborting the observation; the
182
+ // activity read is best-effort ("" on failure → package-only coarse token).
183
+ const [png, tree, activity] = await Promise.all([
182
184
  this.refreshDimensions(),
183
185
  this.dumpTree(),
186
+ currentActivity(),
184
187
  ]);
185
188
  this.lastNodeMap = tree.nodeMap;
189
+ // Scroll-invariant screen signature from this dump's parsed nodes + coarse
190
+ // inputs (foreground package/activity). Sent only when usable (see loop.ts).
191
+ const coarseInputs = {
192
+ platform: "android",
193
+ package: tree.package,
194
+ activity,
195
+ };
196
+ const screenSignature = computeScreenSignature(tree.nodes, coarseInputs);
186
197
  return {
187
198
  screenshot: png.toString("base64"),
188
199
  // Element path when the dump produced a tree; "" → backend vision branch.
@@ -193,12 +204,19 @@ export class AndroidDevice {
193
204
  // Native has no scrollable document; the screen IS the page.
194
205
  documentHeight: this.screenHeight,
195
206
  tabs: [],
207
+ screenSignature,
208
+ // Corpus-dump only (ISH_DUMP_CORPUS): the exact parsed nodes + coarse
209
+ // inputs the signature consumed, so any algorithm can be replayed offline.
210
+ nativeNodes: tree.nodes,
211
+ coarseInputs,
196
212
  };
197
213
  }
198
214
  /**
199
- * Dump + serialize the uiautomator a11y tree. Any failure (dump retries
200
- * exhausted, parse error) degrades to an empty tree so the backend falls back
201
- * to the vision path a missing tree must never abort the observation.
215
+ * Dump + serialize the uiautomator a11y tree. Returns the serialized tree, the
216
+ * node map, the FLAT parsed nodes (for the screen signature) and the
217
+ * foreground package read off the dump. Any failure (dump retries exhausted,
218
+ * parse error) degrades to an empty tree so the backend falls back to the
219
+ * vision path — a missing tree must never abort the observation.
202
220
  */
203
221
  async dumpTree() {
204
222
  try {
@@ -206,12 +224,12 @@ export class AndroidDevice {
206
224
  const nodes = parseUiautomatorXml(xml);
207
225
  const tree = serializeNativeTree(nodes);
208
226
  this.log(`a11y tree: ${tree.nodeMap.size} node(s)`);
209
- return tree;
227
+ return { ...tree, nodes, package: androidPackage(xml) };
210
228
  }
211
229
  catch (err) {
212
230
  const msg = err instanceof Error ? err.message : String(err);
213
231
  this.log(`a11y dump failed, falling back to vision: ${msg}`);
214
- return { simplified: "", nodeMap: new Map() };
232
+ return { simplified: "", nodeMap: new Map(), nodes: [], package: "" };
215
233
  }
216
234
  }
217
235
  async captureScreenshot() {
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Device pool for parallel native runs. One `ish study run --parallel N` drives
3
+ * a pool of N devices; each worker claim()s a device and release()s it when its
4
+ * participant finishes.
5
+ *
6
+ * Devices come from a pluggable `DeviceProvider` (simulator | local-avd today;
7
+ * redroid | remote later), so the pool itself is provider-agnostic — it owns the
8
+ * claim/release queue, the crash-safe lock, and teardown of only what we started.
9
+ *
10
+ * The pure helpers (createDeviceQueue, allocateWdaPorts, maxConcurrentDevices)
11
+ * are exported and unit-tested without any device.
12
+ */
13
+ /** Rough per-device RAM for auto-sizing. iOS sims are lighter than emulators. */
14
+ export declare const PER_DEVICE_MB: {
15
+ readonly ios: 1024;
16
+ readonly android: 1536;
17
+ };
18
+ export interface PooledDevice {
19
+ platform: "ios" | "android";
20
+ /** iOS simulator udid, or Android adb serial (e.g. "emulator-5554"). */
21
+ id: string;
22
+ /** iOS only: the WDA port allocated to THIS device. */
23
+ wdaPort?: number;
24
+ /** True when the pool booted/cloned/launched this device (=> we tear it down). */
25
+ startedByUs: boolean;
26
+ /** Android: name of an AVD WE auto-created for this device (deleted on teardown). */
27
+ createdAvd?: string;
28
+ }
29
+ /**
30
+ * `count` distinct ports starting at `base`, skipping any an injected `isFree`
31
+ * marks taken (a stale runner on 8100). Pure — `isFree` defaults to all-free.
32
+ */
33
+ export declare function allocateWdaPorts(count: number, base?: number, isFree?: (p: number) => boolean): number[];
34
+ /**
35
+ * How many devices we can run concurrently given the host's RAM. This is the
36
+ * "anyone can run it" lever: a weak machine gets fewer (>=1, never errors), the
37
+ * rest queue.
38
+ *
39
+ * Sizes off TOTAL RAM × a utilization fraction, NOT `os.freemem()` —
40
+ * `freemem()` is unreliable cross-platform (macOS reports nearly everything as
41
+ * "used" because file cache is reclaimable-but-counted, so a 64 GB Mac shows
42
+ * ~0 free). Total × utilization is stable and machine-appropriate. Pure so
43
+ * tests can pin the sizing.
44
+ */
45
+ export declare function maxConcurrentDevices(opts: {
46
+ totalMemBytes: number;
47
+ perDeviceMb: number;
48
+ requested: number;
49
+ /** Available device slots (e.g. AVD count). Omit for unbounded (iOS clones). */
50
+ deviceCount?: number;
51
+ /** Fraction of total RAM we're willing to dedicate to devices. */
52
+ utilization?: number;
53
+ /** Headroom (MB) reserved for the OS + the CLI before counting devices. */
54
+ reserveMb?: number;
55
+ }): number;
56
+ export interface DeviceQueue {
57
+ claim(): Promise<PooledDevice>;
58
+ release(d: PooledDevice): void;
59
+ }
60
+ /**
61
+ * FIFO semaphore over a fixed device list: claim() takes a free device or waits;
62
+ * release() hands the device to the next waiter or returns it to the free list.
63
+ * Double-release of an already-free device is a no-op.
64
+ */
65
+ export declare function createDeviceQueue(devices: PooledDevice[]): DeviceQueue;
66
+ export interface DeviceProvider {
67
+ /** Provision up to `want` devices (reuse existing first, start the shortfall). */
68
+ provision(want: number, log: (m: string) => void): Promise<PooledDevice[]>;
69
+ /** Tear down ONE device this provider started. No-op for reused devices. */
70
+ teardownOne(d: PooledDevice, log: (m: string) => void): Promise<void>;
71
+ }
72
+ export interface DevicePool {
73
+ readonly devices: readonly PooledDevice[];
74
+ claim(): Promise<PooledDevice>;
75
+ release(d: PooledDevice): void;
76
+ /** Tear down ONLY devices we started; idempotent. Called by the loop's finally. */
77
+ teardown(): Promise<void>;
78
+ }
79
+ export declare function provisionDevicePool(opts: {
80
+ platform: string;
81
+ size: number;
82
+ log: (m: string) => void;
83
+ }): Promise<DevicePool>;
84
+ /** Total host RAM — the basis for auto-sizing the pool (see maxConcurrentDevices). */
85
+ export declare function totalMemBytes(): number;
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Device pool for parallel native runs. One `ish study run --parallel N` drives
3
+ * a pool of N devices; each worker claim()s a device and release()s it when its
4
+ * participant finishes.
5
+ *
6
+ * Devices come from a pluggable `DeviceProvider` (simulator | local-avd today;
7
+ * redroid | remote later), so the pool itself is provider-agnostic — it owns the
8
+ * claim/release queue, the crash-safe lock, and teardown of only what we started.
9
+ *
10
+ * The pure helpers (createDeviceQueue, allocateWdaPorts, maxConcurrentDevices)
11
+ * are exported and unit-tested without any device.
12
+ */
13
+ import { totalmem } from "node:os";
14
+ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
15
+ import { devicePoolLockPath } from "../paths.js";
16
+ import { IosError } from "./simctl.js";
17
+ import { listAllDevices, listBootedDevices, createSimulator, bootDevice, deleteDevice, } from "./simctl-provision.js";
18
+ import { listOnlineSerials } from "./adb.js";
19
+ import { listAvds, spawnEmulator, emulatorPorts, waitForBoot, emuKill, cloneAvd, deleteAvd, EmulatorError, } from "./emulator.js";
20
+ /** WDA port range start for pooled iOS devices. */
21
+ const WDA_BASE_PORT = 8100;
22
+ /** Rough per-device RAM for auto-sizing. iOS sims are lighter than emulators. */
23
+ export const PER_DEVICE_MB = { ios: 1024, android: 1536 };
24
+ // --- Pure helpers (unit-tested) ---------------------------------------------
25
+ /**
26
+ * `count` distinct ports starting at `base`, skipping any an injected `isFree`
27
+ * marks taken (a stale runner on 8100). Pure — `isFree` defaults to all-free.
28
+ */
29
+ export function allocateWdaPorts(count, base = WDA_BASE_PORT, isFree = () => true) {
30
+ const ports = [];
31
+ for (let p = base; ports.length < count && p < base + 1000; p++) {
32
+ if (isFree(p))
33
+ ports.push(p);
34
+ }
35
+ if (ports.length < count) {
36
+ throw new Error(`could not allocate ${count} free WDA ports from ${base}`);
37
+ }
38
+ return ports;
39
+ }
40
+ /**
41
+ * How many devices we can run concurrently given the host's RAM. This is the
42
+ * "anyone can run it" lever: a weak machine gets fewer (>=1, never errors), the
43
+ * rest queue.
44
+ *
45
+ * Sizes off TOTAL RAM × a utilization fraction, NOT `os.freemem()` —
46
+ * `freemem()` is unreliable cross-platform (macOS reports nearly everything as
47
+ * "used" because file cache is reclaimable-but-counted, so a 64 GB Mac shows
48
+ * ~0 free). Total × utilization is stable and machine-appropriate. Pure so
49
+ * tests can pin the sizing.
50
+ */
51
+ export function maxConcurrentDevices(opts) {
52
+ const utilization = opts.utilization ?? 0.5;
53
+ const reserveMb = opts.reserveMb ?? 1536;
54
+ const budgetMb = Math.max(0, (opts.totalMemBytes / (1024 * 1024)) * utilization - reserveMb);
55
+ const byRam = Math.max(1, Math.floor(budgetMb / Math.max(1, opts.perDeviceMb)));
56
+ let n = Math.min(opts.requested, byRam);
57
+ if (opts.deviceCount !== undefined)
58
+ n = Math.min(n, opts.deviceCount);
59
+ return Math.max(1, n); // always at least sequential
60
+ }
61
+ /**
62
+ * FIFO semaphore over a fixed device list: claim() takes a free device or waits;
63
+ * release() hands the device to the next waiter or returns it to the free list.
64
+ * Double-release of an already-free device is a no-op.
65
+ */
66
+ export function createDeviceQueue(devices) {
67
+ const free = [...devices];
68
+ const freeIds = new Set(free.map((d) => d.id));
69
+ const waiters = [];
70
+ return {
71
+ claim() {
72
+ const d = free.shift();
73
+ if (d) {
74
+ freeIds.delete(d.id);
75
+ return Promise.resolve(d);
76
+ }
77
+ return new Promise((resolve) => waiters.push(resolve));
78
+ },
79
+ release(d) {
80
+ if (freeIds.has(d.id))
81
+ return; // already free — double release is a no-op
82
+ const waiter = waiters.shift();
83
+ if (waiter) {
84
+ waiter(d); // handed straight to the next worker; stays claimed
85
+ }
86
+ else {
87
+ free.push(d);
88
+ freeIds.add(d.id);
89
+ }
90
+ },
91
+ };
92
+ }
93
+ /** Prefer an iPhone to clone from; a booted one first, else any available. */
94
+ function pickCloneSource(all, booted) {
95
+ return (booted[0] ??
96
+ all.find((d) => /iPhone/i.test(d.name) && d.state !== "Creating") ??
97
+ all[0]);
98
+ }
99
+ function simulatorProvider() {
100
+ return {
101
+ async provision(want, log) {
102
+ const all = await listAllDevices();
103
+ const booted = await listBootedDevices();
104
+ const ports = allocateWdaPorts(want);
105
+ const devices = [];
106
+ // Reuse already-booted simulators first (don't tear these down).
107
+ for (const d of booted.slice(0, want)) {
108
+ devices.push({ platform: "ios", id: d.udid, wdaPort: ports[devices.length], startedByUs: false });
109
+ }
110
+ const shortfall = want - devices.length;
111
+ if (shortfall > 0) {
112
+ const source = pickCloneSource(all, booted);
113
+ if (!source?.deviceTypeId) {
114
+ throw new IosError("No simulator to model the pool on — boot one (Simulator.app) or run `ish check ios`.");
115
+ }
116
+ log(`Creating ${shortfall} simulator(s) (${source.name}) for the pool — first boot can take ~30s each...`);
117
+ for (let i = 0; i < shortfall; i++) {
118
+ const name = `ish-pool-${process.pid}-${i}`;
119
+ const udid = await createSimulator(name, source.deviceTypeId, source.runtime);
120
+ await bootDevice(udid);
121
+ devices.push({ platform: "ios", id: udid, wdaPort: ports[devices.length], startedByUs: true });
122
+ }
123
+ }
124
+ return devices;
125
+ },
126
+ async teardownOne(d, log) {
127
+ if (!d.startedByUs)
128
+ return;
129
+ log(`Removing pooled simulator ${d.id.slice(0, 8)}…`);
130
+ await deleteDevice(d.id);
131
+ },
132
+ };
133
+ }
134
+ function localAvdProvider() {
135
+ // Track emulators WE spawned so teardown can SIGKILL them if `emu kill` hangs.
136
+ const spawned = new Map();
137
+ return {
138
+ async provision(want, log) {
139
+ const online = await listOnlineSerials();
140
+ const devices = [];
141
+ for (const serial of online.slice(0, want)) {
142
+ devices.push({ platform: "android", id: serial, startedByUs: false });
143
+ }
144
+ const shortfall = want - devices.length;
145
+ if (shortfall > 0) {
146
+ const avds = await listAvds();
147
+ if (avds.length === 0) {
148
+ throw new EmulatorError("No Android AVDs found — create one in Android Studio › Device Manager. (The pool clones it to as many as you need.)");
149
+ }
150
+ // AVDs we'll launch from. Auto-clone the shortfall from an existing AVD
151
+ // (file-copy, no avdmanager/JDK needed) so you only need ONE AVD.
152
+ const usable = [...avds];
153
+ const createdAvds = [];
154
+ const need = shortfall - usable.length;
155
+ if (need > 0) {
156
+ const source = avds[0];
157
+ log(`Cloning ${need} AVD(s) from "${source}" so --parallel has enough devices...`);
158
+ for (let i = 0; i < need; i++) {
159
+ const name = `ish-pool-avd-${process.pid}-${i}`;
160
+ cloneAvd(source, name);
161
+ createdAvds.push(name);
162
+ usable.push(name);
163
+ }
164
+ }
165
+ const toLaunch = Math.min(shortfall, usable.length);
166
+ if (toLaunch < shortfall) {
167
+ log(`Only ${usable.length} AVD(s) available; launching ${toLaunch} (create more AVDs or lower --parallel).`);
168
+ }
169
+ // Avoid console ports already taken by reused emulators.
170
+ const usedPorts = new Set(online
171
+ .filter((s) => s.startsWith("emulator-"))
172
+ .map((s) => Number(s.slice("emulator-".length))));
173
+ const ports = emulatorPorts(toLaunch + usedPorts.size)
174
+ .filter((p) => !usedPorts.has(p))
175
+ .slice(0, toLaunch);
176
+ const createdSet = new Set(createdAvds);
177
+ log(`Launching ${toLaunch} headless emulator(s) for the pool — first boot can take ~60s each...`);
178
+ const launched = [];
179
+ for (let i = 0; i < toLaunch; i++) {
180
+ const avd = usable[i];
181
+ const { child, serial } = spawnEmulator(avd, ports[i], { headless: true });
182
+ spawned.set(serial, child);
183
+ launched.push({ serial, avd });
184
+ }
185
+ await Promise.all(launched.map((l) => waitForBoot(l.serial)));
186
+ for (const { serial, avd } of launched) {
187
+ devices.push({
188
+ platform: "android",
189
+ id: serial,
190
+ startedByUs: true,
191
+ ...(createdSet.has(avd) ? { createdAvd: avd } : {}),
192
+ });
193
+ }
194
+ }
195
+ return devices;
196
+ },
197
+ async teardownOne(d, log) {
198
+ if (!d.startedByUs)
199
+ return;
200
+ log(`Stopping pooled emulator ${d.id}…`);
201
+ await emuKill(d.id);
202
+ const child = spawned.get(d.id);
203
+ if (child && !child.killed) {
204
+ try {
205
+ child.kill("SIGKILL");
206
+ }
207
+ catch {
208
+ /* already gone */
209
+ }
210
+ }
211
+ spawned.delete(d.id);
212
+ if (d.createdAvd)
213
+ deleteAvd(d.createdAvd);
214
+ },
215
+ };
216
+ }
217
+ function providerFor(platform) {
218
+ if (platform === "ios")
219
+ return simulatorProvider();
220
+ if (platform === "android")
221
+ return localAvdProvider();
222
+ throw new IosError(`device pool not yet implemented for platform "${platform}"`);
223
+ }
224
+ function pidIsAlive(pid) {
225
+ try {
226
+ process.kill(pid, 0);
227
+ return true;
228
+ }
229
+ catch {
230
+ return false;
231
+ }
232
+ }
233
+ function readLock() {
234
+ const p = devicePoolLockPath();
235
+ if (!existsSync(p))
236
+ return null;
237
+ try {
238
+ return JSON.parse(readFileSync(p, "utf8"));
239
+ }
240
+ catch {
241
+ return null;
242
+ }
243
+ }
244
+ function writeLock(lock) {
245
+ writeFileSync(devicePoolLockPath(), JSON.stringify(lock), { mode: 0o600 });
246
+ }
247
+ function clearLock() {
248
+ try {
249
+ rmSync(devicePoolLockPath(), { force: true });
250
+ }
251
+ catch {
252
+ /* best effort */
253
+ }
254
+ }
255
+ /** Reap a lock left by a crashed prior run: tear down its started devices. */
256
+ async function reapStaleLock(log) {
257
+ const lock = readLock();
258
+ if (!lock)
259
+ return;
260
+ if (pidIsAlive(lock.pid)) {
261
+ throw new IosError(`Another parallel native run (pid ${lock.pid}) holds the device pool. ` +
262
+ "Wait for it to finish, or kill it and retry.");
263
+ }
264
+ log("Reaping devices from a previous interrupted parallel run...");
265
+ for (const d of lock.devices) {
266
+ if (!d.startedByUs)
267
+ continue;
268
+ try {
269
+ if (d.platform === "ios")
270
+ await deleteDevice(d.id);
271
+ else if (d.platform === "android") {
272
+ await emuKill(d.id);
273
+ if (d.createdAvd)
274
+ deleteAvd(d.createdAvd);
275
+ }
276
+ }
277
+ catch {
278
+ /* best effort */
279
+ }
280
+ }
281
+ clearLock();
282
+ }
283
+ export async function provisionDevicePool(opts) {
284
+ await reapStaleLock(opts.log);
285
+ const provider = providerFor(opts.platform);
286
+ const devices = await provider.provision(opts.size, opts.log);
287
+ writeLock({ pid: process.pid, platform: opts.platform, startedAt: Date.now(), devices });
288
+ const queue = createDeviceQueue([...devices]);
289
+ let tornDown = false;
290
+ const pool = {
291
+ devices,
292
+ claim: () => queue.claim(),
293
+ release: (d) => queue.release(d),
294
+ async teardown() {
295
+ if (tornDown)
296
+ return;
297
+ tornDown = true;
298
+ for (const d of devices) {
299
+ await provider.teardownOne(d, opts.log).catch(() => { });
300
+ }
301
+ clearLock();
302
+ process.off("SIGTERM", onTerm);
303
+ },
304
+ };
305
+ // The run loop owns SIGINT (sets `cancelled`, then runs teardown in its
306
+ // finally). Add SIGTERM here so a `kill` also cleans up the devices we booted.
307
+ const onTerm = () => {
308
+ void pool.teardown().finally(() => process.exit(1));
309
+ };
310
+ process.once("SIGTERM", onTerm);
311
+ return pool;
312
+ }
313
+ /** Total host RAM — the basis for auto-sizing the pool (see maxConcurrentDevices). */
314
+ export function totalMemBytes() {
315
+ return totalmem();
316
+ }