diff --git a/docs/internal/EVENTS.md b/docs/internal/EVENTS.md index f89b96552..c583cfc36 100644 --- a/docs/internal/EVENTS.md +++ b/docs/internal/EVENTS.md @@ -150,7 +150,7 @@ left behind (#79), each when there is one. | `disk.pressure-detected` | free bytes, threshold | free disk crossed under the configured threshold (edge-triggered: once per crossing, not once per tick while it persists) | CleanupReaper | implemented | | `cleanup.executed` | rule name, action, target, reason | cleanup executor committed a proposed action | CleanupExecutor | implemented | | `doctor.reconciled` | drift findings | daemon reconciliation completed | Doctor | implemented | -| `driver.root-rejected` | platform, root path, reason (not-absolute/missing-marker/invalid-marker/wrong-instance/symlink/wrong-owner/wrong-permissions/non-empty-unowned-root/unreadable) | a driver's device root failed ownership validation at startup, so that platform's driver did not start | DaemonServer | implemented | +| `driver.root-rejected` | platform, root path, reason (not-absolute/missing-marker/invalid-marker/wrong-instance/symlink/wrong-owner/wrong-permissions/non-empty-unowned-root/unreadable/invalid-device-set/device-set-changed) | a driver's device root failed ownership validation at startup, or (iOS) `drivers.ios.deviceSet` was not `simlock` or `default`, conflicted with `drivers.ios.deviceRoot`, or was switched with simulators left in the set being given up (ADR 0048; `root` is then the configured value or the set holding them), so that platform's driver did not start | DaemonServer | implemented | | `driver.adb-server-rejected` | port, reason (occupied/start-failed/invalid-port) | Simlock's own adb server could not be established — the port was occupied by a server it does not own, the server it started never began listening, or the configured port is not usable — so the Android driver did not start | DaemonServer | implemented | ## Fleet (gateway mode) diff --git a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md index 966e7ec28..2f066e87c 100644 --- a/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md +++ b/docs/internal/adr/0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md @@ -1,6 +1,6 @@ # 0048. An iOS worker may keep its simulators in Xcode's device set -- **Status:** Accepted — not yet implemented +- **Status:** Accepted - **Date:** 2026-10-23 - **Issue:** [#962](https://github.com/callstackincubator/simlock/issues/962), from request [#946](https://github.com/callstackincubator/simlock/issues/946) - **Narrows:** ADR 0001 §1, §2, §3, §5, §6 and §7, for iOS simulators under `drivers.ios.deviceSet: "default"`. Android, physical devices and the `simlock` setting keep ADR 0001 unchanged. diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md index 8405702bc..59df76343 100644 --- a/docs/internal/adr/README.md +++ b/docs/internal/adr/README.md @@ -86,4 +86,4 @@ the status is stale. | [0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Accepted — not yet implemented | | [0046](0046-a-standalone-worker-serves-agent-device-by-default-with-a-private-profile.md) | A standalone worker serves agent-device by default and hands local agents a private profile | Accepted — not yet implemented; narrows 0023 §1, §3–4, 0030 §2, 0039 and 0043 §4 | | [0047](0047-device-exec-fills-in-the-leases-device.md) | `device.exec` fills in the lease's device, and refuses another | Accepted — not yet implemented; extends 0005 §19a; narrows 0003 §11 for one MCP tool | -| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Accepted — not yet implemented; narrows 0001 §1, §2, §3, §5, §6 and §7 for iOS under `deviceSet: "default"` | +| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Accepted; narrows 0001 §1, §2, §3, §5, §6 and §7 for iOS under `deviceSet: "default"` | diff --git a/docs/internal/agent-rules/safety.md b/docs/internal/agent-rules/safety.md index 7e76a7aa0..194104bd1 100644 --- a/docs/internal/agent-rules/safety.md +++ b/docs/internal/agent-rules/safety.md @@ -109,7 +109,8 @@ never enforce them only inside an individual rule or driver. `deviceSet: "default"` there is no root to validate; the driver instead refuses to start while devices remain in the other setting's location (ADR 0048 §3). - Never fall back to the default device location, and never adopt or mark a + After a failed root validation, never fall back to the default device + location, and never adopt or mark a root Simlock did not create empty itself. The same applies to Simlock's adb server port: if it is occupied, the Android driver fails rather than attaching to whatever server is already listening there. Physical Android diff --git a/docs/internal/flows/device-exec.md b/docs/internal/flows/device-exec.md index 94d9985cb..e032c7bbe 100644 --- a/docs/internal/flows/device-exec.md +++ b/docs/internal/flows/device-exec.md @@ -47,7 +47,7 @@ Runs the command on the machine that owns the device and streams its output back | Tool | Device | Command | Refused | |---|---|---|---| -| `simctl` | iOS simulator | `xcrun simctl --set `; the lease's UDID goes first among the operands of a device verb (`src/drivers/ios/simctl-device.ts`) | another UDID or `booted` on a device verb, `simctl` on a non-iOS lease, `create`, `erase`, `delete`, `shutdown all`, `runtime delete`, any flag before the subcommand, a physical device | +| `simctl` | iOS simulator | `xcrun simctl --set ` (no `--set` under `drivers.ios.deviceSet: default`); the lease's UDID goes first among the operands of a device verb (`src/drivers/ios/simctl-device.ts`) | another UDID or `booted` on a device verb, `simctl` on a non-iOS lease, `create`, `erase`, `delete`, `shutdown all`, `runtime delete`, any flag before the subcommand, a physical device | | `devicectl` | iOS physical | `xcrun devicectl --device `; `--device` added unless the args name one; a `--device` / `-d` that is not the lease's UDID is refused | a command not on the allowed list (`src/drivers/ios/devicectl-exec.ts`), an option off it, host-path options (`--json-output`, `--log-output`, `--destination`, `--file`, `--filter`), a simulator | | `adb` | Android emulator | `adb -P -s `, `ANDROID_ADB_SERVER_PORT` set; no `-s` for a no-device subcommand (`devices`, `version`, `forward --list`, …) or an empty address | `-s` other than the lease's serial, `-t` whatever it names, `-e`, `-d`, `adb` on a non-Android lease, bare `adb shell`, `kill-server`, `emu kill`, `emu avd stop`, `emu avd snapshot delete`, globals other than `-s`/`-t`/`-d`/`-e` | | `adb` | Android physical | `adb -P -s `; strips `ANDROID_ADB_SERVER_ADDRESS`, `ADB_SERVER_SOCKET`, `ANDROID_SERIAL` | bare `adb shell`, any leading flag, server and transport verbs (`kill-server`, `connect`, `tcpip`, `usb`, …), `reboot bootloader`/`sideload`, a closed default-server gate | diff --git a/e2e/slow-agent-device.test.ts b/e2e/slow-agent-device.test.ts index 8b3a72e73..fd41618e6 100644 --- a/e2e/slow-agent-device.test.ts +++ b/e2e/slow-agent-device.test.ts @@ -666,6 +666,64 @@ describe.skipIf(AGENT_DEVICE === undefined || !iosAvailable)( () => defineSuite(IOS), ); +// why: under `deviceSet: "default"` Simlock keeps its simulators in Xcode's own set, so plain `xcrun simctl` (no `--set`) must see the leased one; a daemon with a home of its own makes this a second daemon, not the shared one above. +describe.skipIf(AGENT_DEVICE === undefined || !iosAvailable)( + "agent-device through simlock (iOS, drivers.ios.deviceSet default)", + { tags: ["slow", "ios"] }, + () => { + let env: SharedTestEnv; + + beforeAll(async () => { + const pinned = await assertPinnedAgentDevice("ios"); + env = await withSharedDaemon({ + driver: "real", + configOverrides: { + http: { enabled: true, host: "127.0.0.1", port: await freeLoopbackPort() }, + capabilities: { "agent-device": { enabled: true, command: pinned } }, + capacity: { config: { limits: { ios: { maxDevices: 1, maxRunning: 1 } } } }, + drivers: { ios: { deviceSet: "default" } }, + }, + }); + sharedEnvs.add(env); + const probe = await newAgentToken(env); + await waitForAgentDevice(env, probe.secret); + }, 420_000); + + afterAll(async () => { + await cleanupAgents(); + // why: the simulators are in the maintainer's Xcode set, which the home's removal does not clean. + if (env !== undefined) { + await env.cli(["release", "--all", "--yes"], { timeout: 60_000 }); + await env.cli(["nuke", "--delete-devices", "--yes"], { timeout: 300_000 }); + } + await env?.teardown(); + sharedEnvs.delete(env); + }, 420_000); + + it( + "under a daemon with drivers.ios.deviceSet default, an agent opens an iOS device, plain xcrun simctl list devices lists its UDID, and snapshot answers", + { timeout: 900_000 }, + async () => { + const a = await makeAgent(env, "default-set"); + const opened = await a.run(["open", "--platform", "ios"], { timeout: 330_000 }); + ok(opened); + const [lease] = await listLeases(env); + const udid = (await listDevices(env)).find( + (device) => device.id === lease?.deviceId, + )?.address; + expect(udid, "the leased device's address").toBeDefined(); + expect( + execFileSync("xcrun", ["simctl", "list", "devices"], { encoding: "utf8" }), + ).toContain(udid as string); + ok(await a.run(["install", "Appduct", IOS.app])); + ok(await a.run(["open", IOS.bundleId])); + ok(await a.run(["snapshot"], FIRST_SNAPSHOT)); + ok(await a.run(["close"])); + }, + ); + }, +); + describe.skipIf(AGENT_DEVICE === undefined || !androidAvailable)( "agent-device through simlock (Android)", { tags: ["slow", "android"] }, diff --git a/scripts/slow-e2e.sh b/scripts/slow-e2e.sh index cfd3f77cd..b4b1819ff 100755 --- a/scripts/slow-e2e.sh +++ b/scripts/slow-e2e.sh @@ -22,7 +22,10 @@ # Before a run it sources `.slow-lane.env` from the main checkout's root, if there is one: this # machine's settings, such as SIMLOCK_E2E_AGENT_DEVICE. A run's temporary homes live under # /tmp/simlock-slow, so `clean` and `stop` find a run's daemons, emulators and simulators by -# that path and touch nothing else. A run starts by cleaning up after earlier ones. +# that path and touch nothing else, except the simulators in Xcode's own device set whose durable +# mark names the instance of a home under that path (a daemon run with drivers.ios.deviceSet +# "default" keeps them there): `clean` and `stop` delete those too. A run starts by cleaning up +# after earlier ones. # # The run is detached from the calling shell, so no tool time limit kills it halfway and # leaves booted devices behind. Its own harness tears down the devices it created. @@ -90,10 +93,40 @@ def clean(): os.kill(pid, signal.SIGKILL) except OSError: pass + delete_marked_simulators() shutil.rmtree(lane_tmp, ignore_errors=True) for pid, name in procs: print(f"stopped {pid} {name}") +def lane_instance_ids(): + # why: each lane home holds its instance id in instance.json (a gateway's under gateway/); the homes sit one or two levels below lane_tmp. + ids = set() + for base, dirs, files in os.walk(lane_tmp): + if base[len(lane_tmp):].count(os.sep) >= 3: + dirs[:] = [] + if "instance.json" in files: + try: + ids.add(json.loads(read(os.path.join(base, "instance.json")))["instanceId"]) + except (ValueError, KeyError, TypeError): + pass + return ids + +def delete_marked_simulators(): + # why: with drivers.ios.deviceSet "default" the lane's simulators live in the maintainer's Xcode set, which removing the lane folder does not clean; only a durable mark naming a lane instance proves a simulator is the lane's. + ids = lane_instance_ids() + devices = os.environ.get("SIMLOCK_SLOW_DEVICES") or os.path.expanduser("~/Library/Developer/CoreSimulator/Devices") + if not ids or not os.path.isdir(devices): + return + for udid in sorted(os.listdir(devices)): + try: + mark = json.loads(read(os.path.join(devices, udid, "simlock-mark.json"))) + except ValueError: + continue + if isinstance(mark, dict) and mark.get("instanceId") in ids: + subprocess.run(["xcrun", "simctl", "shutdown", udid], capture_output=True) + subprocess.run(["xcrun", "simctl", "delete", udid], capture_output=True) + print(f"deleted simulator {udid}") + def free_gib(path): stats = os.statvfs(path) return stats.f_bavail * stats.f_frsize / 2**30 diff --git a/src/agent-device/host/agent-device-supervisor.test.ts b/src/agent-device/host/agent-device-supervisor.test.ts index a89a344bb..c0adc83b5 100644 --- a/src/agent-device/host/agent-device-supervisor.test.ts +++ b/src/agent-device/host/agent-device-supervisor.test.ts @@ -622,7 +622,7 @@ describe("AgentDeviceSupervisor spawning", () => { }, ); - it("passes every key and value of the drivers' environment unchanged, and copies the iOS device set to the agent-device key", async () => { + it("passes every key and value of the drivers' environment unchanged, copies the iOS device set to the agent-device key, and sets none when the lease environment has none", async () => { const harness = await run({ driverEnvironment: { ANDROID_ADB_SERVER_PORT: "5199", @@ -640,6 +640,12 @@ describe("AgentDeviceSupervisor spawning", () => { }); expect(env["AGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST"]).toBeUndefined(); expect(running(harness).platforms).toEqual(["ios", "android"]); + + const xcodeSet = await run({ driverEnvironment: { ANDROID_ADB_SERVER_PORT: "5199" } }); + expect(running(xcodeSet).platforms).toEqual(["ios", "android"]); + expect(xcodeSet.runner.calls[1]?.options.env).not.toHaveProperty( + "AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET", + ); }); it("points the daemon at an empty private directory when the iOS driver is off", async () => { diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index b9383c919..8a05059e8 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -29,6 +29,7 @@ async function run( try { const response = await client.runDoctor({ fix: values.fix === true, purgeOrphans }); writeDriverAdvisoryWarnings(environment, response); + writeUnavailableDrivers(environment, response); writeMissingPrerequisites(environment, response); writeBrokenService(environment, response); writeUnreachableWarmTargets(environment, response); @@ -50,6 +51,18 @@ function writeDriverAdvisoryWarnings( } } +function writeUnavailableDrivers( + environment: CommandContext["environment"], + report: DoctorReport, +): void { + for (const finding of report.findings) { + if (finding.kind !== "driver-unavailable") continue; + environment.stderr.write( + `Unavailable [${finding.platform}] ${finding.reason}: ${finding.detail}\n`, + ); + } +} + function writeMissingPrerequisites( environment: CommandContext["environment"], report: DoctorReport, diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index ad10a6609..44a7e0073 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -2624,6 +2624,33 @@ describe("CLI: daemon status (ADR 0003 §11)", () => { ); }); + it("simlock doctor writes `Unavailable [] : ` on stderr for a driver-unavailable finding and prints the report on stdout unchanged", async () => { + const output = outputCapture(); + const findings = [ + { + detail: "Refusing the ios device set: bad. Restart the daemon.", + kind: "driver-unavailable" as const, + platform: "ios" as const, + reason: "invalid-device-set", + }, + ]; + + await expect( + runCli( + ["doctor"], + output.environmentWith({ + connectAdmin: async () => + fakeClient({ runDoctor: () => Promise.resolve({ findings } as never) }), + }), + ), + ).resolves.toBe(0); + + expect(output.stderr.split("\n")).toContain( + "Unavailable [ios] invalid-device-set: Refusing the ios device set: bad. Restart the daemon.", + ); + expect(JSON.parse(output.stdout)).toEqual({ findings }); + }); + it("simlock doctor writes agent-device fail ; on stderr for a module-unavailable finding, one line of at most 500 characters per part, after the advisory, prerequisite and warm-pool lines, prints the report on stdout unchanged, and exits 0", async () => { const output = outputCapture(); const findings = [ diff --git a/src/contract/AGENTS.md b/src/contract/AGENTS.md index bad8c8107..ccdf3da09 100644 --- a/src/contract/AGENTS.md +++ b/src/contract/AGENTS.md @@ -57,7 +57,7 @@ Three identities: ## Protocol version -- `PROTOCOL_VERSION_RANGE` in `protocol.ts` holds it, now `{ min: 59, max: 59 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`. +- `PROTOCOL_VERSION_RANGE` in `protocol.ts` holds it, now `{ min: 62, max: 62 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`. - Every change to a contract shape bumps it by one in the same PR. Move `min` too, unless a compatibility path stays side by side. `.agents/scripts/protocol-bump-check.mjs` checks it on push and in CI; it counts every file here as a shape but tests, `index.ts` and the diff --git a/src/contract/protocol.ts b/src/contract/protocol.ts index c62b34b55..127d07e0e 100644 --- a/src/contract/protocol.ts +++ b/src/contract/protocol.ts @@ -10,7 +10,7 @@ export interface ProtocolRange { const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().int() }); /** Widen only once a second version is kept alive side by side with the first; a bump that leaves no shim behind moves both ends. */ -export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 61, max: 61 }; +export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 62, max: 62 }; const LEGACY_DAEMON_PROTOCOL_VERSION = 2; diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index e1445c7ca..c62bfc7b7 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -148,7 +148,16 @@ export function quarantineCauseMessage(text: string): string | undefined { const cleaned = text.replace(CONTROL_CHARACTERS, " ").trim(); if (cleaned === "") return undefined; if (cleaned.length <= QUARANTINE_MESSAGE_MAX) return cleaned; - let cut = cleaned.slice(0, QUARANTINE_MESSAGE_MAX - 1); + return boundDriverSummary(cleaned, QUARANTINE_MESSAGE_MAX); +} + +/** The longest `detail` a `driver-unavailable` finding carries. */ +export const DRIVER_DETAIL_MAX = 1000; + +/** Text cut to `max` characters, ending in `…` inside it and never inside a surrogate pair. */ +export function boundDriverSummary(text: string, max: number): string { + if (text.length <= max) return text; + let cut = text.slice(0, max - 1); const last = cut.charCodeAt(cut.length - 1); if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1); return `${cut}…`; @@ -910,6 +919,12 @@ const doctorFindingSchema = z.discriminatedUnion("kind", [ code: z.string(), message: z.string(), }), + z.object({ + kind: z.literal("driver-unavailable"), + platform: platformSchema, + reason: z.string().max(64), + detail: z.string().max(DRIVER_DETAIL_MAX), + }), z.object({ kind: z.literal("service-broken"), manager: z.enum(["launchd", "systemd"]), diff --git a/src/core/device-root.ts b/src/core/device-root.ts index 16681f42d..78650fc77 100644 --- a/src/core/device-root.ts +++ b/src/core/device-root.ts @@ -22,7 +22,9 @@ export type RootRejectionReason = | "wrong-owner" | "wrong-permissions" | "non-empty-unowned-root" - | "unreadable"; + | "unreadable" + | "invalid-device-set" + | "device-set-changed"; export interface OwnedRootMarker { readonly schemaVersion: 1; diff --git a/src/core/doctor.test.ts b/src/core/doctor.test.ts index 95e01928f..ace7dbdbc 100644 --- a/src/core/doctor.test.ts +++ b/src/core/doctor.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { EventBus } from "../bus/index.js"; +import { DRIVER_DETAIL_MAX } from "../contract/index.js"; import { FakeClock, JsonLinesLogger, MemoryLogSink } from "../ports/index.js"; import { FakeSystemStats } from "../ports/index.js"; import type { Config } from "./config.js"; @@ -1637,7 +1638,21 @@ describe("Doctor", () => { eventBus, registry, }).reconcile(); + const long = await new Doctor({ + decisions: new SerializedDecision(), + clock, + config: config(), + drivers: [], + driverRejections: [{ ...rootRejection(), summary: "x".repeat(5000) }], + eventBus, + registry, + }).reconcile(); + const longDetail = (long.findings[0] as { detail: string }).detail; + expect(longDetail.length).toBeLessThanOrEqual(DRIVER_DETAIL_MAX); + expect(longDetail).toMatch( + /…\. Driver discovery runs once.*restart the daemon.*unavailable\.$/, + ); expect(report.findings.map((finding) => finding.kind)).toEqual(["driver-unavailable"]); expect(report.findings[0]).toMatchObject({ // Discovery happens once, at startup, so the finding has to say what actually diff --git a/src/core/doctor.ts b/src/core/doctor.ts index 56d0aa3d1..e9a2a1dc6 100644 --- a/src/core/doctor.ts +++ b/src/core/doctor.ts @@ -1,5 +1,5 @@ import type { EventBus } from "../bus/index.js"; -import { parseOsConstraint } from "../contract/index.js"; +import { boundDriverSummary, DRIVER_DETAIL_MAX, parseOsConstraint } from "../contract/index.js"; import { type Clock, type Logger, NoopLogger } from "../ports/index.js"; import type { Config } from "./config.js"; import { @@ -1136,7 +1136,8 @@ const DRIVER_RETRY_REMEDY = function driverUnavailableFindings(rejections: readonly DriverRejection[]): DoctorFinding[] { return rejections.map((rejection) => ({ - detail: `${rejection.summary}. ${DRIVER_RETRY_REMEDY}`, + // why: a driver's summary is an error message of any length; the remedy is kept whole. + detail: `${boundDriverSummary(rejection.summary, DRIVER_DETAIL_MAX - DRIVER_RETRY_REMEDY.length - 2)}. ${DRIVER_RETRY_REMEDY}`, kind: "driver-unavailable" as const, platform: rejection.platform, reason: rejection.reason, diff --git a/src/daemon-protocol/index.test.ts b/src/daemon-protocol/index.test.ts index dd0ba2b59..7f5fe5a9d 100644 --- a/src/daemon-protocol/index.test.ts +++ b/src/daemon-protocol/index.test.ts @@ -9,7 +9,7 @@ import { describe("daemon protocol", () => { it("keeps the current protocol version and newline framing", () => { - expect(DAEMON_PROTOCOL_VERSION).toBe(61); + expect(DAEMON_PROTOCOL_VERSION).toBe(62); expect(serializeFrame({ id: 1, type: "hello" })).toBe('{"id":1,"type":"hello"}\n'); }); diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 63b8ea411..5252ad895 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -262,6 +262,7 @@ async function buildDispatcher( readonly presence?: (driverDeviceId: string) => LatestPresence | undefined; readonly capabilitySources?: DispatcherOptions["capabilitySources"]; readonly load?: DispatcherOptions["load"]; + readonly driverRejections?: ConstructorParameters[0]["driverRejections"]; } = {}, ) { const clock = overrides.clock ?? new FakeClock(1_000); @@ -334,6 +335,9 @@ async function buildDispatcher( clock, config, drivers: [driver], + ...(overrides.driverRejections === undefined + ? {} + : { driverRejections: overrides.driverRejections }), eventBus, leaseExpirer: engine, quarantine: engine.core.quarantine, @@ -877,6 +881,46 @@ describe("Dispatcher: role rejection", () => { }); }); + it("doctor.run answers a driver-unavailable finding with its reason and detail while iOS is refused", async () => { + const cases = [ + { + reason: "invalid-device-set", + root: "Default", + summary: "Refusing the ios device set: bad", + }, + { + reason: "device-set-changed", + root: "/Volumes/sims", + summary: "Refusing the ios device set: left", + }, + ] as const; + for (const { reason, root, summary } of cases) { + const { dispatcher } = await buildDispatcher({ + driverRejections: [ + { + event: "driver.root-rejected", + payload: { platform: "ios", reason, root }, + platform: "ios", + reason, + summary, + }, + ], + }); + + const report = (await dispatcher.dispatch("doctor.run", {}, session({ role: "admin" }))) as { + findings: { kind: string; platform?: string; reason?: string; detail?: string }[]; + }; + + const finding = report.findings.find((entry) => entry.kind === "driver-unavailable"); + expect(finding, reason).toMatchObject({ + kind: "driver-unavailable", + platform: "ios", + reason, + }); + expect(finding?.detail, reason).toContain(summary); + } + }); + it("driver.passthrough resolves the scoped command through the driver that claims the tool", async () => { const { dispatcher } = await buildDispatcher({ passthroughTool: "simctl" }); diff --git a/src/drivers/AGENTS.md b/src/drivers/AGENTS.md index 2e3921208..93adc767c 100644 --- a/src/drivers/AGENTS.md +++ b/src/drivers/AGENTS.md @@ -24,9 +24,12 @@ Manuals: [Android](android/AGENTS.md), [iOS](ios/AGENTS.md). Flows: [device sele 1. **Owned root** (ADR 0001) — `create` calls `ensureOwnedRoot` (`core/device-root.ts`): when nothing is there it creates the root with `.simlock-owned.json`; otherwise the marker must name this instance and platform; anything else refuses. Key `drivers..deviceRoot` - (absolute), default `$SIMLOCK_HOME/devices/`. + (absolute), default `$SIMLOCK_HOME/devices/`. iOS only: `drivers.ios.deviceSet: "default"` + (ADR 0048) keeps simulators in Xcode's set instead, with no owned root; a device is then this + instance's only when its durable mark names the instance id. A refusal (`OwnedRootError`) skips that platform only and emits `driver.root-rejected`. -2. **Scope every command to the root.** Ownership is membership in the root, never a name prefix. +2. **Scope every command to the root.** Ownership is membership in the root, never a name prefix; + under iOS `deviceSet: "default"` it is the instance id in the device's durable mark. 3. **Opaque to core.** `address`, `driverData`, `deviceRoot` and `drivers..*` are carried unread. Each driver package exports a `DeviceDataTable` (`iosDeviceDataTable`, `androidDeviceDataTable`) that stores its `driverData` in its own `state.db` table, built without a driver instance so it loads where the driver is refused; `daemon/main.ts` `productionDeviceDataTables` passes both on every host. 4. **Prerequisites** — `iosPrerequisites`, `androidPrerequisites` return a `PrerequisiteCheck`, not a `Driver` method; built even when the driver did not start, run only by `doctor`. diff --git a/src/drivers/ios/AGENTS.md b/src/drivers/ios/AGENTS.md index bdc31a1ba..3eba13e83 100644 --- a/src/drivers/ios/AGENTS.md +++ b/src/drivers/ios/AGENTS.md @@ -7,20 +7,21 @@ Parent: [drivers](../AGENTS.md). Flows: [device selection](../../../docs/interna ## Files -| File | Role | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `index.ts` | `IosSimctlDriver`: every `Driver` verb, slim pass, `simctl` passthrough rules | -| `device-data.ts` | `iosDeviceDataTable`: owner `ios-device-data` version 1, table `ios_device_data` (`udid`, `name`, `device_type_id`, `runtime_id`, nullable `slim_signature`, `slim_mark_token`) | -| `physical.ts` | `IosPhysicalHandler`: enrol, presence, reclaim through `devicectl` | -| `devicectl-exec.ts` | `devicectlExecCommand`: the reviewed `devicectl` list | -| `slim-labels.ts` | `SLIM_CATEGORIES`, `resolveSlimCategories`, `labelsFor`, `slimSignature` | -| `prerequisites.ts` | `iosPrerequisites`: Xcode selected, license accepted, first launch done | -| `screen-capture.ts` | `createIosScreenCapture`: runs `vendor/screen-capture/` on a simulator or a physical iPhone | +| File | Role | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `index.ts` | `IosSimctlDriver`: every `Driver` verb, slim pass, `simctl` passthrough rules | +| `device-data.ts` | `iosDeviceDataTable`: owner `ios-device-data` version 1, table `ios_device_data` (`udid`, `name`, `device_type_id`, `runtime_id`, nullable `slim_signature`, `slim_mark_token`) | +| `device-set-leftovers.ts` | `refuseOwnSetLeftovers`, `refuseXcodeSetLeftovers`: the file-only check for simulators left from the other `deviceSet` | +| `physical.ts` | `IosPhysicalHandler`: enrol, presence, reclaim through `devicectl` | +| `devicectl-exec.ts` | `devicectlExecCommand`: the reviewed `devicectl` list | +| `slim-labels.ts` | `SLIM_CATEGORIES`, `resolveSlimCategories`, `labelsFor`, `slimSignature` | +| `prerequisites.ts` | `iosPrerequisites`: Xcode selected, license accepted, first launch done | +| `screen-capture.ts` | `createIosScreenCapture`: runs `vendor/screen-capture/` on a simulator or a physical iPhone | ## How it works -Every simulator command is `xcrun simctl --set …`. Unscoped only: `findLegacy`, -`destroyLegacy`, and the read-only `simctl list -j devices` `listComponents` runs. +Under `drivers.ios.deviceSet` `simlock` (the default) every simulator command is `xcrun simctl --set …`. Unscoped only: `findLegacy`, `destroyLegacy`, and the read-only `simctl list -j devices` `listComponents` runs. +Under `default` (ADR 0048) the set is Xcode's own, `/Devices`, and no call carries `--set`: there is no owned root, `listManaged` lists only devices whose durable mark names this instance's `instanceId`, and `findLegacy` answers `undefined`. A value other than `simlock` or `default`, or `default` with `drivers.ios.deviceRoot`, refuses iOS (`invalid-device-set`); simulators left in the set being given up refuse it too (`device-set-changed`), found by reading files only. Resolve — `resolveSpec` (reads `simctl list -j devicetypes runtimes` on every call): @@ -34,7 +35,7 @@ Resolve — `resolveSpec` (reads `simctl list -j devicetypes runtimes` on every 5. `mode: "slim"` → slim spec only when the runtime is iOS 18.5+; otherwise a full spec. Provision — `simctl create simlock- ` → UDID, then the mark: -`//data/simlock-mark.json` (erasable) first, then `//simlock-mark.json` (durable). +`//data/simlock-mark.json` (erasable) first, then `//simlock-mark.json` (durable). Both carry `{ token, udid, writtenAt, instanceId }`; under `default` a failed mark write deletes the new simulator before the error is rethrown. Boot — `makeReady`: @@ -63,7 +64,7 @@ Components — a component is a runtime version or `latest`. `installComponent`: `xcodebuild -downloadPlatform iOS [-buildVersion ]`, 8 GiB reserved under `~/Library/Developer/CoreSimulator`. `removeComponent`: `simctl runtime delete `, waits while it deletes, reports any download left in the asset store as `residue`. -`foreignDevices` counts devices in the default set. +`foreignDevices` counts devices in the default set (under `deviceSet: default`, those this instance's mark does not name). Physical — `devicectl list devices` with `--json-output` to `$SIMLOCK_HOME/devicectl/` (file deleted after). Id matches identifier, UDID, serial or ECID; wired only. @@ -72,7 +73,7 @@ off, locked. Present = listed, paired, unlocked. Passthrough — -- `simctl`: `xcrun simctl --set …`; with a device (`device.exec`) the lease's UDID goes first among +- `simctl`: `xcrun simctl --set …` (plain `xcrun simctl …` under `default`); with a device (`device.exec`) the lease's UDID goes first among a device verb's operands (`simctl-device.ts`, ADR 0047). Refused: `booted`, another UUID, `create`, `erase`, `delete`, `shutdown all`, `runtime delete`, a flag before the subcommand (`--set`, `--profiles`), a physical or non-iOS lease. - `devicectl`: physical iOS lease only; no local wrapper. `devicectlExecCommand` allows @@ -80,10 +81,10 @@ Passthrough — `device info apps|lockState|details|processes`, each with its own options. Host-path options (`--json-output`, `--log-output`, `--destination`, `--file`, `--filter`, `-j`, `-l`) are refused before `--`. Adds `--device ` unless the caller names one; another UDID is refused. -Lease environment: virtual `SIMLOCK_IOS_DEVICE_SET=`; physical `SIMLOCK_DEVICE_UDID=`. +Lease environment: virtual `SIMLOCK_IOS_DEVICE_SET=` under `simlock`, none under `default`; physical `SIMLOCK_DEVICE_UDID=`. Screen — `screenCapture` (only when `create` is given a launcher): `simlock-screen-capture --codec avcc --fps --bitrate --max-dimension` with `SIMLOCK_IOS_DEVICE_SET=` for a -simulator; a physical iPhone runs `simlock-device-capture` with the same arguments, no such +simulator under `simlock` (the variable is removed from the program's environment under `default`); a physical iPhone runs `simlock-device-capture` with the same arguments, no such variable. stdout is the frames, stdin its liveness. Close: stdin, 2 s, SIGKILL. A missing program → `ScreenUnavailableError` (the iPhone one refuses iPhone opens only), as does an iPhone below iOS 27 per the physical handler's `devicectl list devices` (an unreadable version goes on). @@ -100,6 +101,7 @@ Advisories — `runtime-cache-unreclaimable` (asset bundles no installed runtime | What | Default | Key | | ----------------------------- | --------------------------- | ------------------------ | | Device root | `$SIMLOCK_HOME/devices/ios` | `drivers.ios.deviceRoot` | +| Device set | `simlock` | `drivers.ios.deviceSet` | | Default mode | `full` | `ios.defaultMode` | | Slim categories | all in `SLIM_CATEGORIES` | `ios.slim.categories` | | Slim boot deadline | 10 min | `ios.slim.bootTimeoutMs` | @@ -113,38 +115,26 @@ record of a device's mode; `driverData` holds the slim signature. ## Rules -- Never `simctl` without `--set`, except the three unscoped calls above. +- Under `simlock`, never `simctl` without `--set`, except the three unscoped calls above. Under `default`, never `--set`, and never act on a device whose durable mark does not name this instance. - The erasable mark is written before the durable one, never both at once. - A recovery boot never slims. - A slim spec is returned only for a slim request the driver will slim. ## Known gaps -- **A runtime download outlives the runtime (#79).** `simctl runtime delete` leaves its ~8.5 GiB - asset bundle in `/System/Library/AssetsV2`. Simlock never deletes it; `doctor` and - `component remove` report it. Reclaim in Xcode → Settings → Platforms. +- **A restart between `simctl create` and the durable mark under `default` (#968)** leaves one unlisted `simlock-` simulator in Xcode's set. Delete it with `xcrun simctl delete `. +- **A runtime download outlives the runtime (#79).** `simctl runtime delete` leaves its ~8.5 GiB asset bundle in `/System/Library/AssetsV2`. Simlock never deletes it; `doctor` and `component remove` report it. Reclaim in Xcode → Settings → Platforms. - **Slim costs (#87, #172).** Every reclaim erases, so the next boot re-slims: two boots each time. Below iOS 18.5 a slim request gets `full`. Slim devices lose push, Spotlight, StoreKit, universal links, Siri, iCloud sync. Use `--mode full` when a test needs them. - **Slim drift is invisible.** `launchctl disable` accepts labels that do not exist. Narrowing `ios.slim.categories` never re-enables a daemon: reclaim or destroy old devices first. - **A recovered slim device runs full but its record says `slim` (#174)** until its next `makeReady`. -- **A screen stream can freeze after a reboot under its lease.** The capture program checks the - simulator is booted only when it starts, then repaints its last surface every 200 ms, so after - the health monitor reboots a crashed simulator the viewer can see the old picture until they - leave the Screen tab and open it again. A lease ending stops it as usual. -- **An iPhone rotated mid-stream keeps the first picture's scale**, as a simulator does today: the - scaler is sized once, from the first picture. An iPhone that stays connected and unlocked but - stops sending video, or a decoder that stops producing pictures, shows the last picture until - the lease ends. +- **A screen stream can freeze after a reboot under its lease.** The capture program checks the simulator is booted only when it starts, then repaints its last surface every 200 ms, so after the health monitor reboots a crashed simulator the viewer can see the old picture until they leave the Screen tab and open it again. A lease ending stops it as usual. +- **An iPhone rotated mid-stream keeps the first picture's scale**, as a simulator does today: the scaler is sized once, from the first picture. An iPhone that stays connected and unlocked but stops sending video, or a decoder that stops producing pictures, shows the last picture until the lease ends. - **A capture killed by SIGKILL cannot stop its CoreDevice stream**; CoreDevice is left to notice its client is gone. The same for a stop while `startMediaStream` is still running past 1.5 s. - **A new Xcode major turns iPhone streams off** until `vendor/screen-capture/Stubs/` is checked against that release and the pin moves. -- **Untested on an iPhone:** DeviceHub or Xcode showing the same device during a stream; the lease - holder's `devicectl` commands during one; the source ending after the first picture; `stop()` - really stopping the stream. Nothing can observe CoreDevice's state, and a device that serves two - streams at once lets a new lease stream even after a leaked one, so the slow lane proves only - that the process is gone and that the device streams again. Two viewers of one lease ask for two - streams; the two-viewer case finds out whether it serves both (#602 would remove the second). +- **Untested on an iPhone:** DeviceHub or Xcode showing the same device during a stream; the lease holder's `devicectl` commands during one; the source ending after the first picture; `stop()` really stopping the stream. Nothing can observe CoreDevice's state, and a device that serves two streams at once lets a new lease stream even after a leaked one, so the slow lane proves only that the process is gone and that the device streams again. Two viewers of one lease ask for two streams; the two-viewer case finds out whether it serves both (#602 would remove the second). - **A cold slim lease takes ~160 s.** MCP clients need `resetTimeoutOnProgress` or a longer timeout. diff --git a/src/drivers/ios/device-set-leftovers.ts b/src/drivers/ios/device-set-leftovers.ts new file mode 100644 index 000000000..c68592ea2 --- /dev/null +++ b/src/drivers/ios/device-set-leftovers.ts @@ -0,0 +1,87 @@ +import { join } from "node:path"; + +import { OwnedRootError } from "../../core/index.js"; +import type { Filesystem } from "../../ports/index.js"; + +const UDID_PATTERN = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i; +const MARK_FILE_NAME = "simlock-mark.json"; + +const SWITCH_STEPS = + "run `simlock nuke --delete-devices` and `simlock doctor --purge-orphans`, then switch again."; + +/** + * why: Switching `drivers.ios.deviceSet` with simulators left in the set being given up would + * leave them invisible to the new setting, so the driver refuses to start. The + * check reads files only: a start-up `simctl` call would be a new way for start-up to hang. + */ +export async function refuseOwnSetLeftovers(filesystem: Filesystem, root: string): Promise { + const entries = await readFolder(filesystem, root); + const udids = entries.filter((entry) => UDID_PATTERN.test(entry)).sort(); + if (udids.length === 0) return; + + throw new OwnedRootError( + `Refusing the ios device set: Simlock's own set at ${root} still holds ${String(udids.length)} simulator(s) (${udids.join(", ")}). Set drivers.ios.deviceSet back to simlock, ${SWITCH_STEPS} Delete any that remain with \`xcrun simctl --set ${root} delete \`.`, + "device-set-changed", + root, + "ios", + ); +} + +export async function refuseXcodeSetLeftovers( + filesystem: Filesystem, + devicesDir: string, + instanceId: string, +): Promise { + const entries = await readFolder(filesystem, devicesDir); + const marked: string[] = []; + for (const entry of entries.sort()) { + if (await marksInstance(filesystem, join(devicesDir, entry, MARK_FILE_NAME), instanceId)) { + marked.push(entry); + } + } + if (marked.length === 0) return; + + throw new OwnedRootError( + `Refusing the ios device set: Xcode's device set still holds ${String(marked.length)} of this Simlock's simulator(s) (${marked.join(", ")}). Set drivers.ios.deviceSet back to default, ${SWITCH_STEPS} Delete any that remain with \`xcrun simctl delete \`.`, + "device-set-changed", + devicesDir, + "ios", + ); +} + +async function readFolder(filesystem: Filesystem, folder: string): Promise { + try { + return await filesystem.readdir(folder); + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") return []; + throw new OwnedRootError( + `Refusing the ios device set: ${folder} could not be read: ${error instanceof Error ? error.message : String(error)}`, + "unreadable", + folder, + "ios", + ); + } +} + +async function marksInstance( + filesystem: Filesystem, + markPath: string, + instanceId: string, +): Promise { + try { + const parsed = JSON.parse(await filesystem.readFile(markPath)) as unknown; + return ( + typeof parsed === "object" && + parsed !== null && + "instanceId" in parsed && + parsed.instanceId === instanceId + ); + } catch { + // why: A mark that is missing, unreadable or corrupt proves no instance, so it is not ours. + return false; + } +} + +function errorCode(error: unknown): unknown { + return typeof error === "object" && error !== null && "code" in error ? error.code : undefined; +} diff --git a/src/drivers/ios/index.test.ts b/src/drivers/ios/index.test.ts index 5676537cd..efef757f5 100644 --- a/src/drivers/ios/index.test.ts +++ b/src/drivers/ios/index.test.ts @@ -4736,3 +4736,538 @@ function assetInfoPlist(simulatorVersion: string, build: string): string { const prepareFull = { mode: "full", purpose: "prepare" } as const; const prepareSlim = { mode: "slim", purpose: "prepare" } as const; + +describe("IosSimctlDriver device set", () => { + const coreSimulatorRoot = "/CoreSim"; + const runtimeImage = { + build: "23E244", + identifier: "IMG-26.4-23E244", + platformIdentifier: "com.apple.platform.iphonesimulator", + runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-4", + sizeBytes: 1_000, + state: "Ready", + version: "26.4", + }; + const imageList = JSON.stringify({ [runtimeImage.identifier]: runtimeImage }); + const emptyDevices = JSON.stringify({ devices: {} }); + + interface Step { + readonly args: readonly string[]; + readonly scoped: boolean; + readonly stdout?: string; + } + + async function plantDevice(filesystem: Filesystem, devicesDir: string): Promise { + await filesystem.mkdirp(`${devicesDir}/${driverData.udid}/data`); + } + + it.each([ + ["unset", {}, deviceRoot, true], + ["simlock", { deviceSet: "simlock" }, deviceRoot, true], + ["default", { deviceSet: "default" }, `${coreSimulatorRoot}/Devices`, false], + ] as const)( + "the driver given deviceSet %s runs every simctl call with or without --set", + async (_case, config, devicesDir, scoped) => { + const steps: Step[] = [ + { args: ["list", "-j", "devicetypes", "runtimes"], scoped: true, stdout: listFixture }, + { + args: ["create", driverData.name, driverData.deviceTypeId, driverData.runtimeId], + scoped: true, + stdout: `${driverData.udid}\n`, + }, + { args: ["boot", driverData.udid], scoped: true }, + { args: ["bootstatus", driverData.udid, "-b"], scoped: true }, + { args: ["listapps", driverData.udid], scoped: true }, + { args: ["shutdown", driverData.udid], scoped: true }, + { args: ["erase", driverData.udid], scoped: true }, + { args: ["shutdown", driverData.udid], scoped: true }, + { args: ["delete", driverData.udid], scoped: true }, + { args: ["list", "-j", "devices"], scoped: true, stdout: emptyDevices }, + { args: ["runtime", "list", "-j"], scoped: true, stdout: imageList }, + { args: ["list", "-j", "devices"], scoped: false, stdout: emptyDevices }, + { args: ["runtime", "delete", runtimeImage.identifier], scoped: true }, + { args: ["runtime", "list", "-j"], scoped: true, stdout: "{}" }, + { args: ["runtime", "list", "-j"], scoped: true, stdout: "{}" }, + { args: ["list", "-j", "devices"], scoped: false, stdout: emptyDevices }, + ]; + const argvOf = (step: Step): string[] => + step.scoped && scoped ? simctlArgs(...step.args) : ["simctl", ...step.args]; + const runner = new ScriptedProcessRunner( + steps.map((step) => ({ + match: { args: argvOf(step), command: "xcrun" }, + ...(step.stdout === undefined + ? {} + : { result: { code: 0, stderr: "", stdout: step.stdout } }), + })), + ); + const filesystem = new MemoryFilesystem(); + await plantDevice(filesystem, devicesDir); + if (scoped) await plantManagedDevice(filesystem); + const driver = await IosSimctlDriver.create({ + clock: new FakeClock(), + coreSimulatorRoot, + driverConfig: scoped ? { deviceRoot, ...config } : config, + filesystem, + idGenerator: { generate: () => "device-1" }, + instanceId, + processRunner: runner, + simlockHome: "/home/.simlock", + }); + const device = { address: driverData.udid, deviceId: driverData.udid, driverData }; + + await driver.resolveSpec({ model: "iPhone 16", osVersion: "26.5", platform: "ios" }); + await driver.provision(spec); + await driver.makeReady(device, prepareFull); + await driver.reclaim(device, { clean: "standard" }); + await driver.destroy(device); + await driver.listManaged(); + await driver.removeComponent( + { build: runtimeImage.build, image: runtimeImage.identifier }, + { signal: new AbortController().signal }, + ); + + expect(runner.calls.map((call) => call.args)).toEqual(steps.map(argvOf)); + }, + ); + + it.each([ + ["Default", { deviceSet: "Default" }, "Default", 'but it is "Default"'], + ["the empty string", { deviceSet: "" }, "", 'but it is ""'], + ["a boolean", { deviceSet: true }, "true", "but it is true"], + [ + "default with a deviceRoot", + { deviceSet: "default", deviceRoot: "/Volumes/sims" }, + "default", + 'drivers.ios.deviceRoot names Simlock\'s own set, but drivers.ios.deviceSet is "default". Remove one of them.', + ], + ] as const)( + "refuses iOS with invalid-device-set when deviceSet is %s", + async (_case, config, root, detail) => { + const create = (driverConfig: Record) => + IosSimctlDriver.create({ + clock: new FakeClock(), + coreSimulatorRoot, + driverConfig, + filesystem: new MemoryFilesystem(), + idGenerator: { generate: () => "device-1" }, + instanceId, + processRunner: new ScriptedProcessRunner([]), + simlockHome: "/home/.simlock", + }); + + await expect(create({ deviceSet: "default" })).resolves.toBeInstanceOf(IosSimctlDriver); + const failure = await create(config) + .then(() => undefined) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(OwnedRootError); + expect(failure).toMatchObject({ + message: expect.stringContaining(detail), + path: root, + platform: "ios", + reason: "invalid-device-set", + }); + expect((failure as Error).message).toMatch(/^Refusing the ios device set: /); + }, + ); + + it("an operator who switches with leftovers gets device-set-changed, and an unreadable folder gets unreadable", async () => { + const ownRoot = "/home/.simlock/devices/ios"; + const xcodeDevices = `${coreSimulatorRoot}/Devices`; + const [first, second, third] = [1, 2, 3].map( + (n) => `00000000-0000-0000-0000-00000000000${String(n)}`, + ); + const mark = (owner: string) => JSON.stringify({ instanceId: owner, token: "t", udid: "u" }); + const steps: readonly [ + string, + Record, + (fs: MemoryFilesystem) => Promise, + { readonly message?: string; readonly path?: string; readonly reason: string } | undefined, + ][] = [ + [ + "default with two devices in Simlock's set", + { deviceSet: "default" }, + async (fs) => { + await fs.mkdirp(`${ownRoot}/${second}`); + await fs.mkdirp(`${ownRoot}/${first}`); + await fs.writeFileAtomic(`${ownRoot}/${OWNED_ROOT_MARKER_FILE}`, "{}"); + }, + { + message: `Refusing the ios device set: Simlock's own set at ${ownRoot} still holds 2 simulator(s) (${first}, ${second}). Set drivers.ios.deviceSet back to simlock, run \`simlock nuke --delete-devices\` and \`simlock doctor --purge-orphans\`, then switch again. Delete any that remain with \`xcrun simctl --set ${ownRoot} delete \`.`, + path: ownRoot, + reason: "device-set-changed", + }, + ], + [ + "default with Simlock's set empty", + { deviceSet: "default" }, + async (fs) => { + await fs.mkdirp(ownRoot); + await fs.writeFileAtomic(`${ownRoot}/${OWNED_ROOT_MARKER_FILE}`, "{}"); + }, + undefined, + ], + ["default with no root folder", { deviceSet: "default" }, async () => undefined, undefined], + [ + "simlock with a device of this instance in Xcode's set", + { deviceSet: "simlock" }, + async (fs) => { + await fs.mkdirp(`${xcodeDevices}/${third}`); + await fs.writeFileAtomic(`${xcodeDevices}/${third}/simlock-mark.json`, mark(instanceId)); + }, + { + message: `Refusing the ios device set: Xcode's device set still holds 1 of this Simlock's simulator(s) (${third}). Set drivers.ios.deviceSet back to default, run \`simlock nuke --delete-devices\` and \`simlock doctor --purge-orphans\`, then switch again. Delete any that remain with \`xcrun simctl delete \`.`, + path: xcodeDevices, + reason: "device-set-changed", + }, + ], + [ + "simlock with only unmarked, other-instance and corrupt devices in Xcode's set", + { deviceSet: "simlock" }, + async (fs) => { + await fs.mkdirp(`${xcodeDevices}/${first}`); + await fs.mkdirp(`${xcodeDevices}/${second}`); + await fs.writeFileAtomic(`${xcodeDevices}/${second}/simlock-mark.json`, mark("other")); + await fs.mkdirp(`${xcodeDevices}/${third}`); + await fs.writeFileAtomic(`${xcodeDevices}/${third}/simlock-mark.json`, "{broken"); + }, + undefined, + ], + [ + "default with Simlock's folder unreadable", + { deviceSet: "default" }, + async (fs) => { + await fs.mkdirp(ownRoot); + fs.defineFailure(ownRoot, "EACCES"); + }, + { path: ownRoot, reason: "unreadable" }, + ], + [ + "simlock with Xcode's folder unreadable", + { deviceSet: "simlock" }, + async (fs) => { + await fs.mkdirp(xcodeDevices); + fs.defineFailure(xcodeDevices, "EACCES"); + }, + { path: xcodeDevices, reason: "unreadable" }, + ], + ]; + + for (const [name, config, plant, expected] of steps) { + const filesystem = new MemoryFilesystem(); + await plant(filesystem); + const runner = new ScriptedProcessRunner([]); + const failure = await IosSimctlDriver.create({ + clock: new FakeClock(), + coreSimulatorRoot, + driverConfig: config, + filesystem, + idGenerator: { generate: () => "device-1" }, + instanceId, + processRunner: runner, + simlockHome: "/home/.simlock", + }) + .then(() => undefined) + .catch((error: unknown) => error); + + expect(runner.calls, name).toEqual([]); + if (expected === undefined) { + expect(failure, name).toBeUndefined(); + } else { + expect(failure, name).toBeInstanceOf(OwnedRootError); + expect(failure, name).toMatchObject({ ...expected, platform: "ios" }); + } + } + }); + + it.each([ + ["simlock", { deviceRoot, deviceSet: "simlock" }, deviceRoot], + ["default", { deviceSet: "default" }, `${coreSimulatorRoot}/Devices`], + ] as const)( + "a new or reclaimed simulator's marks name this instance under deviceSet %s", + async (setting, config, devicesDir) => { + const scoped = setting === "simlock"; + const argv = (...args: string[]) => (scoped ? simctlArgs(...args) : ["simctl", ...args]); + const marks = [ + `${devicesDir}/${driverData.udid}/data/simlock-mark.json`, + `${devicesDir}/${driverData.udid}/simlock-mark.json`, + ]; + const driverFor = async (steps: readonly (readonly [string[], string?])[]) => { + const runner = new ScriptedProcessRunner( + steps.map(([args, stdout]) => ({ + match: { args, command: "xcrun" }, + ...(stdout === undefined ? {} : { result: { code: 0, stderr: "", stdout } }), + })), + ); + const filesystem = new MemoryFilesystem(); + if (scoped) await plantManagedDevice(filesystem); + else await filesystem.mkdirp(`${devicesDir}/${driverData.udid}/data`); + const driver = await IosSimctlDriver.create({ + clock: new FakeClock(), + coreSimulatorRoot, + driverConfig: config, + filesystem, + idGenerator: { generate: () => "device-1" }, + instanceId, + processRunner: runner, + simlockHome: "/home/.simlock", + }); + return { driver, filesystem, runner }; + }; + const device = { address: driverData.udid, deviceId: driverData.udid, driverData }; + const created = [ + argv("list", "-j", "devicetypes", "runtimes"), + argv("create", driverData.name, driverData.deviceTypeId, driverData.runtimeId), + ]; + + const { driver, filesystem } = await driverFor([ + [created[0] ?? [], listFixture], + [created[1] ?? [], `${driverData.udid}\n`], + [argv("shutdown", driverData.udid)], + [argv("erase", driverData.udid)], + ]); + await driver.resolveSpec({ model: "iPhone 16", osVersion: "26.5", platform: "ios" }); + await driver.provision(spec); + for (const path of marks) { + expect(JSON.parse(await filesystem.readFile(path)), path).toMatchObject({ instanceId }); + } + for (const path of marks) await filesystem.rm(path); + await driver.reclaim(device, { clean: "standard" }); + for (const path of marks) { + expect(JSON.parse(await filesystem.readFile(path)), path).toMatchObject({ instanceId }); + } + + if (scoped) return; + const failing = await driverFor([ + [created[0] ?? [], listFixture], + [created[1] ?? [], `${driverData.udid}\n`], + [argv("delete", driverData.udid)], + ]); + await failing.filesystem.rm(`${devicesDir}/${driverData.udid}/data`); + await failing.driver.resolveSpec({ model: "iPhone 16", osVersion: "26.5", platform: "ios" }); + await expect(failing.driver.provision(spec)).rejects.toThrow(); + expect(failing.runner.calls.at(-1)).toEqual({ + args: argv("delete", driverData.udid), + command: "xcrun", + options: { timeoutMs: 30_000 }, + }); + }, + ); + + it("under deviceSet default listManaged reports only devices whose durable mark names this instance", async () => { + const devicesDir = `${coreSimulatorRoot}/Devices`; + const udid = (n: number) => `00000000-0000-0000-0000-00000000010${String(n)}`; + const mark = (owner?: string) => + JSON.stringify({ ...(owner === undefined ? {} : { instanceId: owner }), token: "t" }); + const filesystem = new MemoryFilesystem(); + const cases = [ + { durable: mark(instanceId), erasable: mark(instanceId), listed: true, name: "simlock-0" }, + { durable: mark(instanceId), erasable: undefined, listed: true, name: "simlock-9" }, + { durable: undefined, erasable: mark(instanceId), listed: false, name: "no-durable" }, + { durable: mark(), erasable: mark(), listed: false, name: "no-instance" }, + { durable: mark("other"), erasable: mark("other"), listed: false, name: "another" }, + { durable: undefined, erasable: undefined, listed: false, name: "simlock-1" }, + ]; + for (const [index, entry] of cases.entries()) { + await filesystem.mkdirp(`${devicesDir}/${udid(index)}/data`); + if (entry.durable !== undefined) { + await filesystem.writeFileAtomic( + `${devicesDir}/${udid(index)}/simlock-mark.json`, + entry.durable, + ); + } + if (entry.erasable !== undefined) { + await filesystem.writeFileAtomic( + `${devicesDir}/${udid(index)}/data/simlock-mark.json`, + entry.erasable, + ); + } + } + const runner = new ScriptedProcessRunner([ + { + match: { args: ["simctl", "list", "-j", "devices"], command: "xcrun" }, + result: { + code: 0, + stderr: "", + stdout: JSON.stringify({ + devices: { + "com.apple.CoreSimulator.SimRuntime.iOS-26-5": cases.map((entry, index) => ({ + dataPath: `${devicesDir}/${udid(index)}/data`, + name: entry.name, + state: "Shutdown", + udid: udid(index), + })), + }, + }), + }, + }, + ]); + const driver = await IosSimctlDriver.create({ + clock: new FakeClock(), + coreSimulatorRoot, + driverConfig: { deviceSet: "default" }, + filesystem, + idGenerator: { generate: () => "device-1" }, + instanceId, + processRunner: runner, + simlockHome: "/home/.simlock", + }); + + const reality = await driver.listManaged(); + + expect(reality.devices.map((device) => [device.deviceId, device.mark?.erasable])).toEqual([ + [udid(0), "t"], + [udid(1), undefined], + ]); + await expect(driver.findLegacy(udid(0))).resolves.toBeUndefined(); + expect(runner.calls).toHaveLength(1); + }); + + it("under deviceSet default foreignDevices and the removal's unused count take only devices this instance's mark does not name", async () => { + const devicesDir = `${coreSimulatorRoot}/Devices`; + const udids = ["A", "B", "C"].map((n) => `0000000${n}-0000-0000-0000-000000000000`); + const devices = (used: boolean) => + JSON.stringify({ + devices: { + [runtimeImage.runtimeIdentifier]: udids.map((udid) => ({ + ...(used ? { lastUsedAt: "2026-10-01T12:00:00Z" } : {}), + name: "d", + state: "Shutdown", + udid, + })), + }, + }); + const driverWith = async (steps: readonly (readonly [readonly string[], string?])[]) => { + const filesystem = new MemoryFilesystem(); + const marked = [instanceId, undefined, "other"] as const; + for (const [index, udid] of udids.entries()) { + await filesystem.mkdirp(`${devicesDir}/${udid}`); + const owner = marked[index]; + if (owner !== undefined) { + await filesystem.writeFileAtomic( + `${devicesDir}/${udid}/simlock-mark.json`, + JSON.stringify({ instanceId: owner, token: "t" }), + ); + } + } + return IosSimctlDriver.create({ + clock: new FakeClock(), + coreSimulatorRoot, + driverConfig: { deviceSet: "default" }, + filesystem, + idGenerator: { generate: () => "device-1" }, + instanceId, + processRunner: new ScriptedProcessRunner( + steps.map(([args, stdout]) => ({ + match: { args: [...args], command: "xcrun" }, + ...(stdout === undefined ? {} : { result: { code: 0, stderr: "", stdout } }), + })), + ), + simlockHome: "/home/.simlock", + }); + }; + const runtimeList = [["simctl", "runtime", "list", "-j"], imageList] as const; + const deviceList = (used: boolean) => + [["simctl", "list", "-j", "devices"], devices(used)] as const; + + const listing = await driverWith([runtimeList, deviceList(true)]); + const [component] = await listing.listComponents(); + expect(component?.foreignDevices).toBe(2); + + const removing = await driverWith([ + runtimeList, + deviceList(false), + [["simctl", "runtime", "delete", runtimeImage.identifier]], + [["simctl", "runtime", "list", "-j"], "{}"], + [["simctl", "runtime", "list", "-j"], "{}"], + deviceList(false), + ]); + const removal = await removing.removeComponent( + { build: runtimeImage.build, image: runtimeImage.identifier }, + { signal: new AbortController().signal }, + ); + expect(removal.residue).toContain("2 never-used simulators"); + }); + + it.each([ + ["simlock", { deviceRoot, deviceSet: "simlock" }, { SIMLOCK_IOS_DEVICE_SET: deviceRoot }], + ["default", { deviceSet: "default" }, {}], + ] as const)( + "a lease holder and the screen capture get the device set only under deviceSet %s", + async (_setting, config, leaseEnvironment) => { + const filesystem = new MemoryFilesystem(); + await filesystem.mkdirp("/dist/bin"); + await filesystem.writeFileAtomic("/dist/bin/simlock-screen-capture", ""); + const launcher = new FakeBytePipeLauncher(); + const driver = await IosSimctlDriver.create({ + clock: new FakeClock(), + coreSimulatorRoot, + driverConfig: config, + filesystem, + idGenerator: { generate: () => "device-1" }, + instanceId, + processRunner: new ScriptedProcessRunner([]), + screenCapture: { + baseEnvironment: { PATH: "/usr/bin", SIMLOCK_IOS_DEVICE_SET: "/from/the/daemon" }, + command: "/dist/bin/simlock-screen-capture", + launcher, + }, + simlockHome: "/home/.simlock", + }); + const capture = driver.screenCapture; + if (capture === undefined) throw new Error("the driver offers no screen capture"); + + await capture.open( + { address: driverData.udid, deviceId: driverData.udid, driverData, physical: false }, + { bitrateKbps: 1_500, fps: 30, maxDimension: 720 }, + new AbortController().signal, + ); + + expect(driver.virtualLeaseEnvironment()).toEqual(leaseEnvironment); + expect( + driver.leaseEnvironment({ + address: driverData.udid, + deviceId: driverData.udid, + driverData, + }), + ).toEqual(leaseEnvironment); + expect(driver.leaseEnvironment(PHYSICAL_DEVICE)).toEqual({ + SIMLOCK_DEVICE_UDID: PHYSICAL_DEVICE.deviceId, + }); + expect(launcher.starts[0]?.env).toEqual({ + PATH: "/usr/bin", + ...leaseEnvironment, + }); + }, + ); + + it("simlock simctl under deviceSet default runs plain xcrun simctl and keeps its refusals", async () => { + const driver = await IosSimctlDriver.create({ + clock: new FakeClock(), + coreSimulatorRoot, + driverConfig: { deviceSet: "default" }, + filesystem: new MemoryFilesystem(), + idGenerator: { generate: () => "device-1" }, + instanceId, + processRunner: new ScriptedProcessRunner([]), + simlockHome: "/home/.simlock", + }); + + await expect( + simctlPassthrough(driver, ["install", driverData.udid, "app.app"]), + ).resolves.toEqual({ + args: ["simctl", "install", driverData.udid, "app.app"], + command: "xcrun", + env: {}, + }); + for (const refused of [ + ["erase", driverData.udid], + ["shutdown", "all"], + ["--set", "/tmp", "list"], + ]) { + await expect(simctlPassthrough(driver, refused), refused.join(" ")).rejects.toBeInstanceOf( + PassthroughRefusedError, + ); + } + }); +}); diff --git a/src/drivers/ios/index.ts b/src/drivers/ios/index.ts index 5b45c8ffd..89853681d 100644 --- a/src/drivers/ios/index.ts +++ b/src/drivers/ios/index.ts @@ -54,6 +54,7 @@ import type { ProcessRunner, } from "../../ports/index.js"; import { runBoundedProcess, runInstallerProcess } from "../installer-process.js"; +import { refuseOwnSetLeftovers, refuseXcodeSetLeftovers } from "./device-set-leftovers.js"; import { devicectlExecCommand } from "./devicectl-exec.js"; import { fillSimctlDevice } from "./simctl-device.js"; import { IosPhysicalHandler } from "./physical.js"; @@ -295,14 +296,15 @@ export class IosSimctlDriver implements Driver { readonly #physical: IosPhysicalHandler; readonly #resolvedSpecs = new Map(); readonly #deviceRoot: string; - readonly #rootOptions: EnsureOwnedRootOptions; + readonly #instanceId: string; + readonly #rootOptions: EnsureOwnedRootOptions | undefined; readonly #slim: SlimOptions | undefined; readonly #slimByDefault: boolean; private constructor( options: IosSimctlDriverOptions, deviceRoot: string, - rootOptions: EnsureOwnedRootOptions, + rootOptions: EnsureOwnedRootOptions | undefined, ) { this.#clock = options.clock; this.componentFootprint = { @@ -321,6 +323,7 @@ export class IosSimctlDriver implements Driver { simlockHome: options.simlockHome, }); this.#deviceRoot = deviceRoot; + this.#instanceId = options.instanceId; this.#rootOptions = rootOptions; this.#slim = options.slim; this.#slimByDefault = options.slimByDefault === true; @@ -339,7 +342,7 @@ export class IosSimctlDriver implements Driver { configuredDeviceCaptureCommand(options) ?? options.screenCapture.deviceCommand, }), deviceOsVersion: (udid) => this.#physical.osVersion(udid), - deviceSet: deviceRoot, + ...(rootOptions === undefined ? {} : { deviceSet: deviceRoot }), filesystem: options.filesystem, launcher: options.screenCapture.launcher, ...(options.screenCapture.onLog === undefined @@ -359,6 +362,18 @@ export class IosSimctlDriver implements Driver { * machine's default device set (safety rule 9). */ static async create(options: IosSimctlDriverOptions): Promise { + const deviceSet = configuredDeviceSet(options); + if (deviceSet === "default") { + await refuseOwnSetLeftovers(options.filesystem, join(options.simlockHome, "devices", "ios")); + return new IosSimctlDriver(options, xcodeDeviceSetPath(options), undefined); + } + if (options.coreSimulatorRoot !== undefined) { + await refuseXcodeSetLeftovers( + options.filesystem, + xcodeDeviceSetPath(options), + options.instanceId, + ); + } const rootOptions: EnsureOwnedRootOptions = { filesystem: options.filesystem, idGenerator: options.idGenerator, @@ -385,6 +400,7 @@ export class IosSimctlDriver implements Driver { * a `mv` can have left the user's own device set standing where this one was. */ async revalidateRoot(): Promise { + if (this.#rootOptions === undefined) return; await ensureOwnedRoot(this.#rootOptions); } @@ -641,7 +657,8 @@ export class IosSimctlDriver implements Driver { ]); let foreign: ReadonlyMap; try { - foreign = devicesPerRuntime(JSON.parse(defaultSet.stdout) as unknown); + const parsed = JSON.parse(defaultSet.stdout) as unknown; + foreign = devicesPerRuntime(parsed, await this.#ownedUdids(parsed)); } catch (error: unknown) { if (error instanceof DriverCrashError) throw error; throw new DriverCrashError(`Could not parse simctl device list: ${errorMessage(error)}`); @@ -741,7 +758,16 @@ export class IosSimctlDriver implements Driver { throw new DriverCrashError("simctl create returned no device UDID"); } - await this.#writeMark(udid); + try { + await this.#writeMark(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); + } + throw error; + } return { address: udid, @@ -1085,6 +1111,9 @@ export class IosSimctlDriver implements Driver { } async findLegacy(driverDeviceId: string): Promise { + // why: Under Xcode's set the default set is the driver's own, so no device is "pre-root" + // there, and a lost mark stays a report-only finding instead of a deletable legacy device. + if (this.#rootOptions === undefined) return undefined; const result = await this.#legacySimctl(["list", "-j", "devices"], COMMAND_TIMEOUT_MS); const found = parseManagedDevices(JSON.parse(result.stdout) as unknown).find( (device) => device.udid === driverDeviceId, @@ -1125,7 +1154,19 @@ export class IosSimctlDriver implements Driver { async listManaged(): Promise { const result = await this.#simctl(["list", "-j", "devices"], COMMAND_TIMEOUT_MS); - const parsed = parseManagedDevices(JSON.parse(result.stdout) as unknown); + const listed = parseManagedDevices(JSON.parse(result.stdout) as unknown); + // why: Xcode's set also holds the user's own simulators and other Simlocks'; only a + // durable mark naming this instance proves a simulator here is ours. + const parsed = + this.#rootOptions !== undefined + ? listed + : ( + await Promise.all( + listed.map(async (device) => + (await this.#isMarkedByThisInstance(device.udid)) ? [device] : [], + ), + ) + ).flat(); const devices: ObservedDevice[] = await Promise.all( parsed.map(async (device) => { const mark = await this.#readMark(device.udid); @@ -1307,7 +1348,7 @@ export class IosSimctlDriver implements Driver { } virtualLeaseEnvironment(): Readonly> { - return { SIMLOCK_IOS_DEVICE_SET: this.#deviceRoot }; + return this.#rootOptions === undefined ? {} : { SIMLOCK_IOS_DEVICE_SET: this.#deviceRoot }; } leaseEnvironment(device: DriverDevice): Readonly> { @@ -1346,7 +1387,7 @@ export class IosSimctlDriver implements Driver { const scopedArgs = device === undefined ? args : fillSimctlDevice(args, device.deviceId); // why: No environment: simctl ignores every variable that could carry the device set. return await Promise.resolve({ - args: ["simctl", "--set", this.#deviceRoot, ...scopedArgs], + args: this.#scopedSimctlArgv(scopedArgs), command: "xcrun", env: {}, }); @@ -1462,6 +1503,7 @@ export class IosSimctlDriver implements Driver { async #writeMark(udid: string): Promise { const token = this.#idGenerator.generate(); const contents = JSON.stringify({ + instanceId: this.#instanceId, token, udid, writtenAt: new Date(this.#clock.now()).toISOString(), @@ -1486,17 +1528,45 @@ export class IosSimctlDriver implements Driver { } async #readToken(path: string): Promise { + return (await this.#readMarkFile(path))?.token; + } + + async #readMarkFile( + path: string, + ): Promise<{ readonly token: string; readonly instanceId: string | undefined } | undefined> { try { const contents = await this.#filesystem.readFile(path); const parsed = JSON.parse(contents) as unknown; - return isRecord(parsed) && typeof parsed.token === "string" && parsed.token !== "" - ? parsed.token - : undefined; + if (!isRecord(parsed) || typeof parsed.token !== "string" || parsed.token === "") { + return undefined; + } + return { + instanceId: + typeof parsed.instanceId === "string" && parsed.instanceId !== "" + ? parsed.instanceId + : undefined, + token: parsed.token, + }; } catch { return undefined; } } + /** why: Under Xcode's set the default set holds this instance's simulators too, and they are not foreign. */ + async #ownedUdids(listed: unknown): Promise> { + if (this.#rootOptions !== undefined || !isRecord(listed) || !isRecord(listed.devices)) { + return new Set(); + } + const udids = parseManagedDevices(listed).map((device) => device.udid); + const owned = await Promise.all(udids.map((udid) => this.#isMarkedByThisInstance(udid))); + return new Set(udids.filter((_udid, index) => owned[index])); + } + + async #isMarkedByThisInstance(udid: string): Promise { + const durable = await this.#readMarkFile(join(this.#deviceDirectory(udid), MARK_FILE_NAME)); + return durable?.instanceId === this.#instanceId; + } + async #resolvedSpec(spec: VirtualSpec): Promise { const existing = this.#resolvedSpecs.get(specKey(spec)); if (existing !== undefined) { @@ -1576,6 +1646,8 @@ export class IosSimctlDriver implements Driver { } #scopedSimctlArgv(args: readonly string[]): string[] { + // why: Xcode's own set is simctl's default, so a call there carries no `--set`. + if (this.#rootOptions === undefined) return ["simctl", ...args]; return ["simctl", "--set", this.#deviceRoot, ...args]; } @@ -1716,6 +1788,32 @@ function configuredDeviceCaptureCommand(options: IosSimctlDriverOptions): string return typeof configured === "string" && configured !== "" ? configured : undefined; } +function configuredDeviceSet(options: IosSimctlDriverOptions): "simlock" | "default" { + const configured = options.driverConfig["deviceSet"]; + if (configured === undefined || configured === "simlock") return "simlock"; + if (configured !== "default") { + throw new OwnedRootError( + `Refusing the ios device set: drivers.ios.deviceSet must be "simlock" or "default", but it is ${JSON.stringify(configured)}`, + "invalid-device-set", + typeof configured === "string" ? configured : JSON.stringify(configured), + "ios", + ); + } + if (options.driverConfig["deviceRoot"] !== undefined) { + throw new OwnedRootError( + `Refusing the ios device set: drivers.ios.deviceRoot names Simlock's own set, but drivers.ios.deviceSet is "default". Remove one of them.`, + "invalid-device-set", + configured, + "ios", + ); + } + return "default"; +} + +function xcodeDeviceSetPath(options: IosSimctlDriverOptions): string { + return join(options.coreSimulatorRoot ?? ".", "Devices"); +} + function configuredDeviceRoot(options: IosSimctlDriverOptions): string { const configured = options.driverConfig["deviceRoot"]; @@ -1986,13 +2084,18 @@ interface DefaultSetDevices { readonly unused: number; } -function devicesPerRuntime(value: unknown): ReadonlyMap { +function devicesPerRuntime( + value: unknown, + owned: ReadonlySet, +): ReadonlyMap { if (!isRecord(value) || !isRecord(value.devices)) { throw new DriverCrashError("Invalid simctl device list JSON"); } return new Map( Object.entries(value.devices).map(([runtime, devices]): [string, DefaultSetDevices] => { - const entries: readonly unknown[] = Array.isArray(devices) ? devices : []; + const entries: readonly unknown[] = (Array.isArray(devices) ? devices : []).filter( + (device: unknown) => !(isRecord(device) && owned.has(String(device.udid))), + ); const used = entries.filter( (device) => !isRecord(device) || device.lastUsedAt !== undefined, ).length; diff --git a/src/drivers/ios/screen-capture.ts b/src/drivers/ios/screen-capture.ts index 9addcedd1..45efd81be 100644 --- a/src/drivers/ios/screen-capture.ts +++ b/src/drivers/ios/screen-capture.ts @@ -19,7 +19,8 @@ export interface IosScreenCaptureOptions { readonly clock: Clock; readonly filesystem: Filesystem; readonly baseEnvironment: NodeJS.ProcessEnv; - readonly deviceSet: string; + /** why: absent when the simulators live in Xcode's own set, which the capture program searches by default. */ + readonly deviceSet?: string; readonly onLog?: (line: string) => void; } @@ -118,9 +119,14 @@ function startCapture( } function envFor(options: IosScreenCaptureOptions, physical: boolean): NodeJS.ProcessEnv { - const { SIMLOCK_COREDEVICE_INFO_PLIST: _coreDevicePlist, ...environment } = - options.baseEnvironment; - return physical ? environment : { ...environment, SIMLOCK_IOS_DEVICE_SET: options.deviceSet }; + const { + SIMLOCK_COREDEVICE_INFO_PLIST: _coreDevicePlist, + SIMLOCK_IOS_DEVICE_SET: _daemonDeviceSet, + ...environment + } = options.baseEnvironment; + return physical || options.deviceSet === undefined + ? environment + : { ...environment, SIMLOCK_IOS_DEVICE_SET: options.deviceSet }; } // why: a program that dies mid-picture ends its stdout like one that finished, so the body asks how diff --git a/src/instructions/index.test.ts b/src/instructions/index.test.ts index 2737ca856..78eacddcd 100644 --- a/src/instructions/index.test.ts +++ b/src/instructions/index.test.ts @@ -60,6 +60,9 @@ const REQUIRED: Readonly> = LEASE_ENDED_MESSAGE, ], "## iOS builds": [ + "`SIMLOCK_IOS_DEVICE_SET`", + "-DVTSimulatorSetLocation", + "Xcode 27", "`xcodebuild -destination id=`", "`react-native run-ios`", "`expo run:ios`", diff --git a/src/instructions/index.ts b/src/instructions/index.ts index ef73f73d0..83b845625 100644 --- a/src/instructions/index.ts +++ b/src/instructions/index.ts @@ -24,10 +24,12 @@ const AGENT_DEVICE = `## agent-device const IOS_BUILDS = `## iOS builds -Simlock's simulators live in a device set of their own, not the default set that Xcode and \`xcrun simctl\` use. A tool that picks a simulator from the default set cannot find them. +Look at the grant's \`environment\`. The build route depends on whether it holds \`SIMLOCK_IOS_DEVICE_SET\`. -- Do not build against your leased device: \`xcodebuild -destination id=\`, \`react-native run-ios\`, \`expo run:ios\`, Detox and Maestro cannot find it, so they fail. Build for any simulator instead with \`-destination 'generic/platform=iOS Simulator'\`. -- Then install the \`.app\` with \`agent-device install\`, or with \`simlock simctl install \` when the path is on the machine that runs Simlock.`; +- With \`SIMLOCK_IOS_DEVICE_SET\`, the simulator is in a device set of Simlock's own, not the default set that Xcode and \`xcrun simctl\` use. A tool that picks a simulator from the default set cannot find it. \`xcodebuild -destination id=\`, \`react-native run-ios\`, \`expo run:ios\`, Detox and Maestro fail on it. +- With Xcode 27 or later, build for the device with \`-DVTSimulatorSetLocation="$SIMLOCK_IOS_DEVICE_SET"\` added to \`xcodebuild\`, and \`-destination "platform=iOS Simulator,id="\`. +- With an older Xcode, or a tool that takes no extra \`xcodebuild\` arguments, build for any simulator with \`-destination 'generic/platform=iOS Simulator'\`. Then install the \`.app\` with \`agent-device install\`, or with \`simlock simctl install \` when the path is on the machine that runs Simlock. +- Without \`SIMLOCK_IOS_DEVICE_SET\`, the simulator is in Xcode's own set: build and run it by its UDID with \`-destination "platform=iOS Simulator,id="\`, or with any tool that takes a simulator UDID.`; const SIMLOCK_CLI = `## simlock CLI diff --git a/src/slow-e2e.test.ts b/src/slow-e2e.test.ts index 67a9720b5..b41babda5 100644 --- a/src/slow-e2e.test.ts +++ b/src/slow-e2e.test.ts @@ -1,5 +1,12 @@ import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -119,6 +126,56 @@ describe("scripts/slow-e2e.sh", () => { expect(existsSync(lane)).toBe(false); }); + it("clean and stop delete, before removing the lane, the simulators whose durable mark names a lane instance, and no other", () => { + const { dir, env, lane } = machine(); + const devices = join(dir, "Devices"); + const bin = join(dir, "bin"); + const calls = join(dir, "xcrun-calls.txt"); + mkdirSync(bin); + writeFileSync(join(bin, "xcrun"), `#!/bin/sh\necho "$@" >> "${calls}"\n`); + chmodSync(join(bin, "xcrun"), 0o755); + const mark = (udid: string, body: string | undefined) => { + mkdirSync(join(devices, udid), { recursive: true }); + if (body !== undefined) writeFileSync(join(devices, udid, "simlock-mark.json"), body); + }; + mark("MINE-1", JSON.stringify({ instanceId: "lane-one", token: "t", udid: "MINE-1" })); + mark("MINE-2", JSON.stringify({ instanceId: "lane-two", token: "t", udid: "MINE-2" })); + mark("OTHER", JSON.stringify({ instanceId: "somebody-else", token: "t", udid: "OTHER" })); + mark("NO-INSTANCE", JSON.stringify({ token: "t", udid: "NO-INSTANCE" })); + mark("UNMARKED", undefined); + mark("CORRUPT", "{not json"); + const seedLane = () => { + mkdirSync(join(lane, "simlock-e2e-abc"), { recursive: true }); + mkdirSync(join(lane, "simlock-e2e-def", "gateway"), { recursive: true }); + writeFileSync( + join(lane, "simlock-e2e-abc", "instance.json"), + JSON.stringify({ instanceId: "lane-one" }), + ); + writeFileSync( + join(lane, "simlock-e2e-def", "gateway", "instance.json"), + JSON.stringify({ instanceId: "lane-two" }), + ); + }; + + for (const command of ["clean", "stop"]) { + seedLane(); + writeFileSync(calls, ""); + const result = spawnSync("sh", [SCRIPT, command], { + cwd: join(dir, "repo"), + env: { ...env, PATH: `${bin}:${process.env.PATH ?? ""}`, SIMLOCK_SLOW_DEVICES: devices }, + encoding: "utf8", + timeout: 60_000, + }); + + expect(result.status, `${command}: ${result.stderr}`).toBe(0); + const deleted = readFileSync(calls, "utf8") + .split("\n") + .filter((line) => line.startsWith("simctl delete ")); + expect(deleted.sort(), command).toEqual(["simctl delete MINE-1", "simctl delete MINE-2"]); + expect(existsSync(lane), `${command} removed the lane`).toBe(false); + } + }); + it("clean is refused while a run holds the lock", () => { const { lock, run } = machine(); const runner = idleNode(process.env); diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 7dac070d4..c214c1f62 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -98,6 +98,7 @@ export default defineConfig({ doc("using-devices/choosing-a-device", "Choose a device"), doc("using-devices/lease-lifecycle", "Lease lifecycle"), doc("using-devices/working-with-a-device", "Work with a leased device"), + doc("using-devices/build-and-run", "Build and run your app"), doc("using-devices/physical-devices", "Physical devices"), doc("using-devices/watching-the-screen", "Watch the screen"), doc("using-devices/agent-device", "agent-device"), diff --git a/website/src/content/docs/docs/introduction/how-it-works.mdx b/website/src/content/docs/docs/introduction/how-it-works.mdx index 97e31ee8d..2863581bd 100644 --- a/website/src/content/docs/docs/introduction/how-it-works.mdx +++ b/website/src/content/docs/docs/introduction/how-it-works.mdx @@ -11,7 +11,7 @@ A device is something an agent can run an app on. Simlock manages three kinds: - **Android emulators**, on macOS or Linux. - **Physical devices**: an iPhone, iPad or Android phone that an admin plugged in and enrolled with `simlock device add`. -Simlock creates, boots, wipes and deletes the simulators and emulators it manages. It keeps them apart from your own: they live in a separate device set and use a separate adb server, so Xcode's device list and your usual `adb devices` do not show them. Simlock never changes a simulator or emulator it did not create. +Simlock creates, boots, wipes and deletes the simulators and emulators it manages. It keeps them apart from your own: by default they live in a separate iOS device set (`drivers.ios.deviceSet` `simlock`) and use a separate adb server, so Xcode's device list and your usual `adb devices` do not show them. An admin can set `drivers.ios.deviceSet` to `default` to keep iOS simulators in Xcode's own device set, so build tools reach them by UDID. Under either setting Simlock never changes a simulator or emulator it did not create. Simlock never creates, erases or deletes a physical device. See [Physical devices](/simlock/docs/using-devices/physical-devices). diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx index bb6893c52..56c52415b 100644 --- a/website/src/content/docs/docs/reference/cli.mdx +++ b/website/src/content/docs/docs/reference/cli.mdx @@ -154,7 +154,7 @@ Releasing a lease taken under a different [requester identity](#requester-identi ## simlock simctl -Run `xcrun simctl` against Simlock's iOS device set. Simlock's simulators live in their own set, so a bare `xcrun simctl` does not see them. See [Working with a device](/simlock/docs/using-devices/working-with-a-device). +Run `xcrun simctl` against Simlock's iOS device set. Under `drivers.ios.deviceSet` `simlock` (the default), Simlock's simulators live in their own set, so a bare `xcrun simctl` does not see them; under `default` this runs plain `xcrun simctl` in Xcode's set. See [Working with a device](/simlock/docs/using-devices/working-with-a-device). ```text simlock simctl [--lease ] @@ -685,7 +685,7 @@ A daemon started on demand inherits the environment of the command that started | Variable | When | Meaning | | --- | --- | --- | -| `SIMLOCK_IOS_DEVICE_SET` | iOS simulator | The device set to pass to `xcrun simctl --set`. | +| `SIMLOCK_IOS_DEVICE_SET` | iOS simulator under `simlock` (not set under `default`) | The device set to pass to `xcrun simctl --set`. | | `SIMLOCK_DEVICE_UDID` | Physical iOS device | The device's UDID. | | `ANDROID_ADB_SERVER_PORT` | Android | The adb server that sees the device: Simlock's own server for an emulator, `5037` for a physical device. | | `ANDROID_SERIAL` | Physical Android device | The device's adb serial. | diff --git a/website/src/content/docs/docs/reference/configuration.mdx b/website/src/content/docs/docs/reference/configuration.mdx index 52006271d..c7bc36aaa 100644 --- a/website/src/content/docs/docs/reference/configuration.mdx +++ b/website/src/content/docs/docs/reference/configuration.mdx @@ -201,6 +201,7 @@ The warm pool never downloads. A gateway never asks a worker to download for a l | `android.emulator.audio` | boolean | `true` | `false` starts emulators without audio. | Worker | | `android.emulator.bootAnimation` | boolean | `true` | `false` skips the boot animation. | Worker | | `drivers.ios.deviceRoot` | absolute path | `~/.simlock/devices/ios` | Folder that holds Simlock's simulators. A relative path turns iOS off; `simlock doctor` says why. | Worker | +| `drivers.ios.deviceSet` | `simlock` or `default` | `simlock` | Where iOS simulators live. `simlock` keeps them in `drivers.ios.deviceRoot`; `default` keeps them in Xcode's own device set, `~/Library/Developer/CoreSimulator/Devices`. Case-sensitive. Any other value, `default` together with `drivers.ios.deviceRoot`, or a switch with simulators left from the other setting turns iOS off; `simlock doctor` says why. See [Build and run your app](/simlock/docs/using-devices/build-and-run#switch-the-device-set). | Worker | | `drivers.ios.screenCaptureCommand` | path | the program installed with Simlock | Program that captures a simulator's screen for streaming. | Worker | | `drivers.ios.deviceCaptureCommand` | path | the program installed with Simlock | Program that captures a physical iPhone's screen for streaming. | Worker | | `drivers.android.deviceRoot` | absolute path | `~/.simlock/devices/android` | Folder that holds Simlock's emulators. A relative path turns Android off; `simlock doctor` says why. | Worker | diff --git a/website/src/content/docs/docs/reference/events.mdx b/website/src/content/docs/docs/reference/events.mdx index 0312acd21..1943d7088 100644 --- a/website/src/content/docs/docs/reference/events.mdx +++ b/website/src/content/docs/docs/reference/events.mdx @@ -187,7 +187,7 @@ as `incompatible` in `simlock --gateway worker list`. | `doctor.reconciled` | Simlock compared its records with the devices on disk (at start and on `simlock doctor`). | `driftFindings` | Relayed | | `cleanup.executed` | Cleanup shut down or deleted an idle device. | `ruleName` (`idle-shutdown` or `idle-destroy`), `action` (`shutdown` or `destroy`), `target`, `reason` | Relayed | | `disk.pressure-detected` | Free disk space fell below `diskPressure.freeBytesThreshold`. Once per crossing. | `freeBytes`, `threshold` | Relayed | -| `driver.root-rejected` | A device folder failed its ownership check, so that platform did not start. | `platform`, `root`, `reason` | Relayed | +| `driver.root-rejected` | A device folder failed its ownership check, or (iOS) `drivers.ios.deviceSet` was invalid or switched with simulators left over, so that platform did not start. Reasons include `invalid-device-set` and `device-set-changed`; `root` is then the configured value or the set that holds the leftovers. | `platform`, `root`, `reason` | Relayed | | `driver.adb-server-rejected` | Simlock's own adb server could not start, so Android did not start. | `port`, `reason` (`occupied`, `start-failed`, `invalid-port`) | Relayed |