diff --git a/docs/internal/DESIGN.md b/docs/internal/DESIGN.md index 6fde08476..5ae304132 100644 --- a/docs/internal/DESIGN.md +++ b/docs/internal/DESIGN.md @@ -133,7 +133,7 @@ put together from them and draws nothing of its own that one of them already dra - **Sidebar.** The console's navigation, on the left of every signed-in page: the "SIMLOCK" wordmark (`--on-accent` on an `--accent` block), one link per page in a fixed order (Overview, - Inbox, then the **Operate** group: Fleet, Leases, Queue; then the **Observe** group: + Inbox, then the **Operate** group: Fleet, Leases, Queue, Baselines; then the **Observe** group: Activity, Usage; each group under its small label, hidden when collapsed), then at the bottom the connection as a status, "Signed in as " (hidden when collapsed, where the Sign out tooltip reads "Sign out ()"), **Sign out** (absent for a proxy person with no sign-out address) and the collapse button. A link is an icon and its label in `--text-muted`; the diff --git a/docs/internal/adr/0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md b/docs/internal/adr/0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md index 6b5b14eb6..3dc143f88 100644 --- a/docs/internal/adr/0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md +++ b/docs/internal/adr/0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md @@ -1,6 +1,6 @@ # 0061. Every virtual device is a copy of a source, and a baseline is a named source -- **Status:** Accepted — not yet implemented +- **Status:** Accepted - **Date:** 2026-11-02 - **Issue:** [#327](https://github.com/callstackincubator/simlock/issues/327) (request [#1049](https://github.com/callstackincubator/simlock/issues/1049), points 1 and 4) - **Supersedes:** nothing. Narrows [ADR 0001](0001-simlock-owned-device-roots.md) @@ -134,7 +134,7 @@ no JSON text: | Step | iOS | Android | | ---- | --- | ------- | -| Copy | `simctl clone simlock-` in the device set; then write both marks (the clone has no root mark, and its `data/` mark is the source's); then copy the source's `/private/var/tmp/…SimDevice./disabled.plist`, if any, to the clone's UDID | make `//`, copy the source AVD folder into it under the same AVD name (timestamps kept), copy its `.ini` with `path=` rewritten; every command for that device carries `ANDROID_AVD_HOME=/` | +| Copy | `simctl clone simlock-` in the device set; then write both marks (the clone has no root mark, and its `data/` mark is the source's); then copy the source's `/private/var/tmp/…SimDevice./disabled.plist`, if any, to the clone's UDID; delete the clone's `data/var/db/lsd/com.apple.LaunchServices-*.csstore` before its first boot, in every clone (a seed's copy and a baseline's alike: it names each app, system apps included, by the original device's folder) | make `//`, copy the source AVD folder into it under the same AVD name (timestamps kept), copy its `.ini` with `path=` rewritten; every command for that device carries `ANDROID_AVD_HOME=/` | | Boot | as today (ADR 0060 writes the slim file first) | `-snapshot simlock_boot -no-snapshot-save`; a snapshot that does not load cold-boots the same disk (the emulator does this itself); a copy never boots with `-wipe-data` | | Release | destroy (ADR 0060 §4 removes its overrides folder); the next lease gets a new copy | destroy (the ownership check, `emu kill`, delete `//`); the next lease gets a new copy | diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md index f477bc66d..9e36d472c 100644 --- a/docs/internal/adr/README.md +++ b/docs/internal/adr/README.md @@ -96,4 +96,4 @@ the status is stale. | [0058](0058-through-a-gateway-simlock-adb-and-simctl-send-stdin-only-with-stdin.md) | Through a gateway, `simlock adb` and `simlock simctl` send stdin only with `--stdin` | Accepted; narrows 0005 §19c | | [0059](0059-a-worker-with-real-drivers-holds-a-per-user-lock.md) | A worker with real drivers holds a per-user lock | Accepted; narrows 0034 §1 | | [0060](0060-ios-slim-writes-launchd-overrides-while-the-simulator-is-shut-down.md) | iOS slim writes launchd overrides while the simulator is shut down | Accepted; supersedes 0002 §2, §3, §5, §8, §9 | -| [0061](0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md) | Every virtual device is a copy of a source, and a baseline is a named source | Accepted — not yet implemented; narrows 0001 §1, §3, §4, §5, §6, 0007 §6, 0009 §2, §4, §6, 0015 §6–7, 0038 §5 and 0060 §4 | +| [0061](0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md) | Every virtual device is a copy of a source, and a baseline is a named source | Accepted; narrows 0001 §1, §3, §4, §5, §6, 0007 §6, 0009 §2, §4, §6, 0015 §6–7, 0038 §5 and 0060 §4 | diff --git a/e2e/console/baselines.spec.ts b/e2e/console/baselines.spec.ts new file mode 100644 index 000000000..1e8826ad5 --- /dev/null +++ b/e2e/console/baselines.spec.ts @@ -0,0 +1,185 @@ +import type { Locator, Page } from "@playwright/test"; + +import { answer } from "./stub-answers.js"; +import { + disposeFleet, + expect, + type RunningDaemon, + signIn, + startDaemon, + startFleet, + test, +} from "./fixtures.js"; + +const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; +const LEASE = ["lease", "--platform", "ios", "--device", "iPhone 16", "--os", "18.4"] as const; + +async function leaseId(daemon: RunningDaemon, agent: string, extra: readonly string[] = []) { + const granted = await daemon.cli([...LEASE, "--agent-id", agent, "--detach", ...extra]); + expect(granted.code, granted.stderr).toBe(0); + return (granted.json as { lease: { id: string } }).lease.id; +} + +async function save(daemon: RunningDaemon, name: string, lease: string, note?: string) { + const args = ["baseline", "save", name, "--lease", lease]; + const saved = await daemon.cli(note === undefined ? args : [...args, "--note", note]); + expect(saved.code, saved.stderr).toBe(0); +} + +async function openBaselines(page: Page, daemon: RunningDaemon): Promise { + await page.goto(new URL("/baselines", daemon.url).toString()); + await signIn(page, daemon.tokens.operator); + await expect(page.getByRole("heading", { level: 1, name: "Baselines" })).toBeVisible(); +} + +function row(page: Page, name: string): Locator { + return page.locator("main tbody tr").filter({ hasText: name }); +} + +test.describe("the Baselines page", () => { + test("lists each baseline the worker holds, a save shows its row with no reload", async ({ + page, + }) => { + const host = await startDaemon("worker", { driverScript: { ios: IOS } }); + try { + const lease = await leaseId(host, "saver"); + await openBaselines(page, host); + await expect(page.getByText("No baselines.")).toBeVisible(); + + await save(host, "checkout", lease, "app 4.2"); + + const checkout = row(page, "checkout"); + await expect(checkout).toBeVisible(); + await expect(checkout).toContainText("iOS"); + await expect(checkout).toContainText("iPhone 16"); + await expect(checkout).toContainText("18.4"); + await expect(checkout).toContainText("app 4.2"); + await expect(checkout).toContainText("1 MiB"); + await expect(checkout).toContainText("ago"); + } finally { + await host.dispose(); + } + }); + + test("a second save shows version 2 in place, a lease naming the baseline shows it in the device's drawer, and a removal takes the row away with no reload", async ({ + page, + }) => { + const host = await startDaemon("worker", { driverScript: { ios: IOS } }); + try { + await save(host, "checkout", await leaseId(host, "first"), "app 4.2"); + await openBaselines(page, host); + await expect(row(page, "checkout").getByRole("cell").nth(1)).toHaveText("1"); + + await save(host, "checkout", await leaseId(host, "second")); + await expect(row(page, "checkout").getByRole("cell").nth(1)).toHaveText("2"); + await expect(page.locator("main tbody tr")).toHaveCount(1); + + const copy = await leaseId(host, "copier", ["--baseline", "checkout"]); + const plain = await leaseId(host, "plain"); + const view = await workers(host); + const onCopy = view.leases.find((held) => held.id === copy); + const onPlain = view.leases.find((held) => held.id === plain); + await openDrawer(page, host, view.id, onCopy?.deviceId ?? ""); + await expect(fact(page, "Baseline")).toHaveText("checkout v2"); + await openDrawer(page, host, view.id, onPlain?.deviceId ?? ""); + await expect(page.getByRole("dialog").locator("dt", { hasText: "Runtime" })).toBeVisible(); + await expect(page.getByRole("dialog").locator("dt", { hasText: "Baseline" })).toHaveCount(0); + + await page.goto(new URL("/baselines", host.url).toString()); + for (const id of [copy, plain]) { + const released = await host.cli(["release", id]); + expect(released.code, released.stderr).toBe(0); + } + const removed = await host.cli(["baseline", "remove", "checkout", "--yes"]); + expect(removed.code, removed.stderr).toBe(0); + await expect(page.getByText("No baselines.")).toBeVisible(); + } finally { + await host.dispose(); + } + }); + + test("on a gateway, the Worker column names each worker's baseline and a relayed save shows its row", async ({ + page, + }) => { + const fleet = await startFleet([ + { driverScript: { ios: IOS }, label: "worker-a" }, + { driverScript: { ios: IOS }, label: "worker-b" }, + ]); + try { + const [first, second] = fleet.workers as [RunningDaemon, RunningDaemon]; + await openBaselines(page, fleet.gateway); + + await save(first, "alpha", await leaseId(first, "a")); + await expect(page.getByRole("columnheader", { name: "Worker" })).toBeVisible(); + await expect(row(page, "alpha")).toContainText("worker-a"); + await save(second, "beta", await leaseId(second, "b")); + await expect(row(page, "beta")).toContainText("worker-b"); + await expect(page.locator("main tbody tr")).toHaveCount(2); + } finally { + await disposeFleet(fleet); + } + }); + + test("a stale baseline shows the square, stale and its reason word for word, an entry with no size shows a dash, and 26 entries page at 25", async ({ + page, + }) => { + const host = await startDaemon("worker", { driverScript: { ios: IOS } }); + try { + const reason = "runtime com.apple.CoreSimulator.SimRuntime.iOS-27-0 is not installed"; + const entries = Array.from({ length: 26 }, (_, index) => ({ + mode: "full", + model: "iPhone 16", + name: `base-${String(index).padStart(2, "0")}`, + osVersion: "18.4", + platform: "ios", + savedAt: new Date(Date.now() - 3_600_000).toISOString(), + version: 1, + ...(index === 0 ? { staleReason: reason } : { sizeBytes: 2 ** 20 }), + })); + await answer(page, "/v1/baselines", () => ({ baselines: entries })); + await openBaselines(page, host); + + await expect(page.locator("main tbody tr")).toHaveCount(25); + await expect(row(page, "base-25")).toHaveCount(0); + const stale = row(page, "base-00"); + await expect(stale).toContainText(`stale · ${reason}`); + await expect(stale.locator(".status.status-warn")).toHaveText("stale"); + await expect(stale).toContainText("—"); + await page.getByRole("button", { name: "Next" }).click(); + await expect(row(page, "base-25")).toBeVisible(); + } finally { + await host.dispose(); + } + }); +}); + +interface WorkerFacts { + readonly id: string; + readonly leases: readonly { readonly id: string; readonly deviceId: string }[]; +} + +async function workers(daemon: RunningDaemon): Promise { + const listed = await daemon.cli(["worker", "list", "--json"]); + expect(listed.code, listed.stderr).toBe(0); + const [view] = (listed.json as { workers: WorkerFacts[] }).workers; + if (view === undefined) throw new Error("no worker"); + return view; +} + +async function openDrawer( + page: Page, + daemon: RunningDaemon, + workerId: string, + deviceId: string, +): Promise { + const path = `/fleet/${encodeURIComponent(workerId)}/${encodeURIComponent(deviceId)}?tab=devices`; + await page.goto(new URL(path, daemon.url).toString()); + await expect(page.getByRole("dialog")).toBeVisible(); +} + +function fact(scope: Page, label: string): Locator { + return scope + .getByRole("dialog") + .locator("dt", { hasText: label }) + .locator("xpath=following-sibling::dd[1]"); +} diff --git a/e2e/console/big-fleet.spec.ts b/e2e/console/big-fleet.spec.ts index 205c378af..7ae6ea60d 100644 --- a/e2e/console/big-fleet.spec.ts +++ b/e2e/console/big-fleet.spec.ts @@ -40,7 +40,7 @@ function isCounted(request: Request): boolean { } test.describe("big fleet", () => { - test("an idle console on a big fleet stays quiet: for each of the seven pages, in the 60 s after its first reads, at most 40 requests to /v1 other than the event stream", async ({ + test("an idle console on a big fleet stays quiet: for each of the eight pages, in the 60 s after its first reads, at most 40 requests to /v1 other than the event stream", async ({ gateway, page, }) => { diff --git a/e2e/console/fixtures.ts b/e2e/console/fixtures.ts index 44618a63d..976315781 100644 --- a/e2e/console/fixtures.ts +++ b/e2e/console/fixtures.ts @@ -752,12 +752,13 @@ export const NAV = [ { label: "Fleet", path: "/fleet" }, { label: "Leases", path: "/leases" }, { label: "Queue", path: "/queue" }, + { label: "Baselines", path: "/baselines" }, { label: "Activity", path: "/activity" }, { label: "Usage", path: "/usage" }, ] as const; export const NAV_GROUPS = [ - { label: "Operate", links: ["Fleet", "Leases", "Queue"] }, + { label: "Operate", links: ["Fleet", "Leases", "Queue", "Baselines"] }, { label: "Observe", links: ["Activity", "Usage"] }, ] as const; diff --git a/e2e/console/shell.spec.ts b/e2e/console/shell.spec.ts index 227735a9f..8ca22caed 100644 --- a/e2e/console/shell.spec.ts +++ b/e2e/console/shell.spec.ts @@ -165,6 +165,7 @@ test.describe("the shell", () => { "Fleet /fleet", "Leases /leases", "Queue /queue", + "Baselines /baselines", "Activity /activity", "Usage /usage", ]); diff --git a/e2e/helpers/env.test.ts b/e2e/helpers/env.test.ts index a449615af..00ece0a79 100644 --- a/e2e/helpers/env.test.ts +++ b/e2e/helpers/env.test.ts @@ -11,9 +11,13 @@ const execFileAsync = promisify(execFile); // why: teardown after a failure is seen only from a vitest run of its own. Its flows fail as the // slow-lane runs of #1102 did (a shared daemon's beforeAll, as warmUp; a per-test daemon's test) -// and record each socket path, which the daemon's "Daemon started" log line names. +// and record each socket path, which the daemon's "Daemon started" log line names. The shared +// daemon's home is torn down twice, the second time over an empty daemon.log, as in a slow-lane +// run whose suite teardown failed on an emulator still writing into the home and the file's +// afterAll then tore it down again. const FAILING_FLOWS = ` -import { appendFile } from "node:fs/promises"; +import { appendFile, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { withDaemon, withSharedDaemon } from ${JSON.stringify(join(REPO_ROOT, "e2e/helpers/env.ts"))}; const record = (env) => appendFile(process.env.FIXTURE_SOCKETS, env.socketPath + "\\n"); @@ -28,7 +32,10 @@ describe("a suite whose beforeAll fails, as warmUp does", () => { throw new Error("waitFor timed out (2 warm-up devices ready; last observed: shutdown,ready)"); }); afterAll(async () => { - await env?.teardown(); + await env.teardown(); + await mkdir(env.dataDirectory, { recursive: true }); + await writeFile(join(env.dataDirectory, "daemon.log"), ""); + await env.teardown(); }); it("never runs", () => {}); }); @@ -52,7 +59,7 @@ async function filesUnder(directory: string): Promise { } describe("withDaemon and withSharedDaemon teardown", () => { - it("after a run whose flows fail, leaves the log of each daemon it started in SIMLOCK_E2E_LOG_DIR", async () => { + it("after a run whose flows fail, leaves the log of each daemon it started in SIMLOCK_E2E_LOG_DIR, and a second teardown of the same home does not replace it", async () => { const work = await mkdtemp(join(tmpdir(), "simlock-e2e-kept-logs-")); try { const logDirectory = join(work, "daemon-logs"); diff --git a/e2e/helpers/env.ts b/e2e/helpers/env.ts index d140a3501..2483eba28 100644 --- a/e2e/helpers/env.ts +++ b/e2e/helpers/env.ts @@ -24,6 +24,7 @@ import { events, expectEvents, type RecordedEvent } from "./events.js"; import { emptyDeviceSet, iosDeviceSet } from "./ios-device-set.js"; import { mcpClient, type McpClientHandle, type McpClientOptions } from "./mcp.js"; import { freeLoopbackPort } from "./port.js"; +import { removeHome } from "./remove-home.js"; import { REPO_ROOT } from "./repo-root.js"; import { waitFor } from "./wait.js"; @@ -478,7 +479,9 @@ interface PidDiscoveryResult { /** * why: a failed run's daemon logs are the evidence of why it failed, and teardown deletes the - * home they live in. A failed copy is swallowed so it never hides teardown's own error. + * home they live in. A failed copy is swallowed so it never hides teardown's own error. A copy + * already kept is never replaced: a teardown that runs again (the file's afterAll after a suite's + * failed one) finds only what was written into the home after the first, often an empty log. */ async function keepDaemonLogs(dataDirectory: string, home: string): Promise { const logDirectory = process.env.SIMLOCK_E2E_LOG_DIR; @@ -487,7 +490,9 @@ async function keepDaemonLogs(dataDirectory: string, home: string): Promise { // why: a timed-out test may still be running and writing into its simulator home if (realDrivers) await emptyDeviceSet(iosDeviceSet(home)); await keepDaemonLogs(dataDirectory, home); - await rm(home, { force: true, recursive: true }); + await removeHome(home); if (stopResult.stopError !== undefined) throw stopResult.stopError; if (strayPids.length > 0) { @@ -588,8 +593,9 @@ async function waitForDaemonSocketRemoval( ): Promise { if (discovery.pids.length === 0 && discovery.error === undefined) return undefined; try { + // why: `daemon stop` answers when the stop starts; the daemon first settles the boot of the pool's next device and shuts idle devices down, which takes 15 s on a real simulator. await waitFor(() => !existsSync(socketPath), { - timeout: 5_000, + timeout: 60_000, label: "daemon socket removed during teardown", }); return undefined; diff --git a/e2e/helpers/remove-home.test.ts b/e2e/helpers/remove-home.test.ts new file mode 100644 index 000000000..a62fd4818 --- /dev/null +++ b/e2e/helpers/remove-home.test.ts @@ -0,0 +1,36 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { removeHome } from "./remove-home.js"; + +// why: removeHome is a helper file of the e2e lane, outside any module surface, and the race it +// retries (a writer adding files between rm's listing and its rmdir) is forced only here. +describe("removeHome", () => { + it("removes a home whose folder a process is still filling while it is removed", async () => { + const home = await mkdtemp(join(tmpdir(), "simlock-remove-home-")); + const folder = join(home, "simlock.avd", "modem_simulator"); + await mkdir(folder, { recursive: true }); + await Promise.all( + Array.from({ length: 500 }, (_, index) => writeFile(join(folder, `seed-${index}`), "x")), + ); + const until = Date.now() + 600; + let count = 0; + const writer = (async () => { + while (Date.now() < until) { + count += 1; + await writeFile(join(folder, `file-${count}`), "x").catch(() => undefined); + } + })(); + + try { + await removeHome(home); + await writer; + await expect(stat(home)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await writer; + await rm(home, { force: true, recursive: true }); + } + }); +}); diff --git a/e2e/helpers/remove-home.ts b/e2e/helpers/remove-home.ts new file mode 100644 index 000000000..e34265a20 --- /dev/null +++ b/e2e/helpers/remove-home.ts @@ -0,0 +1,10 @@ +import { rm } from "node:fs/promises"; + +/** + * why: teardown deletes a home an emulator may still be writing into while it exits (its + * `modem_simulator` folder gains files between rm's listing and its rmdir, so rmdir fails with + * ENOTEMPTY). Node's rm retries that error, with a growing delay, until the writer is gone. + */ +export async function removeHome(home: string): Promise { + await rm(home, { force: true, maxRetries: 20, recursive: true, retryDelay: 250 }); +} diff --git a/e2e/slow-agent-device.test.ts b/e2e/slow-agent-device.test.ts index 9aba0b336..5695486cc 100644 --- a/e2e/slow-agent-device.test.ts +++ b/e2e/slow-agent-device.test.ts @@ -208,7 +208,8 @@ function defineSuite(target: Target): void { const released = await env.cli(["release", "--all", "--yes"], { timeout: 60_000 }); expect(released.code, released.stderr).toBe(0); await waitForFree(env); - }, 300_000); + // why: longer than the cleanup (up to 100 s), the release (60 s) and waitForFree's own DEVICE_READY_MS together, so a device that never turns ready fails on waitForFree's label, which names what it last saw, instead of on a bare hook timeout. + }, DEVICE_READY_MS + 180_000); it( "an agent opens a device, installs the Appduct fixture app as an upload, opens it by its bundle id, takes a snapshot that shows tab-status, clicks tab-status, saves a PNG screenshot and closes; within 240 s the device is free", diff --git a/e2e/slow-android-smoke.test.ts b/e2e/slow-android-smoke.test.ts index d64a4f3cd..7e9d2463b 100644 --- a/e2e/slow-android-smoke.test.ts +++ b/e2e/slow-android-smoke.test.ts @@ -312,8 +312,14 @@ describe.skipIf(!hasAndroidSdk)( { timeout: DEVICE_READY_MS }, ); expect(granted.code, `lease failed: ${granted.stderr}`).toBe(0); - return granted.json as { device: { driverDeviceId: string }; lease: { id: string } }; + return granted.json as { + device: { address?: string; driverDeviceId: string }; + lease: { id: string }; + }; }; + // why: a worker's `simlock adb` has no lease to aim at (`--lease` is a gateway's), and its adb server lists every emulator, so each call names the lease's own serial. + const adbOn = (grant: { device: { address?: string } }, ...args: string[]) => + env.cli(["adb", "-s", grant.device.address ?? "", ...args]); const [first, second] = await Promise.all([lease("seed-one"), lease("seed-two")]); @@ -324,14 +330,7 @@ describe.skipIf(!hasAndroidSdk)( for (const grant of [first, second]) { expect(existsSync(join(root, grant.device.driverDeviceId, "simlock.avd"))).toBe(true); } - const touched = await env.cli([ - "adb", - "--lease", - first.lease.id, - "shell", - "touch", - "/data/local/tmp/t2", - ]); + const touched = await adbOn(first, "shell", "touch", "/data/local/tmp/t2"); expect(touched.code, touched.stderr).toBe(0); for (const grant of [first, second]) { @@ -348,14 +347,7 @@ describe.skipIf(!hasAndroidSdk)( expect([first, second].map((grant) => grant.device.driverDeviceId)).not.toContain( third.device.driverDeviceId, ); - const listed = await env.cli([ - "adb", - "--lease", - third.lease.id, - "shell", - "ls", - "/data/local/tmp/t2", - ]); + const listed = await adbOn(third, "shell", "ls", "/data/local/tmp/t2"); expect(listed.code, "the file written on the first lease is not on a later one").not.toBe( 0, ); diff --git a/e2e/slow-android-tunnel.test.ts b/e2e/slow-android-tunnel.test.ts index abc4a6ed5..e2b059037 100644 --- a/e2e/slow-android-tunnel.test.ts +++ b/e2e/slow-android-tunnel.test.ts @@ -608,11 +608,18 @@ describe.skipIf(!hasAndroidSdk)( .filter((line) => line.startsWith(`${lease.serial} `)) .join("\n"), }; - return devices.code === 0 && forward.code === 0; + // why: adb lists a killed emulator as `offline` for a few seconds, so the read ends when it is gone, not when adb first answers. + return ( + devices.code === 0 && + forward.code === 0 && + left.devices === "" && + left.forward === "" + ); }, { interval: 2_000, - label: "the worker's adb server answering after the emulator was deleted", + label: () => + `the worker's adb server listing nothing of the lease's serial; last read ${JSON.stringify(left)}`, timeout: 120_000, }, ); diff --git a/e2e/slow-baseline.test.ts b/e2e/slow-baseline.test.ts index b89729924..cc75918a8 100644 --- a/e2e/slow-baseline.test.ts +++ b/e2e/slow-baseline.test.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { IOS } from "./helpers/agent-device-targets.js"; import { withDaemon } from "./helpers/index.js"; import type { TestEnv } from "./helpers/env.js"; import { iosDeviceSet, setDevices, sweepStaleDeviceSets } from "./helpers/ios-device-set.js"; @@ -40,7 +41,7 @@ describe.skipIf(process.platform !== "darwin")( { tags: ["slow", "ios"] }, () => { it( - "saves a leased iPhone as baseline version 1 with a size above 0, ends the lease, lists the source only in the sources set, and keeps the file written into the device's data folder", + "saves a leased iPhone as baseline version 1 with a size above 0, ends the lease, lists the source only in the sources set, keeps the file written into the device's data folder, and launches the installed app from its own folder on a copy", { timeout: 900_000 }, async () => { await sweepStaleDeviceSets(); @@ -57,10 +58,17 @@ describe.skipIf(process.platform !== "darwin")( }; const deviceSet = grant.environment.SIMLOCK_IOS_DEVICE_SET as string; const udid = grant.device.driverDeviceId; - const marker = join(deviceSet, udid, "data", "tmp", "simlock-baseline.txt"); - await mkdir(join(deviceSet, udid, "data", "tmp"), { recursive: true }); + // why: CoreSimulator empties `data/tmp` when a simulator shuts down, so the marker sits in `data/Documents`, which a shutdown keeps. + const marker = join(deviceSet, udid, "data", "Documents", "simlock-baseline.txt"); + await mkdir(join(deviceSet, udid, "data", "Documents"), { recursive: true }); await writeFile(marker, "prepared"); + // why: the app is installed and run before the save, so the copy has an installed app whose path the saved LaunchServices database names. + const installed = await env.cli(["simctl", "install", udid, IOS.app]); + expect(installed.code, `install failed: ${installed.stderr}`).toBe(0); + const started = await env.cli(["simctl", "launch", udid, IOS.bundleId]); + expect(started.code, `launch before the save failed: ${started.stderr}`).toBe(0); + const saved = await env.cli(["baseline", "save", "slow-ios", "--lease", grant.lease.id], { timeout: 600_000, }); @@ -75,7 +83,10 @@ describe.skipIf(process.platform !== "darwin")( const sourcesSets = [join(deviceSet, "sources"), join(env.home, "devices", "ios-sources")]; const sourcesSet = sourcesSets.find((candidate) => existsSync(candidate)); expect(sourcesSet, "no sources set exists after a save").toBeDefined(); - const inSources = await setDevices(sourcesSet as string); + // why: the sources set also holds the seed the next device is copied from; the baseline's clone is the one named `simlock-baseline-`. + const inSources = (await setDevices(sourcesSet as string)).filter((device) => + device.name.startsWith("simlock-baseline-"), + ); expect(inSources).toHaveLength(1); const source = inSources[0]; const inDeviceSet = await setDevices(iosDeviceSet(env.home)); @@ -83,11 +94,15 @@ describe.skipIf(process.platform !== "darwin")( const simctlList = await env.cli(["simctl", "list", "devices"]); expect(simctlList.stdout).not.toContain(source?.udid ?? "no source"); expect( - await readdir(join(sourcesSet as string, source?.udid ?? "", "data", "tmp")), + await readdir(join(sourcesSet as string, source?.udid ?? "", "data", "Documents")), ).toContain("simlock-baseline.txt"); // why: a lease naming the baseline starts from a copy that holds the saved file, and the copy's own changes are gone for the next one. - const copyDirectory = async (): Promise<{ leaseId: string; directory: string }> => { + const copyDirectory = async (): Promise<{ + leaseId: string; + directory: string; + udid: string; + }> => { const copy = await env.cli( [ "lease", @@ -116,13 +131,27 @@ describe.skipIf(process.platform !== "darwin")( copied.environment.SIMLOCK_IOS_DEVICE_SET as string, copied.device.driverDeviceId, "data", - "tmp", + "Documents", ), leaseId: copied.lease.id, + udid: copied.device.driverDeviceId, }; }; const first = await copyDirectory(); expect(await readdir(first.directory)).toContain("simlock-baseline.txt"); + // why: the copy must start the app from its own folder, not from the saved device's. + const container = await env.cli([ + "simctl", + "get_app_container", + first.udid, + IOS.bundleId, + "app", + ]); + expect(container.code, `get_app_container failed: ${container.stderr}`).toBe(0); + expect(container.stdout).toContain(`/${first.udid}/`); + expect(container.stdout).not.toContain(`/${udid}/`); + const copyStarted = await env.cli(["simctl", "launch", first.udid, IOS.bundleId]); + expect(copyStarted.code, `launch on the copy failed: ${copyStarted.stderr}`).toBe(0); await writeFile(join(first.directory, "simlock-session.txt"), "left behind"); const released = await env.cli(["release", first.leaseId]); expect(released.code, `release failed: ${released.stderr}`).toBe(0); @@ -144,107 +173,118 @@ describe.skipIf(!hasAndroidSdk)( { timeout: 900_000 }, async () => { const env = await withDaemon({ driver: "real" }); - const lease = await env.cli( - ["lease", "--platform", "android", "--agent-id", "baseline-android", "--detach"], - { timeout: DEVICE_READY_MS }, - ); - expect(lease.code, `lease failed: ${lease.stderr}`).toBe(0); - const grant = lease.json as { lease: { id: string } }; - const written = await env.cli([ - "adb", - "shell", - "settings", - "put", - "global", - "simlock_baseline", - "1", - ]); - expect(written.code, `settings put failed: ${written.stderr}`).toBe(0); - const file = await env.cli([ - "adb", - "shell", - "echo saved > /data/local/tmp/simlock-baseline.txt", - ]); - expect(file.code, `writing the file failed: ${file.stderr}`).toBe(0); - - const saved = await env.cli( - ["baseline", "save", "slow-android", "--lease", grant.lease.id], - { timeout: 900_000 }, - ); - expect(saved.code, `baseline save failed: ${saved.stderr}`).toBe(0); + try { + const lease = await env.cli( + ["lease", "--platform", "android", "--agent-id", "baseline-android", "--detach"], + { timeout: DEVICE_READY_MS }, + ); + expect(lease.code, `lease failed: ${lease.stderr}`).toBe(0); + const grant = lease.json as { device: { address: string }; lease: { id: string } }; + // why: a worker's `simlock adb` has no lease to aim at, and its adb server also lists the pool's spare emulator, so each call names the lease's serial. + const adbOn = (address: string, ...args: string[]) => + env.cli(["adb", "-s", address, ...args]); + const written = await adbOn( + grant.device.address, + "shell", + "settings", + "put", + "global", + "simlock_baseline", + "1", + ); + expect(written.code, `settings put failed: ${written.stderr}`).toBe(0); + const file = await adbOn( + grant.device.address, + "shell", + "echo saved > /data/local/tmp/simlock-baseline.txt", + ); + expect(file.code, `writing the file failed: ${file.stderr}`).toBe(0); - await expectLeaseEnded(env, grant.lease.id); - const [listed] = await baselines(env); - expect(listed).toMatchObject({ name: "slow-android", version: 1 }); - const sources = join(env.home, "devices", "android", "sources"); - const folders = await readdir(sources); - const snapshots = folders.map((folder) => - join(sources, folder, "simlock.avd", "snapshots", "simlock_boot"), - ); - expect( - snapshots.some((snapshot) => existsSync(snapshot)), - "the source holds no snapshots/simlock_boot folder", - ).toBe(true); + const saved = await env.cli( + ["baseline", "save", "slow-android", "--lease", grant.lease.id], + { timeout: 900_000 }, + ); + expect(saved.code, `baseline save failed: ${saved.stderr}`).toBe(0); - // why: a lease naming the baseline starts from a copy that holds the saved setting and file, and the copy's own changes are gone for the next one. - const copyLease = async (): Promise => { - const copy = await env.cli( - [ - "lease", - "--platform", - "android", - "--baseline", - "slow-android", - "--agent-id", - "baseline-android", - "--detach", - ], - { timeout: DEVICE_READY_MS }, + await expectLeaseEnded(env, grant.lease.id); + const [listed] = await baselines(env); + expect(listed).toMatchObject({ name: "slow-android", version: 1 }); + const sources = join(env.home, "devices", "android", "sources"); + const folders = await readdir(sources); + const snapshots = folders.map((folder) => + join(sources, folder, "simlock.avd", "snapshots", "simlock_boot"), ); - expect(copy.code, `lease naming the baseline failed: ${copy.stderr}`).toBe(0); - return (copy.json as { lease: { id: string } }).lease.id; - }; - const setting = async (): Promise => { - const read = await env.cli([ - "adb", + expect( + snapshots.some((snapshot) => existsSync(snapshot)), + "the source holds no snapshots/simlock_boot folder", + ).toBe(true); + + // why: a lease naming the baseline starts from a copy that holds the saved setting and file, and the copy's own changes are gone for the next one. + const copyLease = async (): Promise<{ address: string; id: string }> => { + const copy = await env.cli( + [ + "lease", + "--platform", + "android", + "--baseline", + "slow-android", + "--agent-id", + "baseline-android", + "--detach", + ], + { timeout: DEVICE_READY_MS }, + ); + expect(copy.code, `lease naming the baseline failed: ${copy.stderr}`).toBe(0); + const copied = copy.json as { device: { address: string }; lease: { id: string } }; + return { address: copied.device.address, id: copied.lease.id }; + }; + const setting = async (address: string): Promise => { + const read = await adbOn( + address, + "shell", + "settings", + "get", + "global", + "simlock_baseline", + ); + return read.stdout.trim(); + }; + // why: a missing file prints nothing to stdout, so the read answers an empty string for it. + const fileText = async (address: string, name: string): Promise => { + const read = await adbOn(address, "shell", `cat /data/local/tmp/${name} 2>/dev/null`); + return read.stdout.trim(); + }; + const copyId = await copyLease(); + expect(await setting(copyId.address)).toBe("1"); + expect(await fileText(copyId.address, "simlock-baseline.txt")).toBe("saved"); + const changed = await adbOn( + copyId.address, "shell", "settings", - "get", + "put", "global", "simlock_baseline", - ]); - return read.stdout.trim(); - }; - // why: a missing file prints nothing to stdout, so the read answers an empty string for it. - const fileText = async (name: string): Promise => { - const read = await env.cli(["adb", "shell", `cat /data/local/tmp/${name} 2>/dev/null`]); - return read.stdout.trim(); - }; - const copyId = await copyLease(); - expect(await setting()).toBe("1"); - expect(await fileText("simlock-baseline.txt")).toBe("saved"); - const changed = await env.cli([ - "adb", - "shell", - "settings", - "put", - "global", - "simlock_baseline", - "2", - ]); - expect(changed.code, `settings put failed: ${changed.stderr}`).toBe(0); - const left = await env.cli([ - "adb", - "shell", - "echo left > /data/local/tmp/simlock-session.txt", - ]); - expect(left.code, `writing the file failed: ${left.stderr}`).toBe(0); - const released = await env.cli(["release", copyId]); - expect(released.code, `release failed: ${released.stderr}`).toBe(0); - await copyLease(); - expect(await setting()).toBe("1"); - expect(await fileText("simlock-baseline.txt")).toBe("saved"); - expect(await fileText("simlock-session.txt")).toBe(""); + "2", + ); + expect(changed.code, `settings put failed: ${changed.stderr}`).toBe(0); + const left = await adbOn( + copyId.address, + "shell", + "echo left > /data/local/tmp/simlock-session.txt", + ); + expect(left.code, `writing the file failed: ${left.stderr}`).toBe(0); + const released = await env.cli(["release", copyId.id]); + expect(released.code, `release failed: ${released.stderr}`).toBe(0); + const second = await copyLease(); + expect(await setting(second.address)).toBe("1"); + expect(await fileText(second.address, "simlock-baseline.txt")).toBe("saved"); + expect(await fileText(second.address, "simlock-session.txt")).toBe(""); + } finally { + // why: the pool boots a spare emulator once a lease ends; teardown removes the home but kills no emulator, so one left running would join the next test's adb server. + await env + .cli(["nuke", "--delete-devices", "--yes"], { timeout: 120_000 }) + .catch(() => undefined); + } }, ); }, diff --git a/e2e/slow-ios-smoke.test.ts b/e2e/slow-ios-smoke.test.ts index 1831aa36d..b6d2837ac 100644 --- a/e2e/slow-ios-smoke.test.ts +++ b/e2e/slow-ios-smoke.test.ts @@ -169,6 +169,17 @@ describe.skipIf(process.platform !== "darwin")( "a Simlock simulator must be invisible in the machine's default device set", ).toBe(false); + // why: a copy of the seed keeps no LaunchServices database naming the seed's folder, so a system app's data lives under the lease's own UDID. + const safariData = await env.cli([ + "simctl", + "get_app_container", + udid, + "com.apple.mobilesafari", + "data", + ]); + expect(safariData.code, `get_app_container failed: ${safariData.stderr}`).toBe(0); + expect(safariData.stdout.trim()).toContain(`/${udid}/`); + // Provenance: both marks exist with the same token. `doctor` is the // documented way to observe this -- a foreign-provenance-change finding // for this device would mean the marks disagree or are missing. diff --git a/src/core/baselines.ts b/src/core/baselines.ts index d5009cd1e..25eed2171 100644 --- a/src/core/baselines.ts +++ b/src/core/baselines.ts @@ -494,7 +494,7 @@ export class BaselineService { .get(request.name); const version = Number(next?.version); const savedAt = new Date(this.options.clock.now()).toISOString(); - const { imageTag } = device.spec; + const imageTag = saved.imageTag ?? device.spec.imageTag; database .prepare( `INSERT INTO baselines diff --git a/src/core/driver.ts b/src/core/driver.ts index 0e8b32fe5..468254d46 100644 --- a/src/core/driver.ts +++ b/src/core/driver.ts @@ -287,7 +287,9 @@ export interface Driver { * device down cleanly itself, makes the copy in its own sources area and sets the row `ready`. * `sizeBytes` is absent when it could not be counted. */ - saveSource(device: VirtualDriverDevice): Promise<{ sourceId: string; sizeBytes?: number }>; + saveSource( + device: VirtualDriverDevice, + ): Promise<{ sourceId: string; sizeBytes?: number; imageTag?: string }>; /** * why: Boots the device and returns it with a freshly read `address`. Never trust the address a * caller passed in or one captured at `provision` -- an Android console port is assigned per diff --git a/src/core/fake-driver.ts b/src/core/fake-driver.ts index af97f9ee5..1f5aa3a4a 100644 --- a/src/core/fake-driver.ts +++ b/src/core/fake-driver.ts @@ -85,6 +85,8 @@ export interface FakeDriverCall { export interface FakeDriverOptions { readonly availableOsVersions?: readonly string[]; + /** why: the image tag a device made for a request naming none really carries, which a saved source reports. */ + readonly defaultImageTag?: string; readonly clock: Clock; readonly componentFootprint?: { readonly path: string; readonly bytes: number }; readonly installProgress?: readonly number[]; @@ -171,6 +173,7 @@ export class FakeDriver implements Driver { readonly #modelAliases: FakeDriverOptions["modelAliases"]; readonly #modelClasses: FakeDriverOptions["modelClasses"]; readonly #images: FakeDriverOptions["images"]; + readonly #defaultImageTag: string | undefined; readonly #customModels: FakeDriverOptions["customModels"]; readonly #latencyMs: FakeDriverOptions["latencyMs"]; readonly #leaseEnvironment: Readonly>; @@ -209,6 +212,7 @@ export class FakeDriver implements Driver { this.#modelClasses = options.modelClasses; this.#defaultModels = options.defaultModels; this.#images = options.images; + this.#defaultImageTag = options.defaultImageTag; this.#customModels = options.customModels; this.#latencyMs = options.latencyMs; this.#leaseEnvironment = options.leaseEnvironment ?? {}; @@ -416,7 +420,9 @@ export class FakeDriver implements Driver { this.#sourceFiles.delete(sourceId); } - async saveSource(device: VirtualDriverDevice): Promise<{ sourceId: string; sizeBytes: number }> { + async saveSource( + device: VirtualDriverDevice, + ): Promise<{ sourceId: string; sizeBytes: number; imageTag?: string }> { await this.#beforeCall("saveSource", device); this.#requireDevice(device); const spec = this.#deviceSpecs.get(device.deviceId); @@ -425,6 +431,7 @@ export class FakeDriver implements Driver { this.#managedReality = removeManagedProcess(this.#managedReality, device.deviceId); const files = new Map(this.#deviceFiles.get(device.deviceId)); const sizeBytes = [...files.values()].reduce((total, content) => total + content.length, 0); + const imageTag = spec.imageTag ?? this.#defaultImageTag; const sourceId = `fake-source-${String(this.#nextSourceNumber)}`; this.#nextSourceNumber += 1; this.#sourceFiles.set(sourceId, files); @@ -435,11 +442,11 @@ export class FakeDriver implements Driver { kind: "baseline", model: spec.model, osVersion: spec.osVersion, - ...(spec.imageTag === undefined ? {} : { imageTag: spec.imageTag }), + ...(imageTag === undefined ? {} : { imageTag }), sizeBytes, }, ]; - return { sourceId, sizeBytes }; + return { sourceId, sizeBytes, ...(imageTag === undefined ? {} : { imageTag }) }; } /** why: a test hook that puts a file on a device, so a saved source can be shown to carry it. */ diff --git a/src/core/reclaim-coordinator.ts b/src/core/reclaim-coordinator.ts index bd1e1db7d..93a5dd213 100644 --- a/src/core/reclaim-coordinator.ts +++ b/src/core/reclaim-coordinator.ts @@ -22,6 +22,8 @@ import { stableError } from "./stable-error.js"; export interface SavedSource { readonly sourceId: string; readonly sizeBytes?: number; + /** why: the image tag the saved device really carries, which a request that named none leaves out. */ + readonly imageTag?: string; } export interface ReclaimDriverCatalog { diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 01d63aa76..0c5b6b37a 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -1571,6 +1571,51 @@ describe("Dispatcher: baseline.save", () => { .map((envelope) => envelope.payload), ).toMatchObject([{ leaseId: grant.lease.id, reason: "explicit" }]); }); + + it("records the image tag the saved device carries when its lease named none, so a lease naming that tag is granted and another tag is refused", async () => { + const { dispatcher } = await buildDispatcher({ + driverOptions: { + availableOsVersions: ["34"], + defaultImageTag: "google_apis_playstore", + images: [ + { abi: "arm64-v8a", runtime: "34", tag: "google_apis" }, + { abi: "arm64-v8a", runtime: "34", tag: "google_apis_playstore" }, + ], + platform: "android", + }, + }); + const grant = await dispatcher.dispatch( + "lease.request", + { model: "Pixel 8", osVersion: "34", platform: "android" }, + session(), + ); + const admin = session({ role: "admin" }); + + const saved = await dispatcher.dispatch( + "baseline.save", + { leaseId: grant.lease.id, name: "android-test" }, + admin, + ); + const listed = await dispatcher.dispatch("baseline.list", {}, admin); + const named = { baseline: "android-test", platform: "android" }; + const granted = await dispatcher.dispatch( + "lease.request", + { ...named, imageTag: "google_apis_playstore" }, + session(), + ); + await dispatcher.dispatch("lease.release", { leaseId: granted.lease.id }, session()); + const refused = await dispatcher + .dispatch("lease.request", { ...named, imageTag: "google_apis" }, session()) + .catch((caught: unknown) => caught); + + expect(saved.baseline.imageTag).toBe("google_apis_playstore"); + expect(listed.baselines.map((entry) => entry.imageTag)).toEqual(["google_apis_playstore"]); + expect(granted.device.spec.baseline).toEqual({ name: "android-test", version: 1 }); + expect(refused).toMatchObject({ + message: + "baseline android-test is image tag google_apis_playstore, the request asks for google_apis", + }); + }); }); describe("Dispatcher: baseline.save versions", () => { diff --git a/src/drivers/AGENTS.md b/src/drivers/AGENTS.md index d08735aa9..73bf65658 100644 --- a/src/drivers/AGENTS.md +++ b/src/drivers/AGENTS.md @@ -17,7 +17,7 @@ Manuals: [Android](android/AGENTS.md), [iOS](ios/AGENTS.md). Flows: [device sele | `findComponent`, `installComponent`, `listComponents`, `removeComponent` | Runtimes / system images. Only core's `ComponentInstaller` installs or removes | | `inspectPhysical`, `listPresentPhysical` | Enrollment read and presence of physical devices | | `leaseEnvironment`, `virtualLeaseEnvironment`, `passthrough` | What a holder needs to reach its device | -| `dispose` | Daemon stop. Android reaps its adb server here | +| `dispose` | Daemon stop. Android reaps its adb server here, unless an emulator is still attached | | `screenCapture` (optional) | A device's screen as core's frame envelope (`core/screen.ts`); absent = cannot stream | ## Rules both drivers keep diff --git a/src/drivers/android/AGENTS.md b/src/drivers/android/AGENTS.md index 8667b81ff..3e06c594a 100644 --- a/src/drivers/android/AGENTS.md +++ b/src/drivers/android/AGENTS.md @@ -45,9 +45,9 @@ Simlock's adb server — `adb -P nodaemon server`, env `ADB_EMU=0` (no sc 1. Port listening + live record → adopt. Listening, no record → `occupied`. 2. Record alive, port silent → reap. Then spawn, write the record, wait for the port → else `start-failed`. 3. Reap = its command line (`ProcessSupervisor.commandLine`) must show `adb` or `adb.exe`, `-P `, `nodaemon`; SIGTERM, then SIGKILL. An unreadable one keeps the record. -4. `dispose` reaps it. Emulators are spawned unref'd and outlive the daemon. +4. `dispose` reaps it, unless `adb devices` still lists an emulator: then it leaves the server and its record for the next daemon to adopt, because an emulator whose server is gone starts an adb `fork-server` on the port, which the next start refuses as `occupied`. Emulators are spawned unref'd and outlive the daemon. -Provision — `provision`: every device is a copy of a seed. +Provision — `provision`: every device is a copy of a seed, or of the ready baseline source named by `options.source` (a lease naming a baseline): then steps 1–3 are skipped and step 4 copies that source's folder. 1. Profile from the `resolveSpec` cache, else `DeviceProfileRegistry.resolve`. Image: installed, fitting, at the spec's API level. Order: host-ABI `google_apis` → host ABI → `google_apis` → any. With an image tag: that tag only, host ABI first. 2. Seed for `|`: a `ready` row recomputed on each provision (`emulator -version`, installed images). Stale (`system image … is not installed` / `changed from revision …`, `emulator changed from … to …`, `android.emulator launch settings changed`) → destroyed and made again; the reason is stored by `listSources` and clears when things match. One make per kind at a time (lock); a `making` row of an earlier process, or older than 900 s, is swept (its emulator stopped by its path) at the next listing or make of that kind. diff --git a/src/drivers/android/adb-server.ts b/src/drivers/android/adb-server.ts index d818f837f..027581238 100644 --- a/src/drivers/android/adb-server.ts +++ b/src/drivers/android/adb-server.ts @@ -157,6 +157,16 @@ export class AdbServerSupervisor { await this.#reap(record); } + /** + * why: ends this daemon's hold on the server without ending the server, the way a daemon that + * died leaves it: the record stays on disk, so the next daemon adopts it. A server reaped + * under a running emulator is replaced within seconds by a `fork-server` the emulator starts + * on the same port, which the next start cannot tell from a stranger's (`occupied`). + */ + leave(): void { + this.#record = undefined; + } + #requireUsablePort(): void { const { port } = this.#options; diff --git a/src/drivers/android/index.test.ts b/src/drivers/android/index.test.ts index c03201300..b1f932b41 100644 --- a/src/drivers/android/index.test.ts +++ b/src/drivers/android/index.test.ts @@ -3883,36 +3883,54 @@ describe("AndroidDriver.create", () => { } }); - it("stops the adb server it adopted when the daemon disposes of it", async () => { - const filesystem = await androidFilesystem(); - const processSupervisor = new FakeProcessSupervisor([adbServerPid]); - processSupervisor.answerCommandLine( - adbServerPid, - `${binaries.adb} -P ${adbServerPort} nodaemon server`, - ); - await recordRunningAdbServer(filesystem); - const driver = await AndroidDriver.create({ - clock: new FakeClock(), - database: await memoryAndroidSourceDatabase(), - driverConfig: {}, - env: { ANDROID_HOME: sdk }, - filesystem, - homeDirectory: home, - hostAbi: "arm64-v8a", - instanceId, - processRunner: new EmulatorExitingRunner([]), - processSupervisor, - simlockHome, - tcpProbe: new FakeTcpProbe([adbServerPort]), - }); - processSupervisor.markDead(adbServerPid); + it.each([ + { + attached: "no emulator is attached", + devices: "List of devices attached\n", + signals: [{ pid: adbServerPid, signal: "SIGTERM" }], + recordKept: false, + }, + { + // why: an emulator that outlives the daemon starts its own `fork-server` on the port once the server is gone, and the next daemon refuses that one as occupied. + attached: "an emulator is still attached", + devices: "List of devices attached\nemulator-5586\tdevice\n", + signals: [], + recordKept: true, + }, + ])( + "when the daemon disposes of the adb server it adopted and $attached, the server gets $signals and the record is kept: $recordKept", + async ({ devices, recordKept, signals }) => { + const filesystem = await androidFilesystem(); + const processSupervisor = new FakeProcessSupervisor([adbServerPid]); + processSupervisor.answerCommandLine( + adbServerPid, + `${binaries.adb} -P ${adbServerPort} nodaemon server`, + ); + await recordRunningAdbServer(filesystem); + const runner = new EmulatorExitingRunner([processResult(binaries.adb, ["devices"], devices)]); + const driver = await AndroidDriver.create({ + clock: new FakeClock(), + database: await memoryAndroidSourceDatabase(), + driverConfig: {}, + env: { ANDROID_HOME: sdk }, + filesystem, + homeDirectory: home, + hostAbi: "arm64-v8a", + instanceId, + processRunner: runner, + processSupervisor, + simlockHome, + tcpProbe: new FakeTcpProbe([adbServerPort]), + }); + processSupervisor.markDead(adbServerPid); - await driver.dispose(); + await driver.dispose(); - // why: By pid, because `ADB_REJECT_KILL_SERVER=1` means `adb kill-server` refuses Simlock too. - expect(processSupervisor.signals).toEqual([{ pid: adbServerPid, signal: "SIGTERM" }]); - await expect(filesystem.exists(adbRecordPath)).resolves.toBe(false); - }); + // why: By pid, because `ADB_REJECT_KILL_SERVER=1` means `adb kill-server` refuses Simlock too. + expect(processSupervisor.signals).toEqual(signals); + await expect(filesystem.exists(adbRecordPath)).resolves.toBe(recordKept); + }, + ); }); describe("AndroidDriver ownership", () => { @@ -6416,6 +6434,38 @@ describe("AndroidDriver image fit", () => { processResult(binaries.adb, ["-s", serial, "shell", "reboot", "-p"]), ]; + it("provisions a device named after a baseline source as a copy of that source's folder with no seed made and only the emulator's version read, and refuses a source whose system image is gone", async () => { + const version = () => + processResult(binaries.emulator, ["-version"], "Android emulator version 36.1.9"); + const harness = await seedHarness( + [...leasing(true), ...savingSteps, ...sourceBoot(), version(), version()], + { + config, + ids: [...ids, "five", "six"], + }, + ); + const ready = await harness.driver.makeReady(await harness.driver.provision(harness.spec)); + await harness.driver.saveSource({ ...ready, physical: false as const }); + await harness.filesystem.writeFileAtomic(`${savedHome}/simlock.avd/marker.txt`, "saved"); + const callsBefore = harness.runner.calls.length; + + const copy = await harness.driver.provision(harness.spec, { source: "baseline_four" }); + + expect(copy.deviceId).toBe("simlock_five"); + await expect( + harness.filesystem.readFile(`${avdDirectory}/simlock_five/simlock.avd/marker.txt`), + ).resolves.toBe("saved"); + expect(harness.runner.calls).toHaveLength(callsBefore + 1); + + await harness.filesystem.rm(`${sdk}/system-images/android-34/google_apis/arm64-v8a`); + await expect( + harness.driver.provision(harness.spec, { source: "baseline_four" }), + ).rejects.toThrow( + "source baseline_four is stale: system image system-images;android-34;google_apis;arm64-v8a is not installed", + ); + expect(await harness.filesystem.exists(`${avdDirectory}/simlock_six`)).toBe(false); + }); + it("keeps a powered-off copy of the device as a ready baseline source with one boot snapshot, powering the emulator off first, or killing it when it will not leave in 60 s, or polling adb with no process handle", async () => { const kept = async (harness: Awaited>) => { expect(await harness.filesystem.exists(`${savedHome}/simlock.avd/config.ini`)).toBe(true); @@ -6456,7 +6506,11 @@ describe("AndroidDriver image fit", () => { ...poweredReady, physical: false as const, }); - expect(poweredSaved).toEqual({ sizeBytes: 121, sourceId: "baseline_four" }); + expect(poweredSaved).toEqual({ + imageTag: "google_apis", + sizeBytes: 121, + sourceId: "baseline_four", + }); await kept(powered); expect(sent(powered)).toEqual( expect.arrayContaining([ diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index a29ffd774..2e520f11d 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -992,9 +992,31 @@ export class AndroidDriver implements Driver { * shutdown that skipped this would leave both a server and a daemon that cannot exit. */ async dispose(): Promise { + if (await this.#emulatorAttached()) { + this.#adbServer.leave(); + return; + } await this.#adbServer.stop(); } + /** + * why: an emulator that outlives the daemon starts its own adb `fork-server` on this port + * once the server is gone, and the next daemon refuses that one as `occupied`; so the server + * stays while an emulator is attached and the next daemon adopts it. An unreadable list + * counts as none: the server is reaped as it always was. + */ + async #emulatorAttached(): Promise { + try { + const result = await this.#processRunner.run(this.#sdk.adb, ["devices"], { + env: this.#env(), + timeoutMs: ADB_DEVICES_TIMEOUT_MS, + }); + return result.code === 0 && portsFromAdbDevices(result.stdout).length > 0; + } catch { + return false; + } + } + /** * why: The single insertion point for Android's scoping, the way `--set` is iOS's: every * `adb`, `emulator`, and `avdmanager` invocation this driver makes carries both keys, so @@ -1246,7 +1268,9 @@ export class AndroidDriver implements Driver { * sources area and booted once cold to save the boot snapshot every copy starts from. The row is * written `making` before any folder exists, so a crash leaves a row that names the folder to clean. */ - async saveSource(device: VirtualDriverDevice): Promise<{ sourceId: string; sizeBytes?: number }> { + async saveSource( + device: VirtualDriverDevice, + ): Promise<{ sourceId: string; sizeBytes?: number; imageTag?: string }> { const data = this.#dataFor(device); return this.#withDeviceLock(this.#avdFolder(data), async () => { const state = this.#stateFor(data); @@ -1291,7 +1315,11 @@ export class AndroidDriver implements Driver { ); const sizeBytes = await this.#filesystem.directorySize(avdFolder).catch(() => undefined); this.#sources.markReady(sourceId, configHash, sizeBytes); - return { sourceId, ...(sizeBytes === undefined ? {} : { sizeBytes }) }; + return { + imageTag: kind.imageTag, + sourceId, + ...(sizeBytes === undefined ? {} : { sizeBytes }), + }; } catch (error: unknown) { await this.#filesystem.rm(folder); this.#sources.remove(sourceId); @@ -1401,8 +1429,12 @@ export class AndroidDriver implements Driver { ); } - async provision(spec: VirtualSpec): Promise { + async provision( + spec: VirtualSpec, + options: { readonly source?: string } = {}, + ): Promise { this.#assertAndroidSpec(spec); + if (options.source !== undefined) return await this.#provisionFromSource(options.source); const profile = await this.#profileFor(spec.model); const image = await this.#requireImage(profile, spec.osVersion, spec.imageTag); // why: Cosmetic only, and worth saying so: the prefix is a label that names the device's @@ -1443,6 +1475,45 @@ export class AndroidDriver implements Driver { return { address: "", deviceId, driverData }; } + /** + * why: ADR 0061 §4: a lease naming a baseline starts from a copy of that baseline's source, which + * holds the saved disk and its boot snapshot; the kind's seed is not touched, the spec was + * already checked against the baseline by the coordinator, and a stale source is refused, never copied. + */ + async #provisionFromSource(sourceId: string): Promise { + const row = this.#sources.readyRows().find((candidate) => candidate.sourceId === sourceId); + if (row === undefined) { + throw new DriverCrashError(`Android keeps no ready source ${sourceId}`); + } + const reason = await this.#staleReasonOf( + row, + await this.#installedImages(), + await this.#emulatorVersion(), + ); + if (reason !== undefined) throw new DriverCrashError(`source ${sourceId} is stale: ${reason}`); + const deviceId = `simlock_${this.#idGenerator.generate()}`; + const avdHome = join(this.#deviceRoot, deviceId); + await this.#copyAvd( + row.folder, + avdHome, + `copying Android source ${sourceId} to ${basename(avdHome)} failed`, + ); + const driverData: AndroidDriverData = { + avdHome, + avdName: SEED_AVD_NAME, + configHash: row.configHash, + imageIdentity: row.imageIdentity, + port: 0, + serial: "", + }; + this.#devices.set(this.#avdFolder(driverData), { + handle: undefined, + imageIdentity: row.imageIdentity, + port: undefined, + }); + return { address: "", deviceId, driverData }; + } + /** * why: A seed is the source every Android device of one kind is copied from: made once, booted * twice (the second boot starts from a disk that was powered off cleanly), saved as one boot diff --git a/src/drivers/ios/AGENTS.md b/src/drivers/ios/AGENTS.md index 21ee0ba96..efda0f63f 100644 --- a/src/drivers/ios/AGENTS.md +++ b/src/drivers/ios/AGENTS.md @@ -56,7 +56,7 @@ Boot — `makeReady`: `device.slimmed`. 4. `purpose: "recover"`: boot only, no write, no readback. -Seeds (ADR 0061) — a simulator is a copy of the kind's seed, a shut-down simulator Simlock booted once, kept in the sources set (`/sources`; under `deviceSet: default` the owned root `$SIMLOCK_HOME/devices/ios-sources`) and known by its `ios_source_data` row (`source-data.ts`), never by name. `provision(spec, {source?})`: find the ready seed of the spec key (device type + runtime), make it when none or stale (`making` row, `create`, boot, shutdown, `ready` row with its size), then `simctl --set clone simlock- `, both marks, the seed's `disabled.plist` copied. Two provisions of a kind make one seed; clones of a seed and its deletion take turns. `listSources` (ready rows with `staleReason`, plus sources-set simulators no row names as `orphan`) sweeps another process's `making` rows on its first call; a `making` row of this process older than 300 s goes at the next make of its kind. `destroySource` deletes by a row's UDID, or an orphan's under `doctor --purge-orphans`. `removeComponent` deletes the runtime's seeds first. A seed is in no `listManaged` and no `simlock simctl list`. +Seeds (ADR 0061) — a simulator is a copy of the kind's seed, a shut-down simulator Simlock booted once, kept in the sources set (`/sources`; under `deviceSet: default` the owned root `$SIMLOCK_HOME/devices/ios-sources`) and known by its `ios_source_data` row (`source-data.ts`), never by name. `provision(spec, {source?})`: find the ready seed of the spec key (device type + runtime), make it when none or stale (`making` row, `create`, boot, shutdown, `ready` row with its size), then `simctl --set clone simlock- `, both marks, the seed's `disabled.plist` copied. Every clone (a seed's copy, `saveSource`'s own clone, a `provision` with `source`) has its `data/var/db/lsd/com.apple.LaunchServices-*.csstore` deleted before its first boot, because it records each app, system apps included, under the original device's folder; a clone whose drop fails is deleted. Two provisions of a kind make one seed; clones of a seed and its deletion take turns. `listSources` (ready rows with `staleReason`, plus sources-set simulators no row names as `orphan`) sweeps another process's `making` rows on its first call; a `making` row of this process older than 300 s goes at the next make of its kind. `destroySource` deletes by a row's UDID, or an orphan's under `doctor --purge-orphans`. `removeComponent` deletes the runtime's seeds first. A seed is in no `listManaged` and no `simlock simctl list`. Reclaim — a simulator is never reclaimed: `reclaim` and `reclaimStrategy` throw, and a lease's end destroys it. Physical → `devicectl device uninstall app` for every app not enrolled → `ready` / `uninstall`. `destroy` / `destroyLegacy`: shut down, `simctl delete`, then remove the overrides folder (a failed removal is logged). `listManaged`: `simctl list -j devices` in the set, plus marks. diff --git a/src/drivers/ios/index.test.ts b/src/drivers/ios/index.test.ts index fdd2fb92b..40496feae 100644 --- a/src/drivers/ios/index.test.ts +++ b/src/drivers/ios/index.test.ts @@ -947,6 +947,27 @@ describe("IosSimctlDriver", () => { expect(clones()).toBe(2); }); + it("provision deletes the clone and throws when the LaunchServices database cannot be dropped", async () => { + const filesystem = new FaultyFilesystem(); + await plantManagedDevice(filesystem); + await plantLaunchServices(filesystem, `${deviceRoot}/${driverData.udid}`); + filesystem.failing = "rm"; + const runner = new ScriptedProcessRunner([ + { match: listInvocation, result: { code: 0, stderr: "", stdout: listFixture } }, + { + match: sourcesSimctl("clone", seedUdid, driverData.name, deviceRoot), + result: { code: 0, stderr: "", stdout: `${driverData.udid}\n` }, + }, + { match: simctl("delete", driverData.udid) }, + ]); + const driver = await createDriver(runner, new FakeClock(), filesystem, await seededDatabase()); + await driver.resolveSpec({ model: "iPhone 16", osVersion: "26.5", platform: "ios" }); + + await expect(driver.provision(spec)).rejects.toThrow("disk full"); + + expect(runner.calls.map((call) => call.args[3])).toEqual(["list", "clone", "delete"]); + }); + it.each([ { name: "a named source is copied instead of the seed", id: "planted-named", cloned: true }, { name: "an unknown source is refused", id: "nope", cloned: false }, @@ -966,6 +987,7 @@ describe("IosSimctlDriver", () => { .run(oldUdid); const filesystem = new MemoryFilesystem(); await plantManagedDevice(filesystem); + await plantLaunchServices(filesystem, `${deviceRoot}/${driverData.udid}`); const catalog = JSON.parse(listFixture) as { runtimes: { buildversion?: string; identifier: string }[]; }; @@ -988,6 +1010,10 @@ describe("IosSimctlDriver", () => { if (cloned) { await expect(provisioned).resolves.toMatchObject({ deviceId: driverData.udid }); expect(runner.calls.map((call) => call.args[3])).toEqual(["list", "list", "clone"]); + expect( + await launchServicesFiles(filesystem, `${deviceRoot}/${driverData.udid}`), + "the copy of a source loses the LaunchServices database that names the source's folder", + ).toEqual(["com.apple.LaunchServices.unregisteredtypes-501.db"]); } else { await expect(provisioned).rejects.toBeInstanceOf(DriverCrashError); expect(runner.calls.map((call) => call.args[3])).not.toContain("clone"); @@ -1408,6 +1434,7 @@ describe("IosSimctlDriver", () => { ); await filesystem.mkdirp(`${sourcesSet}/${cloneUdid}/data`); await filesystem.writeFileAtomic(`${sourcesSet}/${cloneUdid}/data/state`, "12345"); + await plantLaunchServices(filesystem, `${sourcesSet}/${cloneUdid}`); const runner = new ScriptedProcessRunner([ { match: listInvocation, result: { code: 0, stderr: "", stdout: listFixture } }, { @@ -1439,6 +1466,9 @@ describe("IosSimctlDriver", () => { expect(await filesystem.readFile(overridesPath(cloneUdid))).toBe( overridesPlist({ "a.b": true }), ); + expect(await launchServicesFiles(filesystem, `${sourcesSet}/${cloneUdid}`)).toEqual([ + "com.apple.LaunchServices.unregisteredtypes-501.db", + ]); await expect(driver.listSources()).resolves.toEqual([ { id: "device-1", @@ -1521,6 +1551,7 @@ describe("IosSimctlDriver", () => { await filesystem.mkdirp(overridesFolder(seedUdid)); await filesystem.writeFileAtomic(overridesPath(seedUdid), overridesPlist({ "a.b": true })); } + await plantLaunchServices(filesystem, `${deviceRoot}/${driverData.udid}`); filesystem.failing = failing; const runner = new ScriptedProcessRunner([ { match: listInvocation, result: { code: 0, stderr: "", stdout: listFixture } }, @@ -1547,6 +1578,10 @@ describe("IosSimctlDriver", () => { await expect(driver.provision(spec)).resolves.toMatchObject({ deviceId: driverData.udid }); + expect( + await launchServicesFiles(filesystem, `${deviceRoot}/${driverData.udid}`), + "a copy of a seed loses the LaunchServices database that names the seed's folder", + ).toEqual(["com.apple.LaunchServices.unregisteredtypes-501.db"]); const copied = failing === undefined && seedHas; expect(await filesystem.exists(overridesPath(driverData.udid))).toBe(copied); if (copied) { @@ -4846,6 +4881,24 @@ async function createSlimDriver( const overridesRoot = "/private/var/tmp"; +/** why: a LaunchServices database file and a neighbour that must stay, in the device folder `device`. */ +async function plantLaunchServices(filesystem: MemoryFilesystem, device: string): Promise { + const folder = `${device}/data/var/db/lsd`; + await filesystem.mkdirp(folder); + await filesystem.writeFileAtomic(`${folder}/com.apple.LaunchServices-33554481-v2.csstore`, "x"); + await filesystem.writeFileAtomic( + `${folder}/com.apple.LaunchServices.unregisteredtypes-501.db`, + "", + ); +} + +async function launchServicesFiles( + filesystem: MemoryFilesystem, + device: string, +): Promise { + return (await filesystem.readdir(`${device}/data/var/db/lsd`)).sort(); +} + function overridesFolder(udid: string): string { return `${overridesRoot}/com.apple.CoreSimulator.SimDevice.${udid}`; } diff --git a/src/drivers/ios/index.ts b/src/drivers/ios/index.ts index cfc0117f2..20596dfc0 100644 --- a/src/drivers/ios/index.ts +++ b/src/drivers/ios/index.ts @@ -161,6 +161,7 @@ const RECLAIM_INSTEAD = // why: ADR 0060 §2: simslim's limit for a simulator just after its first boot. const READBACK_TIMEOUT_MS = 120_000; +const LAUNCH_SERVICES_DATABASE = /^com\.apple\.LaunchServices-.*\.csstore$/; const OVERRIDES_FILE_NAME = "disabled.plist"; const DEFAULT_OVERRIDES_ROOT = "/private/var/tmp"; const OVERRIDES_FOLDER_PREFIX = "com.apple.CoreSimulator.SimDevice."; @@ -818,6 +819,7 @@ export class IosSimctlDriver implements Driver { if (cloneUdid === "") throw new DriverCrashError("simctl clone returned no device UDID"); this.#sources.setUdid(sourceId, cloneUdid); await this.#copyOverrides(data.udid, cloneUdid); + await this.#dropLaunchServicesDatabase(this.#sourcesSet, cloneUdid); const sizeBytes = await this.#sizeOf(cloneUdid); this.#sources.markReady(sourceId, sizeBytes); return { sourceId, ...(sizeBytes === undefined ? {} : { sizeBytes }) }; @@ -906,12 +908,11 @@ export class IosSimctlDriver implements Driver { try { await this.#writeMark(udid); + await this.#dropLaunchServicesDatabase(this.#deviceRoot, udid); } catch (error: unknown) { - // why: In Xcode's set an unmarked simulator is nobody's, so one left here would never - // be listed, reclaimed or deleted by Simlock again. - if (this.#rootOptions === undefined) { - await this.#invokeSimctl(["delete", udid], COMMAND_TIMEOUT_MS).catch(() => undefined); - } + // why: a clone whose mark or database drop failed is not a device Simlock hands out, and + // in Xcode's set an unmarked one would never be listed, reclaimed or deleted again. + await this.#invokeSimctl(["delete", udid], COMMAND_TIMEOUT_MS).catch(() => undefined); throw error; } await this.#copyOverrides(seed.udid, udid); @@ -1073,6 +1074,20 @@ export class IosSimctlDriver implements Driver { } } + /** + * why: `simctl clone` keeps the LaunchServices database, which records each app (system apps + * included) by its absolute path under the original device's folder; a copy then uses the + * original's data, or fails once that folder is gone. Dropped from every clone before its first + * boot, LaunchServices rebuilds it from the clone's own container folders (ADR 0061 §3). + */ + async #dropLaunchServicesDatabase(setPath: string, udid: string): Promise { + const folder = join(setPath, udid, "data", "var", "db", "lsd"); + if (!(await this.#filesystem.exists(folder))) return; + for (const entry of await this.#filesystem.readdir(folder)) { + if (LAUNCH_SERVICES_DATABASE.test(entry)) await this.#filesystem.rm(join(folder, entry)); + } + } + async #destroySeed(seed: ReadySeed): Promise { await this.#deleteSeedSimulator(seed.udid); this.#sources.deleteRow(seed.sourceId); diff --git a/src/ports/filesystem.test.ts b/src/ports/filesystem.test.ts index ac9b51703..61ea824a2 100644 --- a/src/ports/filesystem.test.ts +++ b/src/ports/filesystem.test.ts @@ -1,4 +1,4 @@ -import { stat, symlink, utimes, writeFile } from "node:fs/promises"; +import { lstat, stat, symlink, utimes, writeFile } from "node:fs/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +9,12 @@ import { ScriptedProcessRunner, } from "./index.js"; +// why: lets one test make a listed file vanish before it is sized; every other call goes to the real `lstat`. +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, lstat: vi.fn(actual.lstat) }; +}); + // why: Per process: `afterEach` deletes it, so a shared name let two overlapping runs (two suites in // one checkout, Stryker's runners in one sandbox) delete each other's files mid-test. const temporaryDirectory = `${process.cwd()}/.simlock-ports-test-${process.pid}`; @@ -319,6 +325,28 @@ describe.each(implementations)("Filesystem ownership contract: $name", ({ create }); }); +describe("NodeFilesystem.directorySize", () => { + it("counts a file that vanishes between the listing and its stat as 0 bytes, and fails on any other error", async () => { + const filesystem = new NodeFilesystem(); + const root = `${temporaryDirectory}/set`; + await filesystem.mkdirp(root); + await filesystem.writeFileAtomic(`${root}/device.plist`, "abcd"); + await filesystem.writeFileAtomic(`${root}/device.plist.sb-1`, "xy"); + const real = vi.mocked(lstat).getMockImplementation() as typeof lstat; + const failWith = (code: string) => + vi.mocked(lstat).mockImplementation((async (path: string, ...rest: never[]) => { + if (path.endsWith(".sb-1")) throw Object.assign(new Error(code), { code }); + return await real(path, ...rest); + }) as typeof lstat); + + failWith("ENOENT"); + await expect(filesystem.directorySize(root)).resolves.toBe(4); + + failWith("EACCES"); + await expect(filesystem.directorySize(root)).rejects.toMatchObject({ code: "EACCES" }); + }); +}); + describe("MemoryFilesystem", () => { it("resolves symlinked path components, not just the last one", async () => { const filesystem = new MemoryFilesystem(); diff --git a/src/ports/filesystem.ts b/src/ports/filesystem.ts index c18daba09..b44167389 100644 --- a/src/ports/filesystem.ts +++ b/src/ports/filesystem.ts @@ -336,12 +336,25 @@ export class NodeFilesystem implements Filesystem { }; } + /** + * why: a file listed a moment ago may be gone (simctl's atomic `device.plist.sb-*` write), and a + * size that fails on it leaves a saved source with no size; a vanished file counts for 0. + */ + async #sizeOfFile(path: string): Promise { + try { + return (await lstat(path)).size; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0; + throw error; + } + } + async directorySize(path: string): Promise { let total = 0; for (const entry of await readdir(path, { withFileTypes: true })) { const child = join(path, entry.name); if (entry.isDirectory()) total += await this.directorySize(child); - else if (entry.isFile()) total += (await lstat(child)).size; + else if (entry.isFile()) total += await this.#sizeOfFile(child); } return total; } diff --git a/ui/AGENTS.md b/ui/AGENTS.md index cc85c8c79..a16e17ae4 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -61,7 +61,7 @@ The Inbox is the daemon's: `GET /v1/inbox` (`inbox.get`, rules in `src/contract/ A page reads a route only while it is on screen (`useRouteQuery`, `state/live.tsx`). `state/queries.ts` holds the TanStack `QueryClient` (the dependency rules keep it out of `data/` and `model/`) and the keys and times of ADR 0044: -`['workers']` every 15 s, `['leases']`, `['lease', id]` and `['inbox']` 10 s, `['lease-requests']` 5 s, +`['workers']` and `['baselines']` every 15 s, `['leases']`, `['lease', id]` and `['inbox']` 10 s, `['lease-requests']` 5 s, `['stats', range]` 60 s, `['tokens']` never; a read stays fresh 2 s (stats 30 s, tokens 5 min); data no page uses is dropped after 60 s. A key polls only while a page uses it, the tab is shown and the connection is up; a hidden tab or a lost connection reads nothing, and coming back reads each key @@ -105,17 +105,18 @@ not (its highlight is an index it resets when the items change), so the listbox ## Pages -| Path | Screen | Layout | -| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `/` | Overview | range chips in the toolbar, five stat tiles, Needs you, capacity, two charts, workers table | -| `/fleet[/[/]]` | Fleet | table; a worker opens as its own page (load, capacity, leases, tabs Devices, Warm pool, Host, Events in `?tab=`); a device opens in a `Drawer` (Details, Screen; `?drawer=screen` holds the tab) | -| `/leases[/]` | Leases | toolbar (filters, search) + table; the lease opens in a `Drawer` (Details, Events) | -| `/queue[/]` | Queue | Wait p95 tile + table; a request opens in a `Drawer` | -| `/inbox[/]` | Inbox | toolbar (group chips) + a panel per group; an item opens in a `Drawer` (what happened, facts, events) | -| `/activity[/]` | Activity | chart, toolbar (type chips, search) + event feed that scrolls back through the history; an event opens in a `Drawer` with the payload | -| `/usage` | Usage | range chips in the toolbar, stat tiles, the Device slots in use chart, panels of tables (workers, requesters page in the URL) | - -The sidebar groups the pages: Overview and Inbox, then Operate (Fleet, Leases, Queue), then +| Path | Screen | Layout | +| ---------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/` | Overview | range chips in the toolbar, five stat tiles, Needs you, capacity, two charts, workers table | +| `/fleet[/[/]]` | Fleet | table; a worker opens as its own page (load, capacity, leases, tabs Devices, Warm pool, Host, Events in `?tab=`); a device opens in a `Drawer` (Details, Screen; `?drawer=screen` holds the tab) | +| `/leases[/]` | Leases | toolbar (filters, search) + table; the lease opens in a `Drawer` (Details, Events) | +| `/queue[/]` | Queue | Wait p95 tile + table; a request opens in a `Drawer` | +| `/baselines` | Baselines | one table, a row per baseline (a Worker column on a gateway); read-only | +| `/inbox[/]` | Inbox | toolbar (group chips) + a panel per group; an item opens in a `Drawer` (what happened, facts, events) | +| `/activity[/]` | Activity | chart, toolbar (type chips, search) + event feed that scrolls back through the history; an event opens in a `Drawer` with the payload | +| `/usage` | Usage | range chips in the toolbar, stat tiles, the Device slots in use chart, panels of tables (workers, requesters page in the URL) | + +The sidebar groups the pages: Overview and Inbox, then Operate (Fleet, Leases, Queue, Baselines), then Observe (Activity, Usage); a route's `group` in `routes.ts` puts it in one. Leases, Queue, Inbox and Activity show the list alone, and the chosen item opens in a drawer over it diff --git a/ui/src/app/routes.test.ts b/ui/src/app/routes.test.ts index 59f6da8de..bad3b880e 100644 --- a/ui/src/app/routes.test.ts +++ b/ui/src/app/routes.test.ts @@ -23,6 +23,7 @@ describe("the console's routes", () => { "/fleet", "/leases", "/queue", + "/baselines", "/activity", "/usage", ]); diff --git a/ui/src/app/routes.ts b/ui/src/app/routes.ts index 052b11262..53e7a90bf 100644 --- a/ui/src/app/routes.ts +++ b/ui/src/app/routes.ts @@ -6,6 +6,7 @@ import { ActivityScreen } from "../screens/activity"; import { LeasesScreen } from "../screens/leases"; import { OverviewScreen } from "../screens/overview"; import { UsageScreen } from "../screens/usage"; +import { BaselinesScreen } from "../screens/baselines"; import { QueueScreen } from "../screens/queue"; import { FleetScreen } from "../screens/fleet"; @@ -25,6 +26,13 @@ export const ROUTES: readonly Route[] = [ { Screen: FleetScreen, group: "Operate", icon: "workers", label: "Fleet", path: "/fleet" }, { Screen: LeasesScreen, group: "Operate", icon: "leases", label: "Leases", path: "/leases" }, { Screen: QueueScreen, group: "Operate", icon: "waiting", label: "Queue", path: "/queue" }, + { + Screen: BaselinesScreen, + group: "Operate", + icon: "baselines", + label: "Baselines", + path: "/baselines", + }, { Screen: ActivityScreen, group: "Observe", diff --git a/ui/src/ds/icon.tsx b/ui/src/ds/icon.tsx index 2ff484a93..82713cf4c 100644 --- a/ui/src/ds/icon.tsx +++ b/ui/src/ds/icon.tsx @@ -3,6 +3,7 @@ const PATHS = { workers: "M2 3h12v4H2zM2 9h12v4H2zM5 5h.01M5 11h.01", leases: "M10 2a4 4 0 1 1-3.5 6L2 12.5V14h2v-1.5h1.5V11h1.5l1-1", waiting: "M2 4h12M2 8h9M2 12h6", + baselines: "M3 2.5h10v11H3zM6 6h4M6 9h4M6 12h2", inbox: "M2 9l2-6h8l2 6v4H2zM2 9h4l1 2h2l1-2h4", events: "M1.5 8h3l2-5 3 10 2-5h3", usage: "M2 14V2M2 14h12M5 11V8M8 11V5M11 11V7", diff --git a/ui/src/model/baselines.test.ts b/ui/src/model/baselines.test.ts new file mode 100644 index 000000000..9568e23b0 --- /dev/null +++ b/ui/src/model/baselines.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import type { BaselineEntry } from "../../../src/contract/index"; +import { baselineRows } from "./baselines"; +import type { WorkerView } from "./workers"; + +const NOW = Date.parse("2026-11-03T12:00:00Z"); + +function entry(overrides: Partial = {}): BaselineEntry { + return { + mode: "full", + model: "Pixel 9", + name: "checkout", + osVersion: "36", + platform: "android", + savedAt: "2026-11-03T10:00:00Z", + version: 2, + ...overrides, + }; +} + +function worker(id: string, label?: string): WorkerView { + return { + catalog: [], + connection: "connected", + devices: [], + drained: false, + id, + lastSeenAt: NOW, + leases: [], + ...(label === undefined ? {} : { label }), + }; +} + +describe("baselineRows", () => { + it("shows each entry's columns in the console's formats, ordered by name then worker", () => { + const rows = baselineRows( + [ + entry({ + imageTag: "google_apis", + note: "app 4.2, test user", + sizeBytes: 4.5 * 2 ** 30, + workerId: "w2", + }), + entry({ + name: "login", + platform: "ios", + model: "iPhone 17", + osVersion: "27.0", + mode: "slim", + version: 1, + workerId: "w1", + }), + entry({ name: "checkout", workerId: "w1", sizeBytes: 300 * 2 ** 20 }), + entry({ name: "alpha", workerId: "w9" }), + ], + [worker("w1", "mac-mini-2"), worker("w2", "mac-studio-1")], + NOW, + ); + + expect(rows.map((row) => [row.name, row.worker])).toEqual([ + ["alpha", "w9"], + ["checkout", "mac-mini-2"], + ["checkout", "mac-studio-1"], + ["login", "mac-mini-2"], + ]); + expect(rows[2]).toMatchObject({ + mode: "full", + model: "Pixel 9 / google_apis", + note: "app 4.2, test user", + os: "36", + platform: "Android", + saved: "2 h ago", + savedAt: "2026-11-03T10:00:00Z", + size: "4.5 GiB", + version: 2, + }); + expect(rows[1]?.size).toBe("300 MiB"); + expect(rows[3]).toMatchObject({ model: "iPhone 17", platform: "iOS", note: "" }); + }); + + it("leaves the worker out when no entry carries one, and shows an entry with no size as a dash", () => { + const [row] = baselineRows([entry()], [], NOW); + expect(row?.worker).toBeUndefined(); + expect(row?.size).toBe("—"); + }); + + it("marks an entry with a stale reason stale, word for word", () => { + const reason = "runtime com.apple.CoreSimulator.SimRuntime.iOS-27-0 is not installed"; + const rows = baselineRows([entry({ staleReason: reason }), entry({ name: "ok" })], [], NOW); + expect(rows.map((row) => row.stale)).toEqual([reason, undefined]); + }); +}); diff --git a/ui/src/model/baselines.ts b/ui/src/model/baselines.ts new file mode 100644 index 000000000..57bf95b3f --- /dev/null +++ b/ui/src/model/baselines.ts @@ -0,0 +1,60 @@ +import type { BaselineEntry } from "../../../src/contract/index"; +import { platformName } from "../../../src/contract/inbox"; +import { formatBytes } from "./load"; +import { formatDuration } from "./time"; +import { type WorkerView, workerName } from "./workers"; + +export interface BaselineList { + readonly baselines: readonly BaselineEntry[]; +} + +export interface BaselineRow { + readonly key: string; + readonly worker?: string; + readonly stale?: string; + readonly name: string; + readonly version: number; + readonly platform: string; + readonly model: string; + readonly os: string; + readonly mode: string; + readonly note: string; + readonly size: string; + readonly saved: string; + readonly savedAt: string; +} + +export function baselineRows( + entries: readonly BaselineEntry[], + workers: readonly WorkerView[], + now: number, +): readonly BaselineRow[] { + const rows = entries.map((entry) => { + const worker = workerOf(entry, workers); + return { + key: `${worker ?? ""}/${entry.name}`, + mode: entry.mode, + model: entry.imageTag === undefined ? entry.model : `${entry.model} / ${entry.imageTag}`, + name: entry.name, + note: entry.note ?? "", + os: entry.osVersion, + platform: platformName(entry.platform), + saved: `${formatDuration(now - Date.parse(entry.savedAt))} ago`, + savedAt: entry.savedAt, + size: entry.sizeBytes === undefined ? "—" : formatBytes(entry.sizeBytes), + version: entry.version, + ...(worker === undefined ? {} : { worker }), + ...(entry.staleReason === undefined ? {} : { stale: entry.staleReason }), + }; + }); + return rows.sort( + (a, b) => a.name.localeCompare(b.name) || (a.worker ?? "").localeCompare(b.worker ?? ""), + ); +} + +// why: a gateway names the worker by id; its label is shown when the fleet view has the worker, and the id stands when it does not. +function workerOf(entry: BaselineEntry, workers: readonly WorkerView[]): string | undefined { + if (entry.workerId === undefined) return undefined; + const known = workers.find((worker) => worker.id === entry.workerId); + return known === undefined ? entry.workerId : workerName(known); +} diff --git a/ui/src/model/stale-keys.ts b/ui/src/model/stale-keys.ts index 9f52b99f0..5f427d8b4 100644 --- a/ui/src/model/stale-keys.ts +++ b/ui/src/model/stale-keys.ts @@ -5,6 +5,7 @@ export type StaleKey = readonly string[]; const LEASES: readonly StaleKey[] = [["leases"], ["workers"], ["lease"], ["inbox"]]; const REQUESTS: readonly StaleKey[] = [["lease-requests"], ["inbox"]]; const WORKERS: readonly StaleKey[] = [["workers"], ["inbox"]]; +const BASELINES: readonly StaleKey[] = [["baselines"]]; const NONE: readonly StaleKey[] = []; function hasLeaseId(payload: unknown): boolean { @@ -28,6 +29,10 @@ export function staleKeys(name: string, payload: unknown): readonly StaleKey[] { return REQUESTS; case "capacity.changed": return WORKERS; + case "baseline.saved": + case "baseline.removed": + case "baseline.stale": + return BASELINES; default: } if (name.startsWith("request.")) return REQUESTS; diff --git a/ui/src/model/worker-page.ts b/ui/src/model/worker-page.ts index 8ca8a67de..53ea838fc 100644 --- a/ui/src/model/worker-page.ts +++ b/ui/src/model/worker-page.ts @@ -302,6 +302,7 @@ export interface DeviceRow { readonly physical: boolean; readonly mode?: string; readonly imageTag?: string; + readonly baseline?: string; } // why: a worker names a lease by its own id, but a gateway lists it, and opens its page, as @@ -335,6 +336,10 @@ function deviceRow( ...leaseFacts(device, worker, now, tokens, leaseIds), ...defined({ udid: device.physical ? device.driverDeviceId : undefined, + baseline: + device.spec.baseline === undefined + ? undefined + : `${device.spec.baseline.name} v${String(device.spec.baseline.version)}`, imageTag: virtual ? device.spec.imageTag : undefined, mode: virtual && !device.servesDefaultMode ? device.mode : undefined, since: entered === undefined ? undefined : formatDuration(now - entered), diff --git a/ui/src/screens/baselines/index.tsx b/ui/src/screens/baselines/index.tsx new file mode 100644 index 000000000..c7fb9538b --- /dev/null +++ b/ui/src/screens/baselines/index.tsx @@ -0,0 +1,50 @@ +import { type Column, DataTable, Loaded, Page, Status } from "../../ds"; +import { type BaselineRow, useBaselines } from "../../state/baselines"; + +const WORKER: Column = { cell: (row) => row.worker, header: "Worker" }; + +const COLUMNS: readonly Column[] = [ + { cell: (row) => row.name, header: "Name", mono: true }, + { cell: (row) => row.version, header: "Version", mono: true, numeric: true }, + { cell: (row) => row.platform, header: "Platform" }, + { cell: (row) => row.model, header: "Model" }, + { cell: (row) => row.os, header: "OS", mono: true }, + { cell: (row) => row.mode, header: "Mode" }, + { cell: (row) => row.note, header: "Note" }, + { cell: (row) => row.size, header: "Size", mono: true, numeric: true }, + { + cell: (row) => {row.saved}, + header: "Saved", + mono: true, + }, + { + cell: (row) => + row.stale === undefined ? null : ( + <> + stale + {` · ${row.stale}`} + + ), + header: "Stale", + }, +]; + +export function BaselinesScreen() { + const view = useBaselines(); + return ( + + + {(page) => ( + row.key} + empty="No baselines." + paging={page.paging} + /> + )} + + + ); +} diff --git a/ui/src/screens/fleet/device-drawer.tsx b/ui/src/screens/fleet/device-drawer.tsx index 66495c899..0a51022f7 100644 --- a/ui/src/screens/fleet/device-drawer.tsx +++ b/ui/src/screens/fleet/device-drawer.tsx @@ -99,6 +99,9 @@ function FactList({ label: "State", value: {device.status.word}, }, + ...(device.baseline === undefined + ? [] + : [{ label: "Baseline", value: {device.baseline} }]), ...(device.holder === undefined ? [] : [{ label: "Holder", value: device.holder.name }]), ...(device.lease === undefined ? [] diff --git a/ui/src/state/baselines.ts b/ui/src/state/baselines.ts new file mode 100644 index 000000000..f23ef5800 --- /dev/null +++ b/ui/src/state/baselines.ts @@ -0,0 +1,29 @@ +import type { LoadView } from "../ds/loaded"; +import type { Paging } from "../ds/pager"; +import { type BaselineList, type BaselineRow, baselineRows } from "../model/baselines"; +import { TABLE_SIZES } from "../model/paging"; +import type { WorkerList } from "../model/workers"; +import { useDaemonMode, useNow, useRouteQuery } from "./live"; +import { usePaging } from "./paging"; +import { loadView, mapView } from "./resource"; + +export type { BaselineRow } from "../model/baselines"; + +export interface BaselinesPage { + readonly rows: readonly BaselineRow[]; + readonly paging: Paging; + readonly gateway: boolean; +} + +export function useBaselines(): LoadView { + const list = loadView(useRouteQuery("/v1/baselines")); + const workers = useRouteQuery("/v1/workers").data?.workers ?? []; + const now = useNow(); + const gateway = useDaemonMode() === "gateway"; + const rows = mapView(list, (all) => baselineRows(all.baselines, workers, now.server)); + const paging = usePaging({ + sizes: TABLE_SIZES, + total: rows.kind === "ready" ? rows.data.length : undefined, + }); + return mapView(rows, (all) => ({ gateway, paging, rows: all })); +} diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index 8a910f35a..44c2a0294 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -162,6 +162,7 @@ describe("events mark keys stale", () => { "/v1/inbox", "/v1/stats?since=1h", "/v1/tokens", + "/v1/baselines", ] as const; // why: each row is an event, its payload, and exactly the routes it marks. const rows: readonly (readonly [string, Record, readonly string[]])[] = [ @@ -183,6 +184,9 @@ describe("events mark keys stale", () => { ["capacity.changed", {}, ["/v1/workers", "/v1/inbox"]], ["component.installed", {}, ["/v1/workers", "/v1/inbox"]], ["daemon.update-started", {}, ["/v1/workers", "/v1/inbox"]], + ["baseline.saved", {}, ["/v1/baselines"]], + ["baseline.removed", {}, ["/v1/baselines"]], + ["baseline.stale", {}, ["/v1/baselines"]], ["disk.pressure-detected", {}, []], ["lease.something-new", {}, []], ]; diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index aaaf52493..0f91b8764 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -38,6 +38,7 @@ interface Times { } const TIMES: Readonly> = { + baselines: { freshMs: FRESH_MS, pollMs: 15_000 }, inbox: { freshMs: FRESH_MS, pollMs: 10_000 }, lease: { freshMs: FRESH_MS, pollMs: 10_000 }, "lease-requests": { freshMs: FRESH_MS, pollMs: 5_000 }, diff --git a/website/src/content/docs/docs/gateway/fleet-console.mdx b/website/src/content/docs/docs/gateway/fleet-console.mdx index 48fbdab4e..c00add7c7 100644 --- a/website/src/content/docs/docs/gateway/fleet-console.mdx +++ b/website/src/content/docs/docs/gateway/fleet-console.mdx @@ -55,6 +55,10 @@ A disconnected worker stays in the list for 24 hours by default (`gateway.discon **Leases** lists the leases on every worker, both those made through the gateway and those agents on a worker machine took directly from it. The lease's **Worker** fact names and links the worker that holds it. A lease made through the gateway has an id made of the worker's id, a dot, and the worker's own lease id, unless the agent chose the id. +## Baselines + +**Baselines** lists every worker's baselines. On a gateway it adds a **Worker** column that names the worker holding each one. + ## Why a request waits On **Waiting**, a request's **Queue** says where it waits: `the gateway's queue` while the gateway looks for a worker. Under it, the console names each worker and why that worker did not take the request: diff --git a/website/src/content/docs/docs/worker/baselines.mdx b/website/src/content/docs/docs/worker/baselines.mdx index 048df6cfe..4fc4d0cde 100644 --- a/website/src/content/docs/docs/worker/baselines.mdx +++ b/website/src/content/docs/docs/worker/baselines.mdx @@ -1,35 +1,65 @@ --- title: Baselines -description: Save a prepared simulator or emulator under a name, see which baselines a worker holds and whether they are stale, and remove one. +description: Prepare a simulator or emulator once, save it under a name, and let agents lease copies of it instead of a factory-fresh device. --- import { Aside } from '@astrojs/starlight/components'; -A baseline is a simulator or emulator you prepared by hand and saved under a name, such as `checkout`: the app installed, a test user signed in. `simlock baseline save` keeps a copy of it on the worker; an agent can start from that copy instead of a factory-fresh device: `simlock lease --baseline checkout` (see [Start from a baseline](/simlock/docs/using-devices/choosing-a-device#start-from-a-baseline)). +A baseline is a simulator or emulator you set up once and saved under a name, for example `checkout`, with your app installed and a test user signed in. An agent that leases with `--baseline checkout` gets its own copy of that device, so it can start testing without installing or signing in first (see [Start from a baseline](/simlock/docs/using-devices/choosing-a-device#start-from-a-baseline)). -`baseline save` and `baseline remove` need an admin session; an agent session gets `FORBIDDEN`. `baseline list` works from any session. Through a gateway they act on its workers; see [Baselines through a gateway](/simlock/docs/gateway/managing-workers#baselines-through-a-gateway). +Before you start: + +- Saving and removing a baseline needs the admin role. Any session can list baselines; an agent session that tries to save or remove gets `FORBIDDEN`. +- Only simulators and emulators can be saved. A lease on a physical device is refused with `BAD_REQUEST`. +- A baseline lives on the worker that saved it. Through a gateway, see [Baselines through a gateway](/simlock/docs/gateway/managing-workers#baselines-through-a-gateway). ## Save a baseline -Lease a simulator or emulator, prepare it, then save it by its lease id. +1. Lease the device you want to prepare. `--detach` keeps the lease after the command exits, so you can work on the device in other commands: -```sh -simlock baseline save checkout --lease L1 --note "app 4.2, test user" -``` + ```sh + simlock lease --platform android --device "Pixel 9" --detach + ``` -```text -Saved baseline checkout v1: android Pixel 9, Android 36 (google_apis), full, 4.8 GB. Lease L1 ended. -``` + Note the lease id (`lease.id`) and the device's serial or UDID (`device.address`) in the grant. + +2. Prepare the device: install the app, sign in, change the settings your tests need. See [Install and launch an app](/simlock/docs/using-devices/working-with-a-device#install-and-launch-an-app). -Saving ends the lease and deletes the device: the baseline is the only thing left. Simlock shuts the device down cleanly first, so what you prepared is kept. A lease on a physical device cannot be saved. + ```sh + simlock adb -s emulator-5556 install app/build/outputs/apk/debug/app-debug.apk + ``` -The name is 1 to 64 characters of `a-z`, `0-9`, `.`, `_` and `-`, starting with a letter or digit. The note is up to 200 characters on one line. A bad name or note is refused before the lease ends. +3. Save it under a name: -Saving under a name that exists makes the next version. Running leases keep their device, idle copies of the older version are deleted, and the next lease, also a request that was already waiting, gets the new version. The older version's saved copy is deleted when its last device is gone. Two saves of one name at once take turns: the second becomes the next version. + -If Simlock cannot make the copy, the lease has still ended, the device is quarantined for the doctor to look at, and the answer is `BASELINE_SAVE_FAILED` with the reason. No baseline is listed. + ```sh + simlock baseline save checkout --lease lse_3c9a7e10-52b4-4f6d-8e21-b07d4a9c6f35 --note "app 4.2, test user" + ``` + + ```text + Saved baseline checkout v1: android Pixel 9, Android 36 (google_apis), full, 4.8 GB. Lease lse_3c9a7e10-52b4-4f6d-8e21-b07d4a9c6f35 ended. + ``` + +Simlock shuts the device down cleanly before copying it, so everything you prepared is in the baseline. + +The name is 1 to 64 characters of `a-z`, `0-9`, `.`, `_` and `-`, and starts with a letter or digit. The note is optional: one line, up to 200 characters. A bad name or note is refused before anything happens to the lease. + + -## List baselines +## Update a baseline + +Prepare a new device and save it under the same name. This makes the next version, for example `checkout v2`. + +- Agents already using a v1 copy keep it until their lease ends. +- The next lease that names `checkout` gets v2, including a lease that was already waiting. +- Idle v1 copies are deleted, and v1 itself is deleted once its last copy is gone. You cannot go back to an older version. + +## See which baselines a worker has ```sh simlock baseline list @@ -42,7 +72,7 @@ login 1 ios iPhone 17 27.0 slim - 3.1 GB 2026-11 stale: runtime com.apple.CoreSimulator.SimRuntime.iOS-27-0 is not installed ``` -A baseline is stale when the runtime, system image or emulator it was made with changed after the save, for example when you removed the Android 36 image. The reason is on its own line under the row. It is fresh again when that runtime or image comes back. The `baseline.stale` event fires once when a baseline becomes stale. +A baseline is stale when the iOS runtime, Android system image or emulator it was saved with has changed or been removed. The reason is on the line under it. A lease that names a stale baseline fails with `BASELINE_STALE`. Install that runtime or image again and the baseline works again; or save a new version from a device on the runtime you have now. ## Remove a baseline @@ -50,10 +80,25 @@ A baseline is stale when the runtime, system image or emulator it was made with simlock baseline remove login ``` -Removing deletes every version of the name, its copy on disk, and the idle devices made from it. Simlock refuses with `BASELINE_IN_USE`, naming the lease, when a lease holds a device made from it. `simlock nuke --delete-devices` removes every baseline. +```text +Remove baseline login v1? [y/N] y +Removed baseline login v1. +``` + +This deletes every version of `login` and the idle copies made from it. If an agent is still using a copy, Simlock refuses with `BASELINE_IN_USE` and names the lease; remove it after that lease ends. Pass `--yes` to skip the question, for example in a script. - +`simlock nuke --delete-devices` removes every baseline on the worker. + +## When something goes wrong + +| What you see | What it means | What to do | +| --- | --- | --- | +| `BASELINE_SAVE_FAILED` | The lease has ended, but Simlock could not copy the device. The message says why. No baseline was saved, and the device is kept aside for `simlock doctor`. | Lease a new device, prepare it again and save again. | +| `UNKNOWN_BASELINE` | No baseline on this worker has that name. | Check the name with `simlock baseline list`. | +| `BASELINE_STALE` | The runtime or image the baseline needs is gone. | Install it again, or save a new version. | +| `BASELINE_IN_USE` | `remove` was refused because a lease uses a copy. | Wait for that lease to end, then remove. | +| `FORBIDDEN` | The session does not have the admin role. | Run the command as an admin. | + +`simlock doctor` lists each baseline's files on disk under `sources`. Files that belong to no baseline are reported as orphans; only `simlock doctor --purge-orphans` deletes them. -See [Events](/simlock/docs/reference/events) for `baseline.saved`, `baseline.removed` and `baseline.stale`, and [CLI reference](/simlock/docs/reference/cli#simlock-baseline) for every flag. +See the [CLI reference](/simlock/docs/reference/cli#simlock-baseline) for every flag, and [Events](/simlock/docs/reference/events) for `baseline.saved`, `baseline.removed` and `baseline.stale`. diff --git a/website/src/content/docs/docs/worker/console.mdx b/website/src/content/docs/docs/worker/console.mdx index 816439706..fe1569756 100644 --- a/website/src/content/docs/docs/worker/console.mdx +++ b/website/src/content/docs/docs/worker/console.mdx @@ -72,7 +72,7 @@ To sign people in through a sign-in proxy instead of tokens, see [Access control ## Find your way around -The sidebar leads to every page: **Overview**, **Workers**, **Leases**, **Waiting**, **Attention**, **Events** and **Usage**. **Attention** shows how many items need you, on every page. At the bottom of the sidebar you see the connection, who you are signed in as, and **Sign out**. On a gateway, when the gateway itself is behind the latest release, a line above the connection says `simlock 0.3.0 · 0.4.0 available`. It is hidden while the sidebar is collapsed, and the console checks again every 10 minutes. +The sidebar leads to every page: **Overview**, **Workers**, **Leases**, **Waiting**, **Baselines**, **Attention**, **Events** and **Usage**. **Attention** shows how many items need you, on every page. At the bottom of the sidebar you see the connection, who you are signed in as, and **Sign out**. On a gateway, when the gateway itself is behind the latest release, a line above the connection says `simlock 0.3.0 · 0.4.0 available`. It is hidden while the sidebar is collapsed, and the console checks again every 10 minutes. Every page but the overview is a list on the left and the item you chose on the right. Lists show 25 rows at a time, with a pager for 50 or 100. @@ -119,7 +119,7 @@ Select the worker to see its CPU, memory and disk, each with a bar, how many lea Select a device on the worker to open its drawer, which has two tabs: -- **Details**: the model, runtime, state, holder, lease and UDID. The drawer opens here. +- **Details**: the model, runtime, state, the baseline and version the device was copied from (a device made from a baseline only), holder, lease and UDID. The drawer opens here. - **Screen**: the device's live picture, whether or not it is leased. The picture starts when you open the tab and stops when you leave it or close the drawer. The **Stream details** button (ⓘ) shows the stream's state, picture size, frames, rate, bandwidth and dropped frames. A device that is not running says why instead: for example "The device is shut down. Its screen shows while it runs." The picture starts by itself once the device is `ready` again. Where screen streaming is off for the worker, the tab says "Streaming is off on" and the worker's name. See [Watching the screen](/simlock/docs/using-devices/watching-the-screen). The address of the Screen tab ends in `?drawer=screen`, so a pasted link opens it. On a gateway the drawer works the same, for a device on any connected worker. @@ -177,6 +177,12 @@ Select a request to see when it was made, its place, its stage and, for a `queue A request leaves the list as soon as it gets its device, fails or is cancelled. To change how many devices the worker runs, see [Capacity and the warm pool](/simlock/docs/worker/capacity). +## Baselines + +**Baselines** lists every baseline the worker holds, one row each: its name, version, platform, model (with the image tag when it has one), OS version, mode, note, size, how long ago it was saved (hover for the exact time) and whether it is stale. A stale baseline shows `stale` and its reason, for example `stale · runtime com.apple.CoreSimulator.SimRuntime.iOS-27-0 is not installed`. Rows are ordered by name, and a baseline saved, removed or gone stale shows within about a second, with no reload. With none, the page says `No baselines.` + +The page only shows. Save and remove baselines from the command line with `simlock baseline save` and `simlock baseline remove`; see [Baselines](/simlock/docs/worker/baselines). + ## Attention **Attention** lists everything on the worker that needs you. Each row is the condition and the worker or device it is about. An item leaves the list as soon as the daemon reports that it cleared, and its page then says "Resolved." The console shows these items; it does not fix them.