diff --git a/packages/tool-server/src/tools/devices/list-devices.ts b/packages/tool-server/src/tools/devices/list-devices.ts index 070b4604..4ce31194 100644 --- a/packages/tool-server/src/tools/devices/list-devices.ts +++ b/packages/tool-server/src/tools/devices/list-devices.ts @@ -150,8 +150,10 @@ async function resolveVvdShadowAdbSerials( // entirely — see listVegaDevices — so the wedged-VVD case is just ~6s.) // - Android: one bounded `adb devices` call (6s) + ~5s concurrent getprop // enrichment = ~11s. -// - iOS / AVD-list / Chromium self-bound by their own subprocess/socket timeouts -// (iOS `simctl` ~10s, AVD-list ~5s, Chromium <1s) — all comfortably under 25s. +// - iOS: waits up to 12s for another argent process' host-wide simctl-list lock +// and then runs a 10s bounded `simctl list devices` probe — still under 25s. +// - AVD-list / Chromium self-bound by their own subprocess/socket timeouts +// (AVD-list ~5s, Chromium <1s) — both comfortably under 25s. // The Vega binary resolution (`resolveVegaBinary`) runs first but is memoized and // returns the instant `vega` is found, so it adds ~0 in practice; only a pathological // cold-session `command -v` shell-fork hang would add up to ~4s on top of the 20s, @@ -210,11 +212,15 @@ Booted/ready devices are listed first. Platforms whose CLI is unavailable are si // out to potentially-wedged devices; iOS / AVD-list / Chromium already self-bound // with their own short subprocess/socket timeouts, but wrapping them too makes the // "no branch can hang the fan-out" guarantee universal at near-zero cost (the - // timer is cleared on the fast happy path). The deadline only substitutes a - // fallback on *slowness*; a rejection still propagates exactly as before — so the - // `.catch(() => [])` wrappers (and the lack of one on iOS/AVDs) are unchanged. + // timer is cleared on the fast happy path). Branch-level discovery failures + // degrade to that branch's empty result so one platform issue does not hide + // working devices from the others. const [ios, iosRemote, android, avds, chromium, vega] = await Promise.all([ - withDeadline(listIosSimulators(), [], "ios"), + withDeadline( + listIosSimulators().catch(() => []), + [], + "ios" + ), withDeadline(listRemoteIosSimulators(), [], "ios-remote"), withDeadline( // Opt into runtimeKind enrichment (list-devices surfaces TV vs mobile to diff --git a/packages/tool-server/src/utils/ios-devices.ts b/packages/tool-server/src/utils/ios-devices.ts index a2879af3..eccfdf88 100644 --- a/packages/tool-server/src/utils/ios-devices.ts +++ b/packages/tool-server/src/utils/ios-devices.ts @@ -1,5 +1,15 @@ import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { open, readFile, stat, unlink, type FileHandle } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { promisify } from "node:util"; +import { + SIMCTL_LIST_DEVICES_LOCK_RETRY_MS, + SIMCTL_LIST_DEVICES_LOCK_STALE_MS, + SIMCTL_LIST_DEVICES_LOCK_WAIT_MS, + SIMCTL_LIST_DEVICES_TIMEOUT_MS, +} from "./simctl-config"; const execFileAsync = promisify(execFile); @@ -19,10 +29,257 @@ interface SimctlDevice { isAvailable: boolean; } -interface SimctlOutput { +export interface SimctlOutput { devices: Record; } +const DEFAULT_SIMCTL_LIST_DEVICES_LOCK_PATH = join(tmpdir(), "argent-simctl-list-devices.lock"); +const SIMCTL_LIST_DEVICES_LOCK_LIVE_OWNER_MAX_MS = SIMCTL_LIST_DEVICES_LOCK_STALE_MS * 4; +let simctlListDevicesLockPath: string | null = DEFAULT_SIMCTL_LIST_DEVICES_LOCK_PATH; + +interface SimctlListDevicesLockMetadata { + createdAt: number; + ownerId: string; + pid: number; +} + +class SimctlListDevicesLockError extends Error { + constructor( + message: string, + readonly cause?: unknown + ) { + super(message); + this.name = "SimctlListDevicesLockError"; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isNodeError(err: unknown): err is NodeJS.ErrnoException { + return err instanceof Error && "code" in err; +} + +function isSimctlListDevicesLockError(err: unknown): err is SimctlListDevicesLockError { + return err instanceof SimctlListDevicesLockError; +} + +function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + return isNodeError(err) && err.code === "EPERM"; + } +} + +function isLockOwnedByLiveProcess(metadata: SimctlListDevicesLockMetadata, now: number): boolean { + if (now - metadata.createdAt >= SIMCTL_LIST_DEVICES_LOCK_LIVE_OWNER_MAX_MS) return false; + return isProcessAlive(metadata.pid); +} + +function parseSimctlListDevicesLockMetadata(raw: string): SimctlListDevicesLockMetadata | null { + try { + const parsed = JSON.parse(raw) as { + createdAt?: unknown; + ownerId?: unknown; + pid?: unknown; + }; + if ( + typeof parsed.createdAt === "number" && + typeof parsed.ownerId === "string" && + typeof parsed.pid === "number" + ) { + return { + createdAt: parsed.createdAt, + ownerId: parsed.ownerId, + pid: parsed.pid, + }; + } + } catch { + return null; + } + return null; +} + +async function readSimctlListDevicesLockMetadata( + lockPath: string +): Promise { + try { + return parseSimctlListDevicesLockMetadata(await readFile(lockPath, "utf8")); + } catch (err) { + if (isNodeError(err) && err.code === "ENOENT") return null; + throw err; + } +} + +async function getSimctlListDevicesLockCreatedAt(lockPath: string): Promise { + const metadata = await readSimctlListDevicesLockMetadata(lockPath); + if (metadata) return metadata.createdAt; + + try { + return (await stat(lockPath)).mtimeMs; + } catch (err) { + if (isNodeError(err) && err.code === "ENOENT") return undefined; + throw err; + } +} + +async function acquireSimctlListDevicesLock( + lockPath: string, + now: number +): Promise { + const metadata: SimctlListDevicesLockMetadata = { + createdAt: now, + ownerId: randomUUID(), + pid: process.pid, + }; + let handle: FileHandle | undefined; + + try { + handle = await open(lockPath, "wx"); + await handle.writeFile(JSON.stringify(metadata)); + return metadata; + } catch (err) { + if (handle) { + await handle.close().catch(() => {}); + await unlink(lockPath).catch(() => {}); + } + if (isNodeError(err) && err.code === "EEXIST") return null; + throw new SimctlListDevicesLockError( + `failed to acquire simctl list devices lock at ${lockPath}`, + err + ); + } finally { + await handle?.close().catch(() => {}); + } +} + +async function releaseSimctlListDevicesLock( + lockPath: string, + metadata: SimctlListDevicesLockMetadata +): Promise { + const currentMetadata = await readSimctlListDevicesLockMetadata(lockPath); + if (currentMetadata?.ownerId !== metadata.ownerId) return; + + try { + await unlink(lockPath); + } catch (err) { + if (!isNodeError(err) || err.code !== "ENOENT") { + throw new SimctlListDevicesLockError( + `failed to release simctl list devices lock at ${lockPath}`, + err + ); + } + } +} + +async function removeStaleCleanupLock(cleanupLockPath: string, now: number): Promise { + const createdAt = await getSimctlListDevicesLockCreatedAt(cleanupLockPath); + if (createdAt === undefined || now - createdAt < SIMCTL_LIST_DEVICES_LOCK_STALE_MS) return; + + const metadata = await readSimctlListDevicesLockMetadata(cleanupLockPath); + if (metadata && isLockOwnedByLiveProcess(metadata, now)) return; + + try { + await unlink(cleanupLockPath); + } catch (err) { + if (!isNodeError(err) || err.code !== "ENOENT") { + throw new SimctlListDevicesLockError( + `failed to remove stale simctl list devices cleanup lock at ${cleanupLockPath}`, + err + ); + } + } +} + +async function removeStaleSimctlListDevicesLock(lockPath: string, now: number): Promise { + const cleanupLockPath = `${lockPath}.cleanup`; + let cleanupMetadata = await acquireSimctlListDevicesLock(cleanupLockPath, now); + if (!cleanupMetadata) { + await removeStaleCleanupLock(cleanupLockPath, now); + cleanupMetadata = await acquireSimctlListDevicesLock(cleanupLockPath, Date.now()); + if (!cleanupMetadata) return; + } + + try { + await removeStaleSimctlListDevicesLockWithCleanupLock(lockPath, now); + } finally { + await releaseSimctlListDevicesLock(cleanupLockPath, cleanupMetadata); + } +} + +async function removeStaleSimctlListDevicesLockWithCleanupLock( + lockPath: string, + now: number +): Promise { + const createdAt = await getSimctlListDevicesLockCreatedAt(lockPath); + if (createdAt === undefined) return; + + if (now - createdAt < SIMCTL_LIST_DEVICES_LOCK_STALE_MS) return; + + const metadata = await readSimctlListDevicesLockMetadata(lockPath); + if (metadata && isLockOwnedByLiveProcess(metadata, now)) return; + + try { + if (metadata) await releaseSimctlListDevicesLock(lockPath, metadata); + else await unlink(lockPath); + } catch (err) { + if (!isNodeError(err) || err.code !== "ENOENT") { + throw new SimctlListDevicesLockError( + `failed to remove stale simctl list devices lock at ${lockPath}`, + err + ); + } + } +} + +async function withSimctlListDevicesLock(fn: () => Promise): Promise { + const lockPath = simctlListDevicesLockPath; + if (!lockPath) return await fn(); + + const started = Date.now(); + + while (true) { + const metadata = await acquireSimctlListDevicesLock(lockPath, Date.now()); + if (metadata) { + try { + return await fn(); + } finally { + await releaseSimctlListDevicesLock(lockPath, metadata); + } + } + + const now = Date.now(); + await removeStaleSimctlListDevicesLock(lockPath, now); + const elapsed = now - started; + if (elapsed >= SIMCTL_LIST_DEVICES_LOCK_WAIT_MS) { + throw new SimctlListDevicesLockError( + `timed out waiting for simctl list devices lock after ${SIMCTL_LIST_DEVICES_LOCK_WAIT_MS}ms` + ); + } + await sleep( + Math.min(SIMCTL_LIST_DEVICES_LOCK_RETRY_MS, SIMCTL_LIST_DEVICES_LOCK_WAIT_MS - elapsed) + ); + } +} + +/** + * Read CoreSimulator's device inventory with a host-wide cap: even if multiple + * tool-server processes are alive, only one of them should run + * `xcrun simctl list devices --json` at a time. + */ +export async function readSimctlDevices(): Promise { + return await withSimctlListDevicesLock(async () => { + const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "--json"], { + timeout: SIMCTL_LIST_DEVICES_TIMEOUT_MS, + }); + return JSON.parse(stdout) as SimctlOutput; + }); +} + /** * List all available iOS and tvOS simulators via `xcrun simctl list devices --json`. * Returns an empty array when xcrun is missing or the call fails so the @@ -30,10 +287,7 @@ interface SimctlOutput { */ export async function listIosSimulators(): Promise { try { - const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "--json"], { - timeout: 10_000, - }); - const data: SimctlOutput = JSON.parse(stdout); + const data = await readSimctlDevices(); const out: IosSimulator[] = []; for (const [runtimeId, devices] of Object.entries(data.devices)) { // Accept both iOS and tvOS runtimes @@ -51,7 +305,8 @@ export async function listIosSimulators(): Promise { } } return out; - } catch { + } catch (err) { + if (isSimctlListDevicesLockError(err)) throw err; return []; } } @@ -113,3 +368,20 @@ export function getCachedSimulatorRuntimeKind(udid: string): "mobile" | "tv" | u export function __resetSimulatorRuntimeKindCacheForTesting(): void { runtimeKindCache.clear(); } + +/** Test-only: isolate the cross-process simctl lock so parallel test workers do + * not contend on the real host lock. */ +export function __setSimctlListDevicesLockPathForTesting(path: string | null): void { + simctlListDevicesLockPath = path; +} + +export function __resetSimctlListDevicesLockPathForTesting(): void { + simctlListDevicesLockPath = DEFAULT_SIMCTL_LIST_DEVICES_LOCK_PATH; +} + +export async function __removeStaleSimctlListDevicesLockForTesting( + lockPath: string, + now: number +): Promise { + await removeStaleSimctlListDevicesLock(lockPath, now); +} diff --git a/packages/tool-server/src/utils/simctl-config.ts b/packages/tool-server/src/utils/simctl-config.ts index f854fdad..40d167ee 100644 --- a/packages/tool-server/src/utils/simctl-config.ts +++ b/packages/tool-server/src/utils/simctl-config.ts @@ -5,3 +5,19 @@ * CoreSimulatorService blocking simctl forever, so the watcher's backoff * would never fire). */ export const SIMCTL_SPAWN_TIMEOUT_MS = 10_000; + +/** Ceiling for `xcrun simctl list devices --json`, the hot-path discovery call + * used by list-devices, runtime-kind resolution, and the simulator watcher. */ +export const SIMCTL_LIST_DEVICES_TIMEOUT_MS = 10_000; + +/** Wait briefly for another argent process' simctl-list probe to finish instead + * of spawning another one. This stays below list-devices' branch deadline when + * combined with SIMCTL_LIST_DEVICES_TIMEOUT_MS. */ +export const SIMCTL_LIST_DEVICES_LOCK_WAIT_MS = 12_000; + +/** Recover from a crashed process that acquired the discovery lock but never + * removed it. This is above the subprocess timeout so a healthy holder is not + * treated as stale under normal load. */ +export const SIMCTL_LIST_DEVICES_LOCK_STALE_MS = 15_000; + +export const SIMCTL_LIST_DEVICES_LOCK_RETRY_MS = 50; diff --git a/packages/tool-server/src/utils/simulator-watcher.ts b/packages/tool-server/src/utils/simulator-watcher.ts index 5fcb19ba..977e4ac0 100644 --- a/packages/tool-server/src/utils/simulator-watcher.ts +++ b/packages/tool-server/src/utils/simulator-watcher.ts @@ -1,21 +1,15 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import type { Registry } from "@argent/registry"; import { NATIVE_DEVTOOLS_NAMESPACE, nativeDevtoolsRef, type NativeDevtoolsApi, } from "../blueprints/native-devtools"; - -const execFileAsync = promisify(execFile); +import { readSimctlDevices } from "./ios-devices"; const POLL_INTERVAL_MS = 10_000; async function getBootedUdids(): Promise> { - const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "--json"]); - const data = JSON.parse(stdout) as { - devices: Record>; - }; + const data = await readSimctlDevices(); const udids = new Set(); for (const devices of Object.values(data.devices)) { for (const device of devices) { diff --git a/packages/tool-server/test/ios-devices-runtime-kind.test.ts b/packages/tool-server/test/ios-devices-runtime-kind.test.ts index 234f03e8..0224c345 100644 --- a/packages/tool-server/test/ios-devices-runtime-kind.test.ts +++ b/packages/tool-server/test/ios-devices-runtime-kind.test.ts @@ -1,4 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { randomUUID } from "node:crypto"; +import { access, stat, unlink, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SIMCTL_LIST_DEVICES_LOCK_STALE_MS } from "../src/utils/simctl-config"; const execFileMock = vi.fn(); @@ -14,7 +19,15 @@ vi.mock("node:child_process", async () => { ) => { const callback = typeof opts === "function" ? opts : cb!; const options = typeof opts === "function" ? undefined : opts; - const result = execFileMock(cmd, args, options); + const result = execFileMock(cmd, args, options, callback); + if ( + result && + typeof result === "object" && + "__asyncHandled" in result && + result.__asyncHandled === true + ) { + return; + } if (result instanceof Error) callback(result, { stdout: "", stderr: "" }); else callback(null, result ?? { stdout: "", stderr: "" }); }, @@ -26,10 +39,24 @@ import { getCachedSimulatorRuntimeKind, cacheSimulatorRuntimeKind, __resetSimulatorRuntimeKindCacheForTesting, + __setSimctlListDevicesLockPathForTesting, + __resetSimctlListDevicesLockPathForTesting, + __removeStaleSimctlListDevicesLockForTesting, + listIosSimulators, } from "../src/utils/ios-devices"; const TV_UDID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"; const PHONE_UDID = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"; +const SIMCTL_LIST_DEVICES_LOCK_LIVE_OWNER_MAX_MS = SIMCTL_LIST_DEVICES_LOCK_STALE_MS * 4; +let testLockPath: string; + +function lockMetadata(createdAt: number, pid = process.pid) { + return JSON.stringify({ + createdAt, + ownerId: randomUUID(), + pid, + }); +} // Shape a `simctl list devices --json` payload: one tvOS device and one iOS // device, so both a "tv" and a "mobile" verdict can be resolved from one probe. @@ -70,6 +97,17 @@ function mockSimctl(): void { beforeEach(() => { execFileMock.mockReset(); __resetSimulatorRuntimeKindCacheForTesting(); + testLockPath = join( + tmpdir(), + `argent-test-simctl-list-devices-${process.pid}-${randomUUID()}.lock` + ); + __setSimctlListDevicesLockPathForTesting(testLockPath); +}); + +afterEach(async () => { + __resetSimctlListDevicesLockPathForTesting(); + await unlink(testLockPath).catch(() => {}); + await unlink(`${testLockPath}.cleanup`).catch(() => {}); }); describe("getCachedSimulatorRuntimeKind — synchronous cache-only read", () => { @@ -123,3 +161,162 @@ describe("cacheSimulatorRuntimeKind — warm from an out-of-band verdict", () => expect(getCachedSimulatorRuntimeKind(TV_UDID)).toBeUndefined(); }); }); + +describe("listIosSimulators — simctl discovery fan-out cap", () => { + it("serializes concurrent simctl list probes through the host lock", async () => { + let activeSimctl = 0; + let maxActiveSimctl = 0; + execFileMock.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + if (cmd !== "xcrun" || args[0] !== "simctl" || args[1] !== "list") { + cb?.(null, { stdout: "", stderr: "" }); + return { __asyncHandled: true }; + } + activeSimctl += 1; + maxActiveSimctl = Math.max(maxActiveSimctl, activeSimctl); + setTimeout(() => { + activeSimctl -= 1; + cb?.(null, { + stdout: JSON.stringify({ + devices: { + "com.apple.CoreSimulator.SimRuntime.iOS-18-0": [ + { + udid: PHONE_UDID, + name: "iPhone 16", + state: "Booted", + deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-16", + isAvailable: true, + }, + ], + }, + }), + stderr: "", + }); + }, 20); + return { __asyncHandled: true }; + } + ); + + const results = await Promise.all([ + listIosSimulators(), + listIosSimulators(), + listIosSimulators(), + ]); + expect(results.map((devices) => devices[0]?.udid)).toEqual([ + PHONE_UDID, + PHONE_UDID, + PHONE_UDID, + ]); + expect(maxActiveSimctl).toBe(1); + expect(execFileMock).toHaveBeenCalledTimes(3); + }); + + it("keeps a fresh malformed lock file instead of treating it as stale", async () => { + await writeFile(testLockPath, ""); + const freshMtimeMs = (await stat(testLockPath)).mtimeMs; + + await __removeStaleSimctlListDevicesLockForTesting(testLockPath, freshMtimeMs + 1); + + await expect(access(testLockPath)).resolves.toBeUndefined(); + }); + + it("keeps a stale lock while its owner process is still alive", async () => { + await writeFile( + testLockPath, + lockMetadata(Date.now() - SIMCTL_LIST_DEVICES_LOCK_STALE_MS - 1_000) + ); + + await __removeStaleSimctlListDevicesLockForTesting(testLockPath, Date.now()); + + await expect(access(testLockPath)).resolves.toBeUndefined(); + }); + + it("removes very old locks even when the recorded PID is alive again", async () => { + const now = Date.now(); + await writeFile( + testLockPath, + lockMetadata(now - SIMCTL_LIST_DEVICES_LOCK_LIVE_OWNER_MAX_MS - 1_000) + ); + + await __removeStaleSimctlListDevicesLockForTesting(testLockPath, now); + + await expect(access(testLockPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("removes stale lock files once the owner process is gone", async () => { + await writeFile( + testLockPath, + lockMetadata(Date.now() - SIMCTL_LIST_DEVICES_LOCK_STALE_MS - 1_000, 0) + ); + + await __removeStaleSimctlListDevicesLockForTesting(testLockPath, Date.now()); + + await expect(access(testLockPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("removes malformed lock files once they are old enough to be stale", async () => { + await writeFile(testLockPath, ""); + const staleMtimeMs = Date.now() - SIMCTL_LIST_DEVICES_LOCK_STALE_MS - 1_000; + await utimes(testLockPath, staleMtimeMs / 1_000, staleMtimeMs / 1_000); + + await __removeStaleSimctlListDevicesLockForTesting(testLockPath, Date.now()); + + await expect(access(testLockPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("leaves stale lock cleanup to one contender at a time", async () => { + await writeFile(testLockPath, ""); + const staleMtimeMs = Date.now() - SIMCTL_LIST_DEVICES_LOCK_STALE_MS - 1_000; + await utimes(testLockPath, staleMtimeMs / 1_000, staleMtimeMs / 1_000); + await writeFile(`${testLockPath}.cleanup`, JSON.stringify({ createdAt: Date.now() })); + + await __removeStaleSimctlListDevicesLockForTesting(testLockPath, Date.now()); + + await expect(access(testLockPath)).resolves.toBeUndefined(); + }); + + it("recovers an orphaned cleanup sidecar before removing a stale lock", async () => { + const staleCreatedAt = Date.now() - SIMCTL_LIST_DEVICES_LOCK_STALE_MS - 1_000; + await writeFile(testLockPath, lockMetadata(staleCreatedAt, 0)); + await writeFile(`${testLockPath}.cleanup`, lockMetadata(staleCreatedAt, 0)); + + await __removeStaleSimctlListDevicesLockForTesting(testLockPath, Date.now()); + + await expect(access(testLockPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(`${testLockPath}.cleanup`)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("recovers very old cleanup sidecars even when the recorded PID is alive again", async () => { + const now = Date.now(); + const staleCreatedAt = now - SIMCTL_LIST_DEVICES_LOCK_LIVE_OWNER_MAX_MS - 1_000; + await writeFile(testLockPath, lockMetadata(staleCreatedAt, 0)); + await writeFile(`${testLockPath}.cleanup`, lockMetadata(staleCreatedAt)); + + await __removeStaleSimctlListDevicesLockForTesting(testLockPath, now); + + await expect(access(testLockPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(`${testLockPath}.cleanup`)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("does not hide lock acquisition failures as an empty simulator list", async () => { + const missingDirectory = join( + tmpdir(), + `argent-test-missing-lock-dir-${process.pid}-${randomUUID()}` + ); + __setSimctlListDevicesLockPathForTesting(join(missingDirectory, "simctl.lock")); + mockSimctl(); + + await expect(listIosSimulators()).rejects.toThrow("failed to acquire simctl list devices lock"); + }); + + it("still returns an empty list when simctl itself fails", async () => { + execFileMock.mockReturnValue(new Error("xcrun unavailable")); + + await expect(listIosSimulators()).resolves.toEqual([]); + }); +}); diff --git a/packages/tool-server/test/list-devices-deadline.test.ts b/packages/tool-server/test/list-devices-deadline.test.ts index 35d54fe0..8dd4f184 100644 --- a/packages/tool-server/test/list-devices-deadline.test.ts +++ b/packages/tool-server/test/list-devices-deadline.test.ts @@ -6,6 +6,10 @@ import { } from "../src/utils/vega-devices"; import { VVD_PS_PROBE_TIMEOUT_MS } from "../src/utils/vega-process"; import { ADB_DEVICES_TIMEOUT_MS, ENRICH_TIMEOUT_MS } from "../src/utils/adb"; +import { + SIMCTL_LIST_DEVICES_LOCK_WAIT_MS, + SIMCTL_LIST_DEVICES_TIMEOUT_MS, +} from "../src/utils/simctl-config"; // Guards the timeout/backstop ordering: the backstop must sit ABOVE every branch's // FULL per-call worst case, or it stops being a last-resort and starts truncating a @@ -39,6 +43,12 @@ describe("discovery timeout vs backstop invariant", () => { expect(androidWorstCase).toBeLessThan(BRANCH_DEADLINE_MS); expect(BRANCH_DEADLINE_MS - androidWorstCase).toBeGreaterThanOrEqual(MIN_MARGIN_MS); }); + + it("the iOS branch's lock wait plus simctl timeout stays under the branch deadline", () => { + const iosWorstCase = SIMCTL_LIST_DEVICES_LOCK_WAIT_MS + SIMCTL_LIST_DEVICES_TIMEOUT_MS; + expect(iosWorstCase).toBeLessThan(BRANCH_DEADLINE_MS); + expect(BRANCH_DEADLINE_MS - iosWorstCase).toBeGreaterThanOrEqual(MIN_MARGIN_MS); + }); }); // Unit cover for the hard backstop that keeps a single wedged discovery branch from diff --git a/packages/tool-server/test/list-devices.test.ts b/packages/tool-server/test/list-devices.test.ts index 34b74853..a6717052 100644 --- a/packages/tool-server/test/list-devices.test.ts +++ b/packages/tool-server/test/list-devices.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const execFileMock = vi.fn(); @@ -83,6 +86,10 @@ vi.mock("../src/utils/vega-sdk", () => ({ listVvdImages: vi.fn(async () => []) } import { listDevicesTool } from "../src/tools/devices/list-devices"; import { __resetVegaBinaryCacheForTests } from "../src/utils/vega-cli"; import { listVvdImages } from "../src/utils/vega-sdk"; +import { + __resetSimctlListDevicesLockPathForTesting, + __setSimctlListDevicesLockPathForTesting, +} from "../src/utils/ios-devices"; function simctlJson(): string { return JSON.stringify({ @@ -125,6 +132,11 @@ function simctlJson(): string { beforeEach(() => { execFileMock.mockReset(); + __resetSimctlListDevicesLockPathForTesting(); +}); + +afterEach(() => { + __resetSimctlListDevicesLockPathForTesting(); }); describe("list-devices", () => { @@ -245,6 +257,32 @@ describe("list-devices", () => { expect(result.avds.length).toBeGreaterThan(0); }); + it("omits iOS when the local simctl lock cannot be created — other platforms still returned", async () => { + const missingDirectory = join( + tmpdir(), + `argent-list-devices-missing-lock-dir-${process.pid}-${randomUUID()}` + ); + __setSimctlListDevicesLockPathForTesting(join(missingDirectory, "simctl.lock")); + execFileMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "adb" && args[0] === "devices") { + return { stdout: "List of devices attached\nemulator-5554\tdevice\n", stderr: "" }; + } + if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") { + return { stdout: "", stderr: "" }; + } + if (cmd === "emulator" && args[0] === "-list-avds") { + return { stdout: "Pixel_3a_API_34\n", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + + const result = await listDevicesTool.execute!({}, {}); + + expect(result.devices.filter((d) => d.platform === "ios")).toHaveLength(0); + expect(result.devices.filter((d) => d.platform === "android")).toHaveLength(1); + expect(result.avds.length).toBeGreaterThan(0); + }); + it("silently omits Android when adb is unavailable — iOS still returned", async () => { execFileMock.mockImplementation((cmd: string, args: string[]) => { if (cmd === "xcrun" && args[0] === "simctl") { diff --git a/packages/tool-server/test/simulator-watcher-init-failure.test.ts b/packages/tool-server/test/simulator-watcher-init-failure.test.ts index 87438cb7..7044772e 100644 --- a/packages/tool-server/test/simulator-watcher-init-failure.test.ts +++ b/packages/tool-server/test/simulator-watcher-init-failure.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; const execFileMock = vi.fn(); +const realSetImmediate = setImmediate; vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); @@ -30,6 +31,10 @@ import { type NativeDevtoolsApi, type NativeDevtoolsInitFailure, } from "../src/blueprints/native-devtools"; +import { + __resetSimctlListDevicesLockPathForTesting, + __setSimctlListDevicesLockPathForTesting, +} from "../src/utils/ios-devices"; import { startSimulatorWatcher } from "../src/utils/simulator-watcher"; const UDID = "11111111-1111-1111-1111-111111111111"; @@ -109,12 +114,24 @@ function makeRegistry(api: NativeDevtoolsApi): { }; } +function waitForRealIo(): Promise { + return new Promise((resolve) => realSetImmediate(resolve)); +} + +async function waitForMockCall(mock: ReturnType): Promise { + for (let i = 0; i < 10 && mock.mock.calls.length === 0; i += 1) { + await waitForRealIo(); + } +} + beforeEach(() => { execFileMock.mockReset(); + __setSimctlListDevicesLockPathForTesting(null); vi.spyOn(process.stderr, "write").mockImplementation(() => true); }); afterEach(() => { + __resetSimctlListDevicesLockPathForTesting(); vi.restoreAllMocks(); }); @@ -218,6 +235,7 @@ describe("simulator-watcher with api-owned init failure state", () => { bootedUdids = []; await vi.advanceTimersByTimeAsync(10_000); + await waitForMockCall(disposeService); expect(disposeService).toHaveBeenCalledWith(`NativeDevtools:${UDID}`); stop();