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.
268 lines
12 KiB
TypeScript
268 lines
12 KiB
TypeScript
/**
|
|
* Proof for PR #340 review comment 2 (hubgan, ios.ts:464):
|
|
*
|
|
* The iOS analyze path forwards `formatTraceFreshness(api.wallClockStartMs, …)`
|
|
* exactly like Android, but `wallClockStartMs` is only ever set in-memory at
|
|
* `native-profiler-start` (ios.ts) — iOS has NO on-disk metadata sidecar, and
|
|
* the iOS branch of `profiler-load` never restores it. So when an old iOS
|
|
* session is loaded from disk, `wallClockStartMs` stays `null` and the
|
|
* stale-trace warning can never fire — the wiring is effective only for a live
|
|
* session, which is fresh by construction.
|
|
*
|
|
* These tests pin that behavior: it must degrade cleanly (no crash, no
|
|
* misleading output), NOT silently emit or suppress something wrong. If iOS
|
|
* later grows real start-time persistence, the `wallClockStartMs` assertion
|
|
* here is the canary that should be updated alongside it.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
nativeProfilerSessionBlueprint,
|
|
type NativeProfilerSessionApi,
|
|
} from "../../src/blueprints/native-profiler-session";
|
|
|
|
vi.mock("../../src/utils/react-profiler/debug/dump", () => ({
|
|
getDebugDir: vi.fn(),
|
|
readCommitTree: vi.fn(),
|
|
}));
|
|
|
|
// The iOS load + analyze paths both parse xctrace XML off disk; stub the
|
|
// pipeline so this runs deterministically and isolates the freshness wiring.
|
|
vi.mock("../../src/utils/ios-profiler/pipeline/index", () => ({
|
|
runIosProfilerPipeline: vi.fn(async () => ({
|
|
bottlenecks: [],
|
|
cpuSamples: [],
|
|
uiHangs: [],
|
|
cpuHotspots: [],
|
|
memoryLeaks: [],
|
|
})),
|
|
}));
|
|
|
|
import { getDebugDir } from "../../src/utils/react-profiler/debug/dump";
|
|
import { profilerLoadTool } from "../../src/tools/profiler/query/profiler-load";
|
|
import { analyzeNativeProfilerIos } from "../../src/tools/profiler/native-profiler/platforms/ios";
|
|
|
|
const mockedGetDebugDir = vi.mocked(getDebugDir);
|
|
const SESSION_ID = "20200101-000000"; // deliberately ancient
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
async function buildIosSession(): Promise<NativeProfilerSessionApi> {
|
|
const device = { id: "ios-sim", platform: "ios" as const, kind: "simulator" as const };
|
|
const instance = await nativeProfilerSessionBlueprint.factory({}, device, { device });
|
|
return instance.api;
|
|
}
|
|
|
|
describe("iOS profiler-load → analyze freshness wiring (PR #340 comment 2)", () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await mkdtemp(join(tmpdir(), "argent-ios-load-freshness-"));
|
|
mockedGetDebugDir.mockResolvedValue(tempDir);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("leaves wallClockStartMs null after restoring an old iOS session (no sidecar exists)", async () => {
|
|
const api = await buildIosSession();
|
|
// The session API initializes wallClockStartMs to null; a real old session
|
|
// captured days ago would be loaded fresh into this state.
|
|
expect(api.wallClockStartMs).toBeNull();
|
|
|
|
// iOS sessions persist only raw_*.xml — there is NO metadata.json sidecar to
|
|
// carry a start time, unlike Android's .pftrace.metadata.json.
|
|
const cpuXml = join(tempDir, `native-profiler-${SESSION_ID}_raw_cpu.xml`);
|
|
await writeFile(cpuXml, "<trace-query-result></trace-query-result>", "utf8");
|
|
|
|
await profilerLoadTool.execute({ session: api } as never, {
|
|
mode: "load_native",
|
|
session_id: SESSION_ID,
|
|
port: 8081,
|
|
device_id: "ios-sim",
|
|
});
|
|
|
|
// The load succeeded and parsed the XML into memory…
|
|
expect(api.exportedFiles).toMatchObject({ cpu: cpuXml });
|
|
// …but the start time was never restored: still null. THIS is why the
|
|
// freshness note is dead on a loaded iOS session.
|
|
expect(api.wallClockStartMs).toBeNull();
|
|
|
|
// Drive analyze on the just-loaded session: the trace is "old" (loaded from
|
|
// a 2020 session id) yet NO stale-trace warning is emitted, because the
|
|
// start time the helper needs was never persisted.
|
|
const { report } = await analyzeNativeProfilerIos(api);
|
|
expect(report).not.toContain("Stale trace");
|
|
// Sanity: analyze did produce a real report (degrades cleanly, no crash).
|
|
expect(report).toContain("iOS Instruments Analysis");
|
|
});
|
|
|
|
it("a live iOS session WITH a start time still flags staleness (wiring is correct, just unreachable via load)", async () => {
|
|
const api = await buildIosSession();
|
|
// Simulate what native-profiler-start does in-process: stamp the start time.
|
|
api.wallClockStartMs = Date.now() - 2 * DAY_MS;
|
|
api.traceFile = join(tempDir, "live.trace");
|
|
api.exportedFiles = { cpu: null, hangs: null, leaks: null };
|
|
|
|
const { report } = await analyzeNativeProfilerIos(api);
|
|
expect(report).toContain("Stale trace");
|
|
});
|
|
|
|
it("clears ALL of a previous live capture's per-capture residue on load", async () => {
|
|
// The raw_*.xml carry no metadata sidecar, so nothing per-capture is known
|
|
// about a loaded trace. A live capture earlier in the same process leaves
|
|
// four fields behind, each of which would corrupt the loaded trace's
|
|
// analyze if it survived: mallocStackLogging mislabels the
|
|
// unattributed-leaks note, cpuFilterPid silently filters the loaded CPU
|
|
// samples by a dead PID, wallClockStartMs fires the stale-trace note with
|
|
// the wrong timestamp, and traceFile mislabels the report and writes the
|
|
// .md over the OLD trace's report.
|
|
const api = await buildIosSession();
|
|
api.mallocStackLogging = true;
|
|
api.cpuFilterPid = 4242;
|
|
api.wallClockStartMs = Date.now() - 2 * DAY_MS;
|
|
api.traceFile = join(tempDir, "previous-live.trace");
|
|
|
|
const cpuXml = join(tempDir, `native-profiler-${SESSION_ID}_raw_cpu.xml`);
|
|
await writeFile(cpuXml, "<trace-query-result></trace-query-result>", "utf8");
|
|
await profilerLoadTool.execute({ session: api } as never, {
|
|
mode: "load_native",
|
|
session_id: SESSION_ID,
|
|
port: 8081,
|
|
device_id: "ios-sim",
|
|
});
|
|
|
|
expect(api.mallocStackLogging).toBeNull();
|
|
expect(api.parsedData?.mallocStackLogging).toBeNull();
|
|
expect(api.cpuFilterPid).toBeNull();
|
|
expect(api.wallClockStartMs).toBeNull();
|
|
expect(api.traceFile).toBeNull();
|
|
|
|
// And analyze on the loaded session behaves like a fresh process: no
|
|
// stale-trace note from the old capture's start time, no report mislabeled
|
|
// with (or written next to) the old .trace.
|
|
const { report, reportFile } = await analyzeNativeProfilerIos(api);
|
|
expect(report).not.toContain("Stale trace");
|
|
expect(report).not.toContain("previous-live.trace");
|
|
expect(reportFile).toBeNull();
|
|
});
|
|
|
|
it("analyze refuses while a newer recording is in flight (would re-label the old exports)", async () => {
|
|
// analyze re-runs the pipeline from api.exportedFiles using the LIVE
|
|
// session fields — mid-recording those belong to the newer capture, so
|
|
// the old exports would render under the new trace's name, freshness
|
|
// anchor, and (on a degraded Xcode) the new capture's CPU filter PID.
|
|
// Same contract as the profiler-load guard: stop first.
|
|
const api = await buildIosSession();
|
|
api.exportedFiles = { cpu: null, hangs: null, leaks: null };
|
|
api.profilingActive = true;
|
|
api.capturePid = 12345;
|
|
api.traceFile = join(tempDir, "in-flight.trace");
|
|
|
|
await expect(analyzeNativeProfilerIos(api)).rejects.toThrow(/native-profiler-stop first/);
|
|
});
|
|
|
|
it("analyze refuses while a crashed capture awaits its recovery export", async () => {
|
|
const api = await buildIosSession();
|
|
api.exportedFiles = { cpu: null, hangs: null, leaks: null };
|
|
api.recordingExitedUnexpectedly = true;
|
|
api.lastExitInfo = { code: 137, signal: "SIGKILL" };
|
|
api.traceFile = join(tempDir, "crashed.trace");
|
|
|
|
// The stated cause must match the crash — not a timeout.
|
|
await expect(analyzeNativeProfilerIos(api)).rejects.toThrow(/ended unexpectedly/);
|
|
});
|
|
|
|
it("analyze names the 10-minute cap (not 'ended unexpectedly') for a timed-out capture", async () => {
|
|
// A 10-min-cap timeout sets recordingTimedOut (NOT recordingExitedUnexpectedly).
|
|
// stop itself distinguishes the two ("timed out at 10 min cap" vs "exited
|
|
// before stop"); the guard's stated cause must not contradict it.
|
|
const api = await buildIosSession();
|
|
api.exportedFiles = { cpu: null, hangs: null, leaks: null };
|
|
api.recordingTimedOut = true;
|
|
api.traceFile = join(tempDir, "timed-out.trace");
|
|
|
|
await expect(analyzeNativeProfilerIos(api)).rejects.toThrow(/10-minute recording cap/);
|
|
await expect(analyzeNativeProfilerIos(api)).rejects.not.toThrow(/ended unexpectedly/);
|
|
});
|
|
|
|
it("refuses load_native while a recording is in flight (residue clearing would wedge the session)", async () => {
|
|
// The residue clearing above nulls traceFile — with a live capture that
|
|
// would make native-profiler-stop throw NO_ACTIVE_SESSION (its gate needs
|
|
// traceFile) while native-profiler-start throws SESSION_ALREADY_RUNNING:
|
|
// wedged both ways, and the in-flight capture is orphaned unexportable.
|
|
// Loading must refuse up front and leave the live session untouched.
|
|
const api = await buildIosSession();
|
|
api.profilingActive = true;
|
|
api.capturePid = 12345;
|
|
api.captureProcess = { kill: vi.fn(), pid: 12345 } as never;
|
|
api.traceFile = join(tempDir, "in-flight.trace");
|
|
|
|
const cpuXml = join(tempDir, `native-profiler-${SESSION_ID}_raw_cpu.xml`);
|
|
await writeFile(cpuXml, "<trace-query-result></trace-query-result>", "utf8");
|
|
|
|
await expect(
|
|
profilerLoadTool.execute({ session: api } as never, {
|
|
mode: "load_native",
|
|
session_id: SESSION_ID,
|
|
port: 8081,
|
|
device_id: "ios-sim",
|
|
})
|
|
).rejects.toThrow(/native-profiler-stop first/);
|
|
|
|
expect(api.traceFile).toBe(join(tempDir, "in-flight.trace"));
|
|
expect(api.profilingActive).toBe(true);
|
|
});
|
|
|
|
it("refuses load_native while a crashed/timed-out capture awaits recovery export", async () => {
|
|
// recordingTimedOut / recordingExitedUnexpectedly + traceFile is the state
|
|
// stop's recovery branch exists for (export the partial trace). A load
|
|
// nulling traceFile would make that export unreachable forever — the raw
|
|
// .trace bundle cannot be re-ingested by profiler-load.
|
|
const api = await buildIosSession();
|
|
api.recordingExitedUnexpectedly = true;
|
|
api.lastExitInfo = { code: 137, signal: "SIGKILL" };
|
|
api.traceFile = join(tempDir, "crashed.trace");
|
|
|
|
const cpuXml = join(tempDir, `native-profiler-${SESSION_ID}_raw_cpu.xml`);
|
|
await writeFile(cpuXml, "<trace-query-result></trace-query-result>", "utf8");
|
|
|
|
await expect(
|
|
profilerLoadTool.execute({ session: api } as never, {
|
|
mode: "load_native",
|
|
session_id: SESSION_ID,
|
|
port: 8081,
|
|
device_id: "ios-sim",
|
|
})
|
|
).rejects.toThrow(/ended unexpectedly/);
|
|
|
|
expect(api.traceFile).toBe(join(tempDir, "crashed.trace"));
|
|
expect(api.recordingExitedUnexpectedly).toBe(true);
|
|
});
|
|
|
|
it("load names the 10-minute cap (not 'ended unexpectedly') for a timed-out capture", async () => {
|
|
const api = await buildIosSession();
|
|
api.recordingTimedOut = true;
|
|
api.traceFile = join(tempDir, "timed-out.trace");
|
|
|
|
const cpuXml = join(tempDir, `native-profiler-${SESSION_ID}_raw_cpu.xml`);
|
|
await writeFile(cpuXml, "<trace-query-result></trace-query-result>", "utf8");
|
|
|
|
await expect(
|
|
profilerLoadTool.execute({ session: api } as never, {
|
|
mode: "load_native",
|
|
session_id: SESSION_ID,
|
|
port: 8081,
|
|
device_id: "ios-sim",
|
|
})
|
|
).rejects.toThrow(/10-minute recording cap/);
|
|
|
|
// Recovery state untouched — the partial trace stays reachable by stop.
|
|
expect(api.traceFile).toBe(join(tempDir, "timed-out.trace"));
|
|
expect(api.recordingTimedOut).toBe(true);
|
|
});
|
|
});
|