@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.
- package/README.md +4 -0
- package/dist/commands/doctor.js +21 -11
- package/dist/commands/iteration.js +13 -4
- package/dist/commands/study-run.js +12 -12
- package/dist/commands/study-screenshots.js +15 -12
- package/dist/commands/study.js +22 -3
- package/dist/lib/api-client.d.ts +1 -0
- package/dist/lib/docs.js +139 -7
- package/dist/lib/local-sim/adb.d.ts +35 -2
- package/dist/lib/local-sim/adb.js +107 -14
- package/dist/lib/local-sim/android.d.ts +5 -3
- package/dist/lib/local-sim/android.js +29 -11
- package/dist/lib/local-sim/device-pool.d.ts +85 -0
- package/dist/lib/local-sim/device-pool.js +316 -0
- package/dist/lib/local-sim/device.d.ts +29 -0
- package/dist/lib/local-sim/device.js +19 -1
- package/dist/lib/local-sim/emulator.d.ts +50 -0
- package/dist/lib/local-sim/emulator.js +189 -0
- package/dist/lib/local-sim/install.js +23 -3
- package/dist/lib/local-sim/ios.d.ts +31 -5
- package/dist/lib/local-sim/ios.js +80 -21
- package/dist/lib/local-sim/loop.js +199 -9
- package/dist/lib/local-sim/native-a11y.d.ts +24 -0
- package/dist/lib/local-sim/native-a11y.js +76 -14
- package/dist/lib/local-sim/screen-signature.d.ts +77 -0
- package/dist/lib/local-sim/screen-signature.js +170 -0
- package/dist/lib/local-sim/simctl-provision.d.ts +49 -0
- package/dist/lib/local-sim/simctl-provision.js +89 -0
- package/dist/lib/local-sim/simctl.d.ts +6 -4
- package/dist/lib/local-sim/simctl.js +18 -5
- package/dist/lib/local-sim/xcuitest.d.ts +22 -1
- package/dist/lib/local-sim/xcuitest.js +38 -6
- package/dist/lib/modality.js +7 -2
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +3 -0
- package/dist/lib/skill-content.js +5 -2
- package/dist/lib/upload.d.ts +27 -0
- package/dist/lib/upload.js +108 -11
- 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,
|
|
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 ${
|
|
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,
|
|
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
|
-
/**
|
|
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
|
|
269
|
+
let online;
|
|
178
270
|
try {
|
|
179
|
-
|
|
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}.
|
|
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.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
|
179
|
-
// The dump is wrapped so a failure degrades to the
|
|
180
|
-
// rather than aborting the observation
|
|
181
|
-
|
|
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.
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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
|
+
}
|