Files
software-mansion__argent/packages/tool-server/test/ios-simctl-hardening.test.ts
Ignacy Łątka eb16d1c193 fix(tool-server): hard-kill (SIGKILL) timed-out iOS simctl calls so describe can't hang (#525)
## What happened

An iOS `describe` call hung for **~24 minutes** on the benchmark host,
logging the incoming request and then going completely silent until the
tool-server was killed.

The iOS `describe` path shells out to `xcrun simctl` for nearly every
step — `isTvOsSimulator` (`simctl list`), the ax-service bootstrap
(`simctl spawn defaults …`), and the native-devtools env setup (`simctl
spawn launchctl …`). Every one is guarded by `execFileAsync(…, {
timeout: 10_000 })`, so on paper each is bounded to 10s.

The problem is in Node itself: **`execFile`'s `timeout` sends its
`killSignal` (default `SIGTERM`) exactly once and never escalates to
`SIGKILL`.** When CoreSimulatorService wedges (which rapid
clone/boot/delete churn induces), the `simctl` child ignores SIGTERM,
its stdio never closes, and the promise never settles — so the
"10-second timeout" never actually fires. There is no outer deadline on
the `describe` tool to catch it, so the call hangs until the process is
killed.

## Why this is the root cause (not a guess)

- **Static elimination:** every JS-level guard on the describe path
(ax-service's 10s connect/query timers, native-devtools' 5s RPC timers)
*would* have fired and let `describe` return — and it never returned.
That leaves only an `execFileAsync` whose OS-level kill failed to settle
the promise.
- **Server logs:** the hung process logged the POST, then nothing for 24
min until the kill — the exact signature of a stuck subprocess await
with no error thrown.
- **Existing precedent:** `adb.ts` already fixes this identical class of
bug for Android via `ADB_KILL_SIGNAL = "SIGKILL"` (see its comment:
"Node's execFile default kill signal is SIGTERM, which an `adb` process
blocked on a hung daemon can ignore — leaving the parent waiting past
the deadline"). The same fix was simply never applied to the `simctl`
path.
- **Deterministic repro:** `execFileAsync('sh', ['-c', 'trap "" TERM;
…'], { timeout: 2000 })` never settles; adding `killSignal: "SIGKILL"`
makes it reject at ~2006ms. Reproduces both the hang and the fix.

## The fix

- Add `SIMCTL_KILL_SIGNAL = "SIGKILL"` to `simctl-config.ts` and pass it
alongside the timeout on every describe-path `simctl` invocation
(`ax-prefs.ts`, `ios-devices.ts`, `ios-host.ts`), mirroring
`ADB_KILL_SIGNAL`.
- Add the missing `timeout` + kill signal to
`listRunningUIKitApplicationBundleIds`, which had **no timeout at all**
(a latent forever-hang on the `native-devtools-status` path).
- Add `test/ios-simctl-hardening.test.ts` pinning every describe-path
`xcrun simctl` call to `killSignal: SIGKILL` + a finite timeout.

## Verification

- `tsc --noEmit` clean (src + tests).
- Full tool-server suite: **2670/2670 pass**.
- Confirmed the new test is a real guard: stripping the flag makes it
fail, restoring makes it pass.

## Scope note

This fixes the **proven** describe / native-devtools path. The same
latent hang exists in other `simctl` call sites (`boot-device`,
`launch-app`, `screenshot`, etc.) that use `execFileAsync` with the
default signal — extending SIGKILL to the whole `simctl` surface is a
reasonable follow-up but was kept out of this PR to stay scoped to the
reported hang.

One caveat: if `simctl` is ever stuck in true uninterruptible kernel
state, even SIGKILL can't reap it until the syscall returns; the fully
robust version would detach the await from the child via an independent
deadline. SIGKILL matches the `adb` precedent and handles the realistic
CoreSimulator-wedge case.

---------

Co-authored-by: Ignacy Łątka <claude-hubert.gancarczyk@swmansion.com>
2026-07-20 19:26:33 +02:00

140 lines
6.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
// Capture the options object handed to every `execFile` call so we can assert
// the timeout is backed by a SIGKILL. Node's `execFile` `timeout` only sends its
// `killSignal` once and never escalates; with the default SIGTERM a `simctl`
// process blocked on a wedged CoreSimulatorService ignores the signal and the
// await hangs past the deadline — the failure mode that hung a real `describe`
// call for ~24 minutes. These tests pin every *bounded* describe-path `xcrun
// simctl` invocation to `killSignal: "SIGKILL"` + a finite `timeout` so it can't
// regress — across all three describe-path modules (ax-prefs, ios-devices, and
// ios-host via the exported `localIosHost`).
//
// The long-lived ax-service daemon spawn (`spawnAxDaemonLocal` /
// `localIosHost.spawnAxDaemon`) is deliberately NOT asserted here: it runs for
// up to `--timeout 3600` serving RPC, so a 10s exec `timeout` would SIGKILL it
// mid-session. Its hang is bounded elsewhere — `waitForDaemonConnection(…, 10s)`
// in ax-service — not by an exec deadline. So these tests never invoke it.
type ExecOptions = { timeout?: number; killSignal?: string } | undefined;
const calls: Array<{ cmd: string; args: readonly string[]; options: ExecOptions }> = [];
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: (
cmd: string,
args: readonly string[],
opts: unknown,
cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void
) => {
const callback = typeof opts === "function" ? opts : cb!;
const options = (typeof opts === "function" ? undefined : opts) as ExecOptions;
calls.push({ cmd, args, options });
// `simctl list devices --json` must parse; everything else can be empty.
const isListJson = args.includes("list") && args.includes("--json");
callback(null, { stdout: isListJson ? '{"devices":{}}' : "", stderr: "" });
},
};
});
// `setupNativeDevtoolsEnvLocal` resolves the injection dylib path before any
// simctl call, and that resolver hard-requires a macOS host. Stub the three
// bootstrap-path getters so this cross-platform test can reach the simctl
// getenv/setenv spawns it is meant to guard.
vi.mock("@argent/native-devtools-ios", async () => {
const actual = await vi.importActual<typeof import("@argent/native-devtools-ios")>(
"@argent/native-devtools-ios"
);
return {
...actual,
bootstrapDylibPath: () => "/fake/bootstrap.dylib",
bootstrapDylibPathTcp: () => "/fake/bootstrap-tcp.dylib",
bootstrapDylibPathTvos: () => "/fake/bootstrap-tvos.dylib",
};
});
import { ensureAutomationEnabled, isEntitlementBypassActive } from "../src/utils/ax-prefs";
import { listIosSimulators } from "../src/utils/ios-devices";
import { localIosHost } from "../src/utils/ios-host";
beforeEach(() => {
calls.length = 0;
});
function xcrunCalls() {
return calls.filter((c) => c.cmd === "xcrun" && c.args[0] === "simctl");
}
describe("iOS simctl calls are hard-killed on timeout (SIGKILL, not SIGTERM)", () => {
it("ensureAutomationEnabled passes killSignal SIGKILL + a finite timeout", async () => {
await ensureAutomationEnabled("UDID-1");
const simctl = xcrunCalls();
expect(simctl.length).toBeGreaterThan(0);
for (const c of simctl) {
expect(c.options?.killSignal).toBe("SIGKILL");
expect(typeof c.options?.timeout).toBe("number");
}
});
it("isEntitlementBypassActive passes killSignal SIGKILL + a finite timeout", async () => {
await isEntitlementBypassActive("UDID-2");
const simctl = xcrunCalls();
expect(simctl.length).toBeGreaterThan(0);
for (const c of simctl) {
expect(c.options?.killSignal).toBe("SIGKILL");
expect(typeof c.options?.timeout).toBe("number");
}
});
it("listIosSimulators (the isTvOsSimulator/describe probe) passes killSignal SIGKILL", async () => {
await listIosSimulators();
const simctl = xcrunCalls();
expect(simctl.length).toBeGreaterThan(0);
for (const c of simctl) {
expect(c.options?.killSignal).toBe("SIGKILL");
expect(typeof c.options?.timeout).toBe("number");
}
});
// The ios-host.ts describe-path sites — reached via the exported `localIosHost`
// rather than the internal functions. This is the block that guards
// `listRunningUIKitApplicationBundleIds` (the call this fix added a
// previously-missing timeout+kill to) and the six other ios-host simctl spawns.
it("localIosHost.bootstrapAx (ensureAutomationEnabled + entitlement probe) passes killSignal SIGKILL", async () => {
await localIosHost.bootstrapAx("UDID-HOST-1");
const simctl = xcrunCalls();
expect(simctl.length).toBeGreaterThan(0);
for (const c of simctl) {
expect(c.options?.killSignal).toBe("SIGKILL");
expect(typeof c.options?.timeout).toBe("number");
}
});
it("localIosHost.listRunningBundleIds (launchctl list) passes killSignal SIGKILL + a finite timeout", async () => {
await localIosHost.listRunningBundleIds("UDID-HOST-2");
const simctl = xcrunCalls();
// Exactly the `simctl spawn … launchctl list` call this fix hardened.
expect(simctl.some((c) => c.args.includes("launchctl") && c.args.includes("list"))).toBe(true);
for (const c of simctl) {
expect(c.options?.killSignal).toBe("SIGKILL");
expect(typeof c.options?.timeout).toBe("number");
}
});
it("localIosHost.setupNativeDevtoolsEnv (getenv/setenv + ensureAccessibilityEnabled) passes killSignal SIGKILL", async () => {
await localIosHost.setupNativeDevtoolsEnv("UDID-HOST-3", {
transport: "unix",
socketPath: "/tmp/argent-hardening-test.sock",
});
const simctl = xcrunCalls();
// Covers the launchctl getenv/setenv spawns plus the two
// ensureAccessibilityEnabled `defaults write` spawns.
expect(simctl.length).toBeGreaterThan(1);
for (const c of simctl) {
expect(c.options?.killSignal).toBe("SIGKILL");
expect(typeof c.options?.timeout).toBe("number");
}
});
});