Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/docs/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ An environment variable changes the behavior of the process that reads it. Expor
| `ARGENT_IOS_TEAM_ID` | unset | The Apple Developer Team ID that signs the runner for [physical iOS devices](/docs/features/physical-ios-devices). Unset, Argent detects the team from the keychain of the Mac. The variable must be in the environment of the tool-server; a value in the shell of the client does not reach a running server |
| `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `ANDROID_AVD_HOME` | SDK defaults | The standard Android SDK locations. Argent reads them to find `adb`, the emulator and the AVDs |

A Simulator.app older than the installed CoreSimulator crashes at launch, for example Xcode 16.4's after an Xcode 27 install. `boot-device` notices this on the first launch and stops opening that Simulator.app, so iOS simulators boot without the window. It keeps the result per Simulator.app and CoreSimulator build in `~/.argent/simulator-app-launch.json`. Delete the file to try again.

### MCP server

| Variable | Default | Description |
Expand Down
13 changes: 11 additions & 2 deletions packages/tool-server/src/tools/devices/boot-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { ensureDep } from "../../utils/check-deps";
import { linuxBootDiagnostics } from "../../utils/linux-preflight";
import { listIosSimulators } from "../../utils/ios-devices";
import { launchSimulatorApp } from "../../utils/simulator-app-launch";
import { deviceSetForUdid, simctlPrefix } from "../../utils/ios-device-sets";
import { androidHeadlessFromEnv, iosHeadlessFromEnv } from "../../utils/no-window-env";
import { classifyDevice, stripRemotePrefix } from "../../utils/device-info";
Expand Down Expand Up @@ -541,15 +542,23 @@ async function bootIos(
* Xcode 27 replaces Simulator.app with Device Hub, which opens the device's
* own window from a `devices://device/open?id=<udid>` URL; it reports success
* even when it cannot show the device, so a missing window goes unnoticed.
* Without either app nothing is opened.
* Without either app, or with a Simulator.app on record as crashing at launch,
* nothing is opened.
*/
async function openSimulatorWindow(udid: string): Promise<void> {
const timeout = 5_000;
const { stdout } = await execFileAsync("xcode-select", ["-p"], { timeout });
const developerDir = realpathSync(stdout.trim());
const simulatorApp = join(developerDir, "Applications", "Simulator.app");
if (existsSync(simulatorApp)) {
await execFileAsync("open", ["-a", simulatorApp], { timeout });
const launched = await launchSimulatorApp(simulatorApp, () =>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: The first watched launch per key adds about 6 s to boot-device (5.9-6.3 s measured, against about 70 ms on the cached path). boot-device is not longRunning, so argent-mcp aborts each attempt at 30 s and replays it, and the server keeps running the first one. A cold boot already near that cap now hits it once, and the replay runs the boot steps a second time in parallel on the same simulator.

execFileAsync("open", ["-a", simulatorApp], { timeout })
);
if (!launched) {
process.stderr.write(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: When the window is skipped, boot-device still returns { platform: "ios", udid, booted: true }, the same result as a boot that opened a window. This line goes to the tool-server's stderr, which the launcher points at ~/.argent/tool-server.log, so neither the agent nor the user is told why no window appeared or how to reset it.

`[boot-device ${udid.slice(0, 8)}] not opening ${simulatorApp}: it exited right after an earlier launch, so the device runs without a window. Delete ~/.argent/simulator-app-launch.json to try again.\n`
);
}
return;
}
const deviceHubApp = join(developerDir, "..", "Applications", "DeviceHub.app");
Expand Down
130 changes: 130 additions & 0 deletions packages/tool-server/src/utils/simulator-app-launch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { withKeyedLock } from "./keyed-lock";
import { PS_BIN } from "./vega-process";

const execFileAsync = promisify(execFile);

const CORE_SIMULATOR_INFO_PLIST =
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/Versions/A/Resources/Info.plist";

// A Simulator.app built against an older CoreSimulator than the installed one
// (e.g. Xcode 16.4 selected after an Xcode 27 install) aborts up to ~1.7 s into
// every launch, popping a "Simulator quit unexpectedly" dialog each time.
const LAUNCH_WATCH_MS = 5_000;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: The opposite case: a crash that lands after the 5 s window is stored as a permanent runs, and verdict !== "runs" then skips the watch on every later boot, so the crash dialog comes back each time. The PR measured the abort at up to 1.64 s on an idle host; it was not measured on a heavily loaded host booting several simulators.

const LAUNCH_POLL_MS = 250;

type Verdict = "runs" | "crashes";

// Serializes launches per app, so parallel boots wait for the first verdict
// instead of each crashing the app once.
const launchLocks = new Map<string, Promise<unknown>>();

function verdictFilePath(): string {
return path.join(os.homedir(), ".argent", "simulator-app-launch.json");
}

function loadVerdicts(): Record<string, Verdict> {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(verdictFilePath(), "utf8"));
return parsed && typeof parsed === "object" ? (parsed as Record<string, Verdict>) : {};
} catch {
return {};
}
}

function persistVerdict(key: string, verdict: Verdict): void {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: The read-modify-write is serialized only inside one process. Two tool-servers sharing a HOME can each read the record, add their key and rename, and the later rename drops the earlier entry, so one more crash dialog is shown before the entry is re-recorded. With 24 processes recording distinct keys at once, 17-23 of 24 keys survived per round (20 rounds).

const filePath = verdictFilePath();
fs.mkdirSync(path.dirname(filePath), { recursive: true });
// Renamed into place, so another tool-server never reads a half-written file.
const tmpPath = `${filePath}.${process.pid}.tmp`;
try {
fs.writeFileSync(tmpPath, JSON.stringify({ ...loadVerdicts(), [key]: verdict }, null, 2));
fs.renameSync(tmpPath, filePath);
} catch (err) {
fs.rmSync(tmpPath, { force: true });
throw err;
}
}

async function bundleVersion(infoPlist: string): Promise<string> {
const { stdout } = await execFileAsync(
"plutil",
["-extract", "CFBundleVersion", "raw", "-o", "-", infoPlist],
{ timeout: 5_000 }
);
return stdout.trim();
}

/** Whether the app pairs with the installed CoreSimulator is fixed by these two builds. */
async function launchKey(simulatorApp: string): Promise<string> {
const [appBuild, coreSimulatorBuild] = await Promise.all([
bundleVersion(path.join(simulatorApp, "Contents", "Info.plist")),
bundleVersion(CORE_SIMULATOR_INFO_PLIST),
]);
return `${simulatorApp} ${appBuild} / CoreSimulator ${coreSimulatorBuild}`;
}

// `ps` prints the on-disk case and Unicode form, which a case-insensitive volume
// need not share with the path the app was opened by. Outside a UTF-8 locale (as
// a launchd-started server may be) it escapes non-ASCII bytes instead.
const comparablePath = (p: string) => p.normalize("NFC").toLowerCase();

async function isRunning(executable: string): Promise<boolean> {
const { stdout } = await execFileAsync(PS_BIN, ["-axo", "comm="], {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: -ax lists every user's processes. On a Mac where another logged-in user runs the same Xcode's Simulator.app, the pre-launch check sees that instance and never watches, so a crashing app is never recorded. If the other user's instance starts during the watch instead, a crash in this session can be stored as runs. Traced from code only; this host has one login user.

timeout: 5_000,
env: { ...process.env, LC_ALL: "en_US.UTF-8" },
});
const wanted = comparablePath(executable);
return stdout.split("\n").some((line) => comparablePath(line.trim()) === wanted);
}

async function survivesLaunch(executable: string): Promise<boolean> {
for (let waited = 0; ; waited += LAUNCH_POLL_MS) {
if (!(await isRunning(executable))) return false;
if (waited >= LAUNCH_WATCH_MS) return true;
await new Promise((resolve) => setTimeout(resolve, LAUNCH_POLL_MS));
}
}

/**
* Runs `launch` (which opens `simulatorApp`) unless this app is on record as
* crashing at launch. The first launch of each app/CoreSimulator build pair is
* watched for {@link LAUNCH_WATCH_MS}, and an app whose process is gone by then
* is recorded as crashing. A launch that only activates an instance already
* running is not watched. Records live in `~/.argent/simulator-app-launch.json`;
* delete it to retry. Returns false when the launch was skipped.
*/
export function launchSimulatorApp(
simulatorApp: string,
launch: () => Promise<unknown>
): Promise<boolean> {
return withKeyedLock(launchLocks, simulatorApp, async () => {
const key = await launchKey(simulatorApp).catch(() => null);
if (key === null) {
await launch();
return true;
}
const verdict = loadVerdicts()[key];
if (verdict === "crashes") return false;
const executable = path.join(simulatorApp, "Contents", "MacOS", "Simulator");
const watch = verdict !== "runs" && !(await isRunning(executable).catch(() => true));
await launch();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: Under heavy CPU load, open -a issued a few hundred ms after the previous instance died can exit 0 without starting a new process, as LaunchServices still has the dead instance registered. The pre-check saw nothing running, so the first poll then stores a permanent crashes for a healthy app. Measured with Calculator and TextEdit under yes x42 on 14 cores and a 300 ms gap after a kill: between 1 of 30 and 10 of 50 launches spawned nothing. With no load, or a 2.5 s gap, there were 0 misses.

if (!watch) return true;
const runs = await survivesLaunch(executable).catch(() => null);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: The watch cannot tell a crash from a clean quit. A healthy Simulator.app that exits for any reason within 5 s of its first watched launch, e.g. the user pressing Cmd-Q on the window that just popped up, is stored as crashes, and its window stays off until the record is deleted or a build changes. Reproduced with a healthy app killed 1.5-2 s after open -a: the record got crashes, and the next two calls returned false without launching.

if (runs === null) return true;
try {
persistVerdict(key, runs ? "runs" : "crashes");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: The verdict is only remembered on disk. When ~/.argent cannot be written (EACCES, ENOSPC), each later boot in the same tool-server reopens and re-watches the crashing app. Ran it with .argent at mode 555: two consecutive calls both launched the app and both logged could not record ... EACCES.

} catch (err) {
process.stderr.write(
`[simulator-app-launch] could not record that ${simulatorApp} ${runs ? "runs" : "crashes at launch"}: ${
err instanceof Error ? err.message : String(err)
}\n`
);
}
return true;
});
}
64 changes: 64 additions & 0 deletions packages/tool-server/test/boot-device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ vi.mock("../src/blueprints/ax-service", () => ({
isEntitlementBypassActive: (...args: unknown[]) => isEntitlementBypassActiveMock(...args),
}));

// The launch-crash record lives in the real ~/.argent; its own suite covers it.
const launchSimulatorAppMock = vi.hoisted(() => vi.fn());
vi.mock("../src/utils/simulator-app-launch", () => ({
launchSimulatorApp: (...args: unknown[]) => launchSimulatorAppMock(...args),
}));

import { createBootDeviceTool } from "../src/tools/devices/boot-device";
import { __primeDepCacheForTests, __resetDepCacheForTests } from "../src/utils/check-deps";

Expand Down Expand Up @@ -86,6 +92,12 @@ describe("boot-device — iOS path", () => {
return {} as never;
});
existsSyncMock.mockReset().mockImplementation((path: string) => path === SIMULATOR_APP);
launchSimulatorAppMock
.mockReset()
.mockImplementation(async (_app: string, launch: () => Promise<unknown>) => {
await launch();
return true;
});
// Default state: 11111111 + 33333333 Shutdown (happy path), 22222222
// Booted (kickstart-fallback path). Individual tests override.
listIosSimulatorsMock.mockReset().mockResolvedValue([
Expand Down Expand Up @@ -280,6 +292,58 @@ describe("boot-device — iOS path", () => {
expect(openCalls()).toEqual([["open", ["-a", SIMULATOR_APP]]]);
});

it("does not open a Simulator.app on record as crashing at launch, nor Device Hub", async () => {
existsSyncMock.mockImplementation(
(path: string) => path === SIMULATOR_APP || path === DEVICE_HUB_APP
);
launchSimulatorAppMock.mockResolvedValue(false);
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);

await expect(createBootDeviceTool(registry).execute!({}, { udid })).resolves.toMatchObject({
booted: true,
});
expect(launchSimulatorAppMock).toHaveBeenCalledWith(SIMULATOR_APP, expect.any(Function));
expect(openCalls()).toEqual([]);
expect(stderr).toHaveBeenCalledWith(
expect.stringContaining(
`not opening ${SIMULATOR_APP}: it exited right after an earlier launch, so the device runs without a window. Delete ~/.argent/simulator-app-launch.json to try again.`
)
);
stderr.mockRestore();
});

it("hands over a launch that settles only once open has", async () => {
let launch: () => Promise<unknown> = async () => undefined;
launchSimulatorAppMock.mockImplementation(
async (_app: string, fn: () => Promise<unknown>) => {
launch = fn;
return true;
}
);
await createBootDeviceTool(registry).execute!({}, { udid });

let finishOpen = () => {};
mockExecFile.mockImplementation((...args: unknown[]) => {
finishOpen = () => getCallback(args)(null, { stdout: "", stderr: "" } as never);
return {} as never;
});
let settled = false;
const launched = launch().finally(() => (settled = true));
await Promise.resolve();
expect(settled).toBe(false);
finishOpen();
await launched;
expect(openCalls()).toEqual([["open", ["-a", SIMULATOR_APP]]]);
});

it("says nothing about the record when the Simulator.app opens", async () => {
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);

await createBootDeviceTool(registry).execute!({}, { udid });
expect(stderr).not.toHaveBeenCalledWith(expect.stringContaining("simulator-app-launch.json"));
stderr.mockRestore();
});

it("bounds the GUI lookups with a timeout", async () => {
await createBootDeviceTool(registry).execute!({}, { udid });

Expand Down
Loading
Loading