Skip to content

fix(boot-device): stop reopening a Simulator.app that crashes at launch - #1261

Open
latekvo wants to merge 9 commits into
mainfrom
fix/simulator-window-crash-latch
Open

latekvo wants to merge 9 commits into
mainfrom
fix/simulator-window-crash-latch

Conversation

@latekvo

@latekvo latekvo commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Problem: a Simulator.app older than the installed CoreSimulator (e.g. Xcode 16.4 selected after an Xcode 27 install) aborts on every launch. #1198 opens the active Xcode's Simulator.app by path, so on such a host every non-headless boot-device pops a "Simulator quit unexpectedly" dialog. open exits 0, so nothing notices.

Fix: the first launch of each Simulator.app / CoreSimulator build pair is watched for 5 s. If the process is gone, that's recorded in ~/.argent/simulator-app-launch.json and the app is not opened again; the device boots without a window, as with headless: true. Parallel boots share the first verdict; a launch that only activates an instance already running is not judged. Device Hub is untouched.

boot-device call 1 call 2 fresh server, calls 1-2
main (v0.26.0) crash dialog crash dialog -
this PR crash dialog (recorded) none none, none

Docs: reference/configuration.mdx describes the record and how to reset it.

Verification
  • E2E on a real iOS 18.6 sim (Xcode 16.4 selected, CoreSimulator 1166) via POST /tools/boot-device on a branch tool-server, crash reports matched to each call by their procLaunch time. Every call returned booted: true. A second, fresh server read the record and opened nothing.
  • Root cause: all 20+ Simulator-*.ips reports on that host are SIGABRT in swift::ResolveAsSymbolicReference from Xcode 16.4's SimulatorKit, <2 s after launch (max 1.64 s, hence the 5 s watch).
  • Tests: test/simulator-app-launch.test.ts (crash/run verdicts, watch window, parallel callers, app/CoreSimulator build change, per-app key, already-running instance, ps case/Unicode form/locale/decoy paths, ps/plutil failures and timeouts, unwritable ~/.argent, corrupt record, rename-into-place) + boot-device cases for the skip and its notice. Mutations of each rule (skip, watch window, lock, key fields and plist paths, ps binary/args, path match, merge, error handling) fail the suite.
  • Host checks (scratch Cocoa apps standing in for Simulator.app, real open -a / /bin/ps / plutil): crash recorded and skipped with PATH=/usr/bin:/usr/sbin (no /bin); healthy app recorded as running; an already-running instance left unjudged; a healthy app under an NFC/NFD-mismatched or non-ASCII path (no locale) recorded as running; 24 processes recording at once never left a half-written record (20 rounds).
  • CI locally: tool-server suite in 6 shards (6,934 pass), typecheck:tests, eslint, prettier, knip, docusaurus build.
  • Not covered on hardware: a healthy Simulator.app (no working one on the test host); unit-tested only.

A Simulator.app older than the installed CoreSimulator (Xcode 16.4 selected
after an Xcode 27 install) aborts within a second of every launch, so every
non-headless boot-device popped a "Simulator quit unexpectedly" dialog.
The first launch per app/CoreSimulator build pair is now watched and the
outcome recorded in ~/.argent/simulator-app-launch.json; a crashing app is
not opened again.
… record

Crash reports on an affected host show aborts up to 1.64 s after launch, so
the 2 s watch could record a crashing app as running. Match the process path
case-insensitively, log instead of failing when the record cannot be written,
and name the reset file in the skip message.
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 30 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 8 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: software-mansion/argent/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: e8e9b510-cb49-4784-ba19-398ba4d80ea8

📥 Commits

Reviewing files that changed from the base of the PR and between 2307ce7 and 60ad96a.


📒 Files selected for processing (5)
  • packages/docs/docs/reference/configuration.mdx
  • packages/tool-server/src/tools/devices/boot-device.ts
  • packages/tool-server/src/utils/simulator-app-launch.ts
  • packages/tool-server/test/boot-device.test.ts
  • packages/tool-server/test/simulator-app-launch.test.ts

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@latekvo
latekvo marked this pull request as ready for review October 12, 2026 15:17
…h watch

An MCP server launched from a GUI or launchd context inherits a PATH
without /bin, where macOS keeps ps. The bare "ps" then failed, the watch
recorded nothing, and every boot reopened the crashing Simulator.app.
Use PS_BIN like the other ps call sites.

Also pin the CFBundleVersion key field, the end of the watch window and
the skip notice in tests.
…he path's Unicode form

open -a only activates a Simulator.app that is already running, so the
watch saw the old instance and stored a lasting "runs" for a build that
may crash at launch. Skip the watch when an instance is already up.

ps prints the on-disk Unicode form (NFD for Finder-made names), while
xcode-select echoes the form it was given, so a healthy app under a
non-ASCII path was stored as "crashes". Compare NFC-normalized paths.

Pin the Info.plist paths, both key fields and the plutil/ps timeouts in
tests, and say in openSimulatorWindow's JSDoc that only an app on record
as crashing is skipped.
writeFileSync truncates before it writes, so a second tool-server reading
the record mid-write parsed nothing and rewrote it with its own entry
alone, dropping every other verdict. Write a temp file and rename it.

Pin in tests the ps arguments, a ps failure during the watch, the early
return on a crash, decoy Simulator paths, and that boot-device's launch
waits for open.
…ord write

A launchd-started tool-server may have no locale, and ps then escapes
non-ASCII bytes, so a running Simulator.app under such a path was stored
as "crashes". Run ps with LC_ALL=en_US.UTF-8.

Remove the temp file when the record cannot be written or renamed.

Pin in tests the pre-launch probe's failure handling, the Device Hub
fallthrough after a skip, and the reset hint.

@latekvo latekvo left a comment

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]: Remaining edge cases after four review passes, each checked against the code or with scratch Cocoa apps standing in for Simulator.app (real open -a, /bin/ps, plutil, HOME pointed at a scratch dir).

const watch = verdict !== "runs" && !(await isRunning(executable).catch(() => true));
await launch();
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.

// 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 runs = await survivesLaunch(executable).catch(() => null);
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.

}
}

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 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.

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.

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.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant