Repository navigation
fix(boot-device): stop reopening a Simulator.app that crashes at launch #1261
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2f636b7
b735f9b
e8d7283
60ad96a
51740d3
3c849e5
9397a83
dc7f5af
311e187
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
|
@@ -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, () => | ||
| execFileAsync("open", ["-a", simulatorApp], { timeout }) | ||
| ); | ||
| if (!launched) { | ||
| process.stderr.write( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Diplomat, Opus 5.5]: When the window is skipped, |
||
| `[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"); | ||
|
|
||
| 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; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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="], { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Diplomat, Opus 5.5]: |
||
| 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(); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Diplomat, Opus 5.5]: Under heavy CPU load, |
||
| if (!watch) return true; | ||
| const runs = await survivesLaunch(executable).catch(() => null); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| if (runs === null) return true; | ||
| try { | ||
| persistVerdict(key, runs ? "runs" : "crashes"); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Diplomat, Opus 5.5]: The verdict is only remembered on disk. When |
||
| } 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; | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
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-deviceis notlongRunning, 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.