mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
a5c6730d05
## What Opt-in `malloc_stack_logging` flag on `native-profiler-start` that makes iOS memory leaks **attributable** — they come back with a real responsible frame + library instead of `<Call stack limit reached>`. ## Why `native-profiler-start` attaches to the already-running app. iOS records malloc allocation backtraces only when `MallocStackLogging` is set **at process launch**, so an attached app has none: Instruments' Leaks scanner finds the leaked blocks but can't attribute them and emits the placeholder `<Call stack limit reached>` (that string is Instruments', not ours). ## How When `malloc_stack_logging: true`, the profiler cold-launches the app **under** xctrace instead of attaching: ``` xctrace record --template <Argent.tracetemplate> --device <udid> \ --env MallocStackLogging=1 --output <…> --no-prompt \ --launch -- <App.app> ``` - `.app` path resolved via `simctl get_app_container`; the running instance is terminated first for a clean cold start. - The existing Leaks export / parser / render path is reused **unchanged** — no parser changes. - Default behaviour is untouched: attach to the running app (no relaunch, no overhead). The flag is opt-in because it restarts the app (loses state) and adds allocator overhead — for leak-attribution passes, not CPU/hang work. - The report relabels unattributable leaks with a hint to re-run with `malloc_stack_logging: true`, instead of surfacing the raw placeholder. ## Evidence (real iPhone 16 sim, same scroll workload) Before (attach) — every leak row: ``` responsible-frame="<Call stack limit reached>" responsible-library="" ``` After (`--env MallocStackLogging=1 --launch`) — same Leaks export: | leaked-object | responsible-frame | library | |---|---|---| | Malloc 1008 B ×3 | `itanium_demangle::OutputBuffer::grow(...)` | libc++abi | | Malloc 48 B ×1 | `hermes::vm::JSTypedArrayBase::createBuffer(...)` | hermes | ## Tests - `test/ios-instruments/malloc-stack-logging.test.ts` — the malloc launch argv (`--env MallocStackLogging=1`, `--launch -- <app>` last, with and without `--notify-tracing-started`, `--no-prompt`) vs default `--attach`; terminate-first / get_app_container ordering; failed-start behavior; per-test `ARGENT_IOS_CAPTURE` scrubbing. - `test/ios-instruments/leak-attribution-render.test.ts` — unattributable vs attributed leak rendering, capture-mode-aware hints. - `test/ios-instruments/load-freshness.test.ts` — per-capture residue clearing on `profiler-load`, live/recovery-pending load refusals. - `test/ios-instruments/stop-recovery.test.ts` — capture-mode pairing at stop (clean and recovery paths). - `test/profiler-combined-leaks.test.ts`, `test/profiler-leak-stacks.test.ts`, `test/ios-leak-pipeline.test.ts` — shared caveat line, evidence-first hints, attribution-normalized grouping. - Full `test/ios-instruments/` suite green (9 files / 70 tests); full tool-server suite green; `tsc --noEmit` + `tsc -p tsconfig.test.json` clean. ## Notes E2E'd live twice during review (real iPhone 16 Pro / iPhone 17 Pro sims on Xcode 26.6): guard refusals fire before the app is touched, the `--device` cold launch's deadlock on degraded Xcode is confirmed (so the guard is warranted), and the malloc argv/terminate/relaunch behavior checked out on-device. Docs updated: `IOS_PROFILER_REFERENCE.md` and the `argent-native-profiler` skill. ## Review follow-ups (post-approval hardening) Later commits address the remaining review threads and several subsequent multi-lens review passes: - **Capture-mode is a real signal, end to end.** The session records the mode of the in-flight recording at start (`recordingMallocStackLogging`), pairs it with `exportedFiles` at stop (`mallocStackLogging`), and freezes it into `parsedData` at analyze/load — so the analyze report, the combined report, and the `leak_stacks` drill-down all name the capture mode of the data they actually render instead of inferring it (or mislabeling it after a newer capture starts). One shared `renderUnattributedLeaksNote()` keeps the wording identical across reports, and attribution evidence outranks the flag: a resolved frame proves the target ran under malloc stack logging however it was launched. - **`profiler-load` restores a clean slate.** Loading clears all per-capture residue (capture mode, `cpuFilterPid`, `wallClockStartMs`, `traceFile`) so a loaded trace is never analyzed with a previous live capture's PID filter, freshness anchor, or report path — and refuses to load while a recording is in flight or a crashed capture awaits `native-profiler-stop`'s partial-trace recovery, which the load would otherwise orphan. - **Failed starts are non-destructive.** Per-capture descriptors — and the recovery flags that gate `native-profiler-stop`'s partial-trace export — are stamped/reset only on a successful start, so a failed attempt leaves the previous capture's still-loaded exports fully described and a pending abnormal-end recovery intact. - **Guards keep session state coherent.** `profiler-load`, `native-profiler-analyze`, and `profiler-combined-report` all refuse while a recording is in flight or a crashed capture awaits recovery, via one shared `isCaptureInFlight`/`inFlightGuardMessage` helper whose message distinguishes recording vs 10-min-cap timeout vs unexpected exit (so the stated cause matches what `native-profiler-stop` reports next). Proceeding would otherwise wedge the session (load) or render one capture's frozen data under another's live fields (analyze/combined). - **The combined report anchors on a frozen clock.** The recording's start time is frozen into `parsedData` at analyze, and the iOS combined report anchors its hangs to that frozen value rather than the live session field — so a capture started after analyze can never shift a real hang↔commit correlation into "unmatched". Android keeps its live anchor (it re-derives hangs from the live trace). The combined-report guard's recovery advice routes through `native-profiler-analyze`, the only step that rebuilds `parsedData`. - **Leak grouping is attribution-normalized.** `aggregateLeaks` keys on `(objectType, frame-normalized-the-way-isLeakAttributed-matches)`, so mixed unattributed spellings (`Unknown`, the sentinel, whitespace variants) can't split one group per type. - **The malloc launch target is unambiguous.** Since the cold launch terminates the resolved app, `app_process` resolution prefers an exact `CFBundleExecutable` match and, when builds still tie (dev + prod sharing an executable/display name), refuses and points at the globally-unique `CFBundleIdentifier` — which it also accepts as an `app_process` — instead of killing whichever app the plist enumerates first. - **Report tables escape `|`.** Demangled C++ frames (`operator|` included) are real headline content under malloc mode; every free-form cell across the leak, CPU-hotspot, function caller/callee, and thread-breakdown tables — symbol *and* thread/queue-name columns — is escaped so a stray pipe can't shift the row's columns. - **Android start is non-destructive too.** `startNativeProfilerAndroid` now starts perfetto before mutating session state, mirroring iOS, so a failed start no longer burns a prior capped capture's pending partial-trace recovery.
156 lines
6.6 KiB
TypeScript
156 lines
6.6 KiB
TypeScript
/**
|
|
* Device-free coverage of the start-path app_process dispatch (PR #340,
|
|
* behavior 1). The full validate-against-a-real-device flow is covered by the
|
|
* emulator E2E; this pins the pure dispatch logic and — crucially — the
|
|
* fail-fast ORDERING that is the whole point of the change: an explicit
|
|
* app_process is validated BEFORE perfetto is started, so a bogus target fails
|
|
* immediately instead of producing a zero-sample trace that only errors minutes
|
|
* later at analyze time.
|
|
*
|
|
* Everything below the handler is stubbed at the module boundary (no adb, no
|
|
* perfetto, no fs), so this never touches a device.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import {
|
|
nativeProfilerSessionBlueprint,
|
|
type NativeProfilerSessionApi,
|
|
} from "../../src/blueprints/native-profiler-session";
|
|
|
|
const validateAndroidAppProcess = vi.fn();
|
|
const detectAndroidRunningApp = vi.fn();
|
|
const startPerfetto = vi.fn();
|
|
|
|
vi.mock("../../src/utils/android-profiler/detect-app", () => ({
|
|
validateAndroidAppProcess: (...a: unknown[]) => validateAndroidAppProcess(...a),
|
|
detectAndroidRunningApp: (...a: unknown[]) => detectAndroidRunningApp(...a),
|
|
}));
|
|
vi.mock("../../src/utils/android-profiler/capture", () => ({
|
|
startPerfetto: (...a: unknown[]) => startPerfetto(...a),
|
|
stopPerfetto: vi.fn(),
|
|
}));
|
|
vi.mock("../../src/utils/react-profiler/debug/dump", () => ({
|
|
getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"),
|
|
}));
|
|
|
|
import { startNativeProfilerAndroid } from "../../src/tools/profiler/native-profiler/platforms/android";
|
|
|
|
async function buildAndroidSession(): Promise<NativeProfilerSessionApi> {
|
|
const device = { id: "emulator-5554", platform: "android" as const, kind: "emulator" as const };
|
|
const instance = await nativeProfilerSessionBlueprint.factory({}, device, { device });
|
|
return instance.api;
|
|
}
|
|
|
|
describe("startNativeProfilerAndroid — app_process dispatch (fail-fast validation)", () => {
|
|
beforeEach(() => {
|
|
validateAndroidAppProcess.mockReset();
|
|
detectAndroidRunningApp.mockReset();
|
|
startPerfetto.mockReset();
|
|
startPerfetto.mockResolvedValue({
|
|
pid: 4242,
|
|
onDeviceTracePath: "/data/misc/perfetto-traces/argent-x.pftrace",
|
|
child: { kill: () => true },
|
|
});
|
|
});
|
|
|
|
it("validates an explicit app_process BEFORE starting perfetto, and stamps wallClockStartMs", async () => {
|
|
const api = await buildAndroidSession();
|
|
const callOrder: string[] = [];
|
|
validateAndroidAppProcess.mockImplementation(async () => {
|
|
callOrder.push("validate");
|
|
});
|
|
startPerfetto.mockImplementation(async () => {
|
|
callOrder.push("start");
|
|
return {
|
|
pid: 4242,
|
|
onDeviceTracePath: "/data/misc/perfetto-traces/argent-x.pftrace",
|
|
child: { kill: () => true },
|
|
};
|
|
});
|
|
|
|
const before = Date.now();
|
|
const result = await startNativeProfilerAndroid(api, {
|
|
device_id: "emulator-5554",
|
|
app_process: " com.example.app ", // also exercises the .trim()
|
|
});
|
|
|
|
expect(validateAndroidAppProcess).toHaveBeenCalledWith("emulator-5554", "com.example.app");
|
|
expect(detectAndroidRunningApp).not.toHaveBeenCalled();
|
|
expect(callOrder).toEqual(["validate", "start"]); // fail-fast ordering
|
|
expect(api.appProcess).toBe("com.example.app");
|
|
expect(api.profilingActive).toBe(true);
|
|
expect(api.wallClockStartMs).toBeGreaterThanOrEqual(before);
|
|
expect(result.status).toBe("recording");
|
|
if (api.recordingTimeout) clearTimeout(api.recordingTimeout);
|
|
});
|
|
|
|
it("does NOT start perfetto when validation rejects (bogus app_process fails fast)", async () => {
|
|
const api = await buildAndroidSession();
|
|
validateAndroidAppProcess.mockRejectedValueOnce(
|
|
new Error("app_process `com.bogus` was not found on emulator-5554")
|
|
);
|
|
|
|
await expect(
|
|
startNativeProfilerAndroid(api, {
|
|
device_id: "emulator-5554",
|
|
app_process: "com.bogus",
|
|
})
|
|
).rejects.toThrow(/was not found/);
|
|
|
|
expect(startPerfetto).not.toHaveBeenCalled();
|
|
expect(api.profilingActive).toBe(false);
|
|
});
|
|
|
|
it("auto-detects the foreground app when app_process is omitted (no validation call)", async () => {
|
|
const api = await buildAndroidSession();
|
|
detectAndroidRunningApp.mockResolvedValueOnce("com.detected.app");
|
|
|
|
await startNativeProfilerAndroid(api, { device_id: "emulator-5554" });
|
|
|
|
expect(detectAndroidRunningApp).toHaveBeenCalledWith("emulator-5554");
|
|
expect(validateAndroidAppProcess).not.toHaveBeenCalled();
|
|
expect(api.appProcess).toBe("com.detected.app");
|
|
if (api.recordingTimeout) clearTimeout(api.recordingTimeout);
|
|
});
|
|
|
|
it("treats a whitespace-only app_process as omitted (falls back to auto-detect)", async () => {
|
|
const api = await buildAndroidSession();
|
|
detectAndroidRunningApp.mockResolvedValueOnce("com.detected.app");
|
|
|
|
await startNativeProfilerAndroid(api, { device_id: "emulator-5554", app_process: " " });
|
|
|
|
expect(validateAndroidAppProcess).not.toHaveBeenCalled();
|
|
expect(detectAndroidRunningApp).toHaveBeenCalledOnce();
|
|
if (api.recordingTimeout) clearTimeout(api.recordingTimeout);
|
|
});
|
|
|
|
it("a failed startPerfetto does not burn a prior capture's pending partial-trace recovery", async () => {
|
|
// Pass-4 finding 2 (same class the iOS start path already guards): a prior
|
|
// capture hit the 10-min cap — recordingTimedOut=true with its partial trace
|
|
// still on the device, recoverable by native-profiler-stop. A new start whose
|
|
// startPerfetto then fails (adb offline, spawn failure) must NOT have already
|
|
// cleared recordingTimedOut or overwritten traceFile, or that ~10 min of data
|
|
// becomes unrecoverable (stop would throw "No active session").
|
|
const api = await buildAndroidSession();
|
|
api.recordingTimedOut = true; // prior capped capture awaiting recovery
|
|
api.traceFile = "/prior/partial.pftrace";
|
|
api.appProcess = "com.prior.app";
|
|
api.capturePid = 9999;
|
|
api.androidOnDeviceTracePath = "/data/misc/perfetto-traces/prior.pftrace";
|
|
|
|
detectAndroidRunningApp.mockResolvedValueOnce("com.new.app");
|
|
startPerfetto.mockRejectedValueOnce(new Error("adb: device offline"));
|
|
|
|
await expect(startNativeProfilerAndroid(api, { device_id: "emulator-5554" })).rejects.toThrow(
|
|
/device offline/
|
|
);
|
|
|
|
// Recovery state for the prior capture must survive the failed start.
|
|
expect(api.recordingTimedOut).toBe(true);
|
|
expect(api.traceFile).toBe("/prior/partial.pftrace");
|
|
expect(api.androidOnDeviceTracePath).toBe("/data/misc/perfetto-traces/prior.pftrace");
|
|
expect(api.capturePid).toBe(9999);
|
|
expect(api.profilingActive).toBe(false);
|
|
if (api.recordingTimeout) clearTimeout(api.recordingTimeout);
|
|
});
|
|
});
|