Files
Paweł Fornagiel 55d1567d48 feat: improve iOS profiler startup, shutdown, and exit recovery (#181)
## Summary

Refactor of the iOS profiler (`xctrace`-driven) session to make start,
stop, and unexpected-exit handling proper, without the iOS hanging
infinitely.

### Startup (`ios-profiler-start`)
- **Locale ready signal**: subscribe-before-spawn to a Darwin
notification (`com.argent.ios-profiler.started.<pid>.<ts>`) via
`notifyutil`, passed through `xctrace --notify-tracing-started`. Falls
back to the existing stdout substring match (`Starting recording` /
`Ctrl-C to stop`) if `notifyutil` fails to spawn or register within 2 s.
- **Startup timeout**: new 10 s cap on reaching the ready state — on
timeout, `SIGKILL` the child so it cannot leak.
- **Bounded `simctl` calls**: `detectRunningApp` now applies a 10 s
timeout to both `xcrun simctl spawn ... launchctl list` and `xcrun
simctl listapps`, with explicit error messages telling the user to
verify the simulator is booted.
- **Cold-start retry**: if xctrace's process resolver fails with
`"Cannot find process matching name:"` (launchd has registered the
bundle but xctrace's resolver hasn't caught up), the tool retries once
after a 1.2 s backoff — up to 2 attempts total. On exhausted retries
throws a classified error naming the cold-launch race and pointing at
`app_process` as the override. Non-cold-start failures propagate
immediately without retry.
- **Exit listener installed**: every `xctrace record` child gets a
long-lived `'exit'` handler (`handleXctraceExit`) that marks the session
as `recordingExitedUnexpectedly` and stores `lastExitInfo`.

### Shutdown (`ios-profiler-stop`)
- **SIGINT → SIGTERM → SIGKILL ladder** via
`utils/ios-profiler/lifecycle.ts::shutdownChild` (30 s grace, 5 s term,
5 s kill).
- **Returns a `warning`** when the ladder had to escalate past SIGINT,
so callers know the trace bundle may be incomplete.

### Exit recovery
- **New `recordingTimedOut` / `recordingExitedUnexpectedly` flags** on
the session API. If `ios-profiler-stop` is called after either fired,
the tool now exports whatever `.trace` bundle is on disk and returns it
with a descriptive `warning` instead of throwing "no active session".
- Covers the in-process 10-min recording cap, attached-app crashes, and
simulator daemon hiccups.

### Dispose semantics
- Registry teardown (process shutdown) skips SIGINT and goes straight to
`SIGKILL` with a 1 s reap window. The explicit `ios-profiler-stop`
contract is the only path that takes the slow finalise grace.

### Other
- `utils/ios-profiler/run-with-timeout.ts`: `execSyncWithTimeout`
wrapper with a 60 s default — guards every shelled-out `xctrace export`
/ `xctrace version`.
- New reference doc `utils/ios-profiler/IOS_PROFILER_REFERENCE.md`
covering the native foundations, capture flow, and pipeline.

## Tests
New unit tests under `packages/tool-server/test/ios-instruments/`

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 09:26:57 +02:00

155 lines
6.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "events";
import type { ChildProcess } from "child_process";
import { waitForChildExit, shutdownChild } from "../../src/utils/ios-profiler/lifecycle";
// A minimal fake of ChildProcess that supports the surface lifecycle.ts uses:
// `exitCode`, `signalCode`, `kill(signal)`, and the `'exit'` event.
class FakeChild extends EventEmitter {
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
/** Signals that, when received, cause the fake to "exit" after `respondAfterMs`. */
respondsTo: Set<NodeJS.Signals> = new Set();
/** Latency between receiving a respected signal and emitting `'exit'`. */
respondAfterMs = 0;
/** Recorded log of every signal sent through `kill()`. */
signalLog: NodeJS.Signals[] = [];
kill(signal: NodeJS.Signals = "SIGTERM"): boolean {
this.signalLog.push(signal);
if (this.respondsTo.has(signal) && this.exitCode === null && this.signalCode === null) {
setTimeout(() => this.exitWith(signal), this.respondAfterMs);
}
return true;
}
exitWith(signal: NodeJS.Signals | null): void {
this.signalCode = signal;
this.exitCode = signal ? null : 0;
this.emit("exit", this.exitCode, this.signalCode);
}
exitClean(): void {
this.exitCode = 0;
this.signalCode = null;
this.emit("exit", 0, null);
}
}
const asChild = (c: FakeChild): ChildProcess => c as unknown as ChildProcess;
describe("waitForChildExit", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("resolves true synchronously when child has already exited", async () => {
const child = new FakeChild();
child.exitClean();
await expect(waitForChildExit(asChild(child), 1000)).resolves.toBe(true);
});
it("resolves true synchronously when child was killed by signal", async () => {
const child = new FakeChild();
child.exitWith("SIGKILL");
await expect(waitForChildExit(asChild(child), 1000)).resolves.toBe(true);
});
it("resolves true when child exits before the deadline", async () => {
const child = new FakeChild();
const promise = waitForChildExit(asChild(child), 1000);
setTimeout(() => child.exitClean(), 200);
await vi.advanceTimersByTimeAsync(200);
await expect(promise).resolves.toBe(true);
});
it("resolves false when the deadline expires first", async () => {
const child = new FakeChild();
const promise = waitForChildExit(asChild(child), 500);
await vi.advanceTimersByTimeAsync(500);
await expect(promise).resolves.toBe(false);
});
it("clears the deadline timer once the child exits", async () => {
const child = new FakeChild();
const promise = waitForChildExit(asChild(child), 5000);
setTimeout(() => child.exitClean(), 100);
await vi.advanceTimersByTimeAsync(100);
expect(await promise).toBe(true);
// No pending timers after early exit (deadline was cleared).
expect(vi.getTimerCount()).toBe(0);
});
});
describe("shutdownChild", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const TIMINGS = { graceMs: 30_000, termMs: 5_000, killMs: 5_000 };
it("returns clean=SIGINT immediately when child is already dead", async () => {
const child = new FakeChild();
child.exitClean();
const result = await shutdownChild(asChild(child), TIMINGS);
expect(result).toEqual({ clean: true, signalUsed: "SIGINT" });
expect(child.signalLog).toEqual([]);
});
it("returns clean=SIGINT when SIGINT is sufficient", async () => {
const child = new FakeChild();
child.respondsTo = new Set<NodeJS.Signals>(["SIGINT"]);
child.respondAfterMs = 100;
const promise = shutdownChild(asChild(child), TIMINGS);
await vi.advanceTimersByTimeAsync(100);
await expect(promise).resolves.toEqual({ clean: true, signalUsed: "SIGINT" });
expect(child.signalLog).toEqual(["SIGINT"]);
});
it("escalates to SIGTERM when SIGINT is ignored", async () => {
const child = new FakeChild();
child.respondsTo = new Set<NodeJS.Signals>(["SIGTERM"]);
child.respondAfterMs = 50;
const promise = shutdownChild(asChild(child), TIMINGS);
// SIGINT window expires (30s), then SIGTERM is sent and lands.
await vi.advanceTimersByTimeAsync(TIMINGS.graceMs);
await vi.advanceTimersByTimeAsync(50);
await expect(promise).resolves.toEqual({ clean: false, signalUsed: "SIGTERM" });
expect(child.signalLog).toEqual(["SIGINT", "SIGTERM"]);
});
it("escalates to SIGKILL when SIGINT and SIGTERM are ignored", async () => {
const child = new FakeChild();
child.respondsTo = new Set<NodeJS.Signals>(["SIGKILL"]);
child.respondAfterMs = 1;
const promise = shutdownChild(asChild(child), TIMINGS);
await vi.advanceTimersByTimeAsync(TIMINGS.graceMs);
await vi.advanceTimersByTimeAsync(TIMINGS.termMs);
await vi.advanceTimersByTimeAsync(child.respondAfterMs);
await expect(promise).resolves.toEqual({ clean: false, signalUsed: "SIGKILL" });
expect(child.signalLog).toEqual(["SIGINT", "SIGTERM", "SIGKILL"]);
});
it("returns SIGKILL even if the child never responds at all", async () => {
const child = new FakeChild();
// respondsTo is empty → child ignores every signal
const promise = shutdownChild(asChild(child), TIMINGS);
await vi.advanceTimersByTimeAsync(TIMINGS.graceMs);
await vi.advanceTimersByTimeAsync(TIMINGS.termMs);
await vi.advanceTimersByTimeAsync(TIMINGS.killMs);
await expect(promise).resolves.toEqual({ clean: false, signalUsed: "SIGKILL" });
expect(child.signalLog).toEqual(["SIGINT", "SIGTERM", "SIGKILL"]);
});
it("survives kill() throwing (already-dead races)", async () => {
const child = new FakeChild();
child.kill = vi.fn(() => {
throw new Error("ESRCH");
});
const promise = shutdownChild(asChild(child), TIMINGS);
// No signal landed; deadline expires through every stage.
await vi.advanceTimersByTimeAsync(TIMINGS.graceMs);
await vi.advanceTimersByTimeAsync(TIMINGS.termMs);
await vi.advanceTimersByTimeAsync(TIMINGS.killMs);
await expect(promise).resolves.toEqual({ clean: false, signalUsed: "SIGKILL" });
});
});