Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
13771fb
feat(ios-profiler): attributable memory leaks via malloc_stack_logging
latekvo Jun 16, 2026
58a8d62
docs(ios-profiler): document malloc_stack_logging for attributable leaks
latekvo Jun 16, 2026
9140f0f
refactor(ios-profiler): dedup installed-apps listing in enumerateRunn…
latekvo Jun 18, 2026
573c469
Merge remote-tracking branch 'origin/main' into profiler-attributable…
latekvo Jun 18, 2026
a7772de
Merge remote-tracking branch 'origin/main' into profiler-attributable…
latekvo Jun 24, 2026
9e12335
fix(ios-profiler): guard malloc_stack_logging against degraded Xcode;…
latekvo Jun 30, 2026
0c1b274
test(ios-profiler): cover best-effort relaunch when malloc cold launc…
latekvo Jun 30, 2026
90956cd
fix(ios-profiler): resolve the debug dir before terminating the app f…
latekvo Jul 1, 2026
5aebc21
fix(ios-profiler): attribute malloc refusal accurately, restore only …
latekvo Jul 1, 2026
73fafa4
refactor(ios-profiler): use execFileSync for get_app_container and te…
latekvo Jul 1, 2026
37231e2
fix(ios-profiler): attribute leaks per call site, not per object type
latekvo Jul 2, 2026
88db2f6
fix(ios-profiler): address review feedback on the malloc_stack_loggin…
latekvo Jul 3, 2026
ac28cfd
fix(ios-profiler): give getInstalledApps a generous execFileSync buffer
latekvo Jul 3, 2026
d819b3a
test(ios-profiler): type the execFileSync mock options arg for typech…
latekvo Jul 3, 2026
b40765b
Merge branch 'main' into profiler-attributable-leaks
latekvo Jul 3, 2026
7b87427
fix(ios-profiler): address review feedback on malloc_stack_logging
latekvo Jul 7, 2026
e0aa252
docs(ios-profiler): scrub the last "26.4-27.0" range from the isDegra…
latekvo Jul 7, 2026
bbd2f4e
test(ios-profiler): pin getAppBundlePath-before-terminate ordering (m…
latekvo Jul 7, 2026
acf0d76
fix(ios-profiler): quote the operator's literal ARGENT_IOS_CAPTURE in…
latekvo Jul 7, 2026
d0b6700
fix(ios-profiler): group leaks by attribution-normalized frame
latekvo Jul 9, 2026
9aa05db
docs(ios-profiler): note ARGENT_IOS_CAPTURE=all-processes rejects mal…
latekvo Jul 9, 2026
737e56b
fix(ios-profiler): thread the real capture mode into the leak reports
latekvo Jul 9, 2026
e2d9aff
test(ios-profiler): pin the notify-present malloc argv and --no-prompt
latekvo Jul 9, 2026
284ccf8
fix(ios-profiler): clear stale mallocStackLogging when loading a sess…
latekvo Jul 10, 2026
8c80cd1
fix(ios-profiler): make the leak_stacks drill-down capture-mode aware
latekvo Jul 10, 2026
1c6b5bd
fix(ios-profiler): pair the capture mode with the data it describes
latekvo Jul 10, 2026
0ea2bad
docs(ios-profiler): describe the (objectType, normalized frame) leak …
latekvo Jul 10, 2026
d016a8a
fix(ios-profiler): guard load against live captures; evidence-first l…
latekvo Jul 10, 2026
07594b1
fix(ios-profiler): protect pending recoveries, disambiguate malloc la…
latekvo Jul 10, 2026
d05b862
fix(ios-profiler): guard combined-report mid-capture, unify in-flight…
latekvo Jul 10, 2026
8dcca01
fix(ios-profiler): freeze combined-report anchor at analyze; escape t…
latekvo Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/registry/src/failure-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ export const FAILURE_CODES = {
NATIVE_PROFILER_NO_RUNNING_APPS: "NATIVE_PROFILER_NO_RUNNING_APPS",
NATIVE_PROFILER_NO_RUNNING_USER_APPS: "NATIVE_PROFILER_NO_RUNNING_USER_APPS",
NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS: "NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS",
NATIVE_PROFILER_MALLOC_DEGRADED_XCODE: "NATIVE_PROFILER_MALLOC_DEGRADED_XCODE",
NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE: "NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE",
NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED: "NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED",
NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND: "NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND",
NATIVE_PROFILER_LAUNCH_APP_AMBIGUOUS: "NATIVE_PROFILER_LAUNCH_APP_AMBIGUOUS",
NATIVE_PROFILER_SESSION_ALREADY_RUNNING: "NATIVE_PROFILER_SESSION_ALREADY_RUNNING",
NATIVE_PROFILER_XCTRACE_NO_PID: "NATIVE_PROFILER_XCTRACE_NO_PID",
NATIVE_PROFILER_XCTRACE_PROCESS_NOT_FOUND: "NATIVE_PROFILER_XCTRACE_PROCESS_NOT_FOUND",
Expand Down
1 change: 1 addition & 0 deletions packages/skills/skills/argent-native-profiler/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ After `native-profiler-analyze` surfaces findings, use `profiler-stack-query` to
- **Hang detected** → `profiler-stack-query` mode=`hang_stacks` for full native call chains → mode=`function_callers` for the suspected function → read native source.
- **CPU hotspot** → `profiler-stack-query` mode=`thread_breakdown` for per-thread distribution → mode=`function_callers` for the dominant function.
- **Memory leak** → `profiler-stack-query` mode=`leak_stacks` filtered by `object_type` for responsible frames and libraries.
- iOS: if leaks come back unattributed (responsible frame `<Call stack limit reached>`), re-run `native-profiler-start` with `malloc_stack_logging: true`. This cold-launches the app with Malloc Stack Logging so leaks carry a real allocation backtrace (responsible frame + library). It restarts the app and adds overhead, so use it only when you need leak attribution — not for CPU/hang passes.

After presenting findings, ask the user whether to investigate further, implement fixes, or stop. After applying fixes, always re-profile the same scenario and compare with `profiler-load`. Report honestly whether the target metric improved, regressed, or stayed flat. If the fix showed no net benefit or introduced regressions elsewhere, say so and reconsider.

Expand Down
33 changes: 33 additions & 0 deletions packages/tool-server/src/blueprints/native-profiler-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ export interface NativeProfilerParsedData {
uiHangs: UiHang[];
cpuHotspots: CpuHotspot[];
memoryLeaks: MemoryLeak[];
/**
* Capture mode of THIS parsed data (see the session field of the same name),
* frozen at parse time so drill-down consumers (leak_stacks, the combined
* report) stay paired with the data even after a newer capture re-stamps the
* session. Null when unknown (session restored from disk).
*/
mallocStackLogging: boolean | null;
/**
* Recording start (wall-clock ms) of THIS parsed data, frozen at parse time.
* The combined report anchors these hangs to wall-clock time; reading the live
* session `wallClockStartMs` instead would pair frozen hangs with a NEWER
* capture's start once a second recording re-stamps the session, shifting every
* hang. Null for iOS sessions restored from disk (no start-time sidecar).
*/
wallClockStartMs: number | null;
}

export interface NativeProfilerSessionApi {
Expand All @@ -55,6 +70,22 @@ export interface NativeProfilerSessionApi {
* scopes via --attach and leaves this null. See utils/ios-profiler/capture-strategy.
*/
cpuFilterPid: number | null;
/**
* iOS-only: whether the IN-FLIGHT (or most recently attempted) recording was
* cold-launched with MallocStackLogging=1 (native-profiler-start's
* malloc_stack_logging flag). Stamped at start, copied into
* `mallocStackLogging` when stop writes `exportedFiles` — the split keeps a
* new start from re-labeling the previous capture's still-loaded data.
*/
recordingMallocStackLogging: boolean | null;
/**
* iOS-only: capture mode of the data currently in `exportedFiles` (and, via
* analyze, `parsedData`) — the report layer names it instead of inferring it
* from the attributed-leak count. Stamped at stop alongside `exportedFiles`;
* cleared by profiler-load (the raw_*.xml carry no capture-mode sidecar).
* Null when unknown — before any stop, on Android, or after a load.
*/
mallocStackLogging: boolean | null;
recordingTimeout: NodeJS.Timeout | null;
recordingTimedOut: boolean;
recordingExitedUnexpectedly: boolean;
Expand Down Expand Up @@ -127,6 +158,8 @@ export const nativeProfilerSessionBlueprint: ServiceBlueprint<
wallClockStartMs: null,
parsedData: null,
cpuFilterPid: null,
recordingMallocStackLogging: null,
mallocStackLogging: null,
recordingTimeout: null,
recordingTimedOut: false,
recordingExitedUnexpectedly: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import {
import type { HotCommitSummary } from "../../../utils/react-profiler/types/output";
import type { UiHang, MemoryLeak } from "../../../utils/profiler-shared/types";
import { formatBytes } from "../../../utils/profiler-shared/format";
import {
isCaptureInFlight,
inFlightGuardMessage,
} from "../../../utils/profiler-shared/capture-guard";
import { renderUnattributedLeaksNote } from "../../../utils/ios-profiler/render";
import { loadAndroidCombinedData } from "../../../utils/android-profiler/pipeline/index";
import { buildHotCommitSummaries } from "../../../utils/react-profiler/pipeline/00-hot-commits";
import { preprocess } from "../../../utils/react-profiler/pipeline/00-preprocess";
Expand Down Expand Up @@ -61,6 +66,33 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c
async execute(services, params) {
const nativeApi = services.nativeSession as NativeProfilerSessionApi;

// Freshness gate: once a newer capture is recording (or ended and pending
// recovery), the frozen iOS parsedData this report renders is stale relative
// to what the user is now capturing. Refuse and point them at a fresh analyze
// so they get the current capture's correlations, not the previous one's.
// (Numeric correctness is handled independently by freezing the wall-clock
// anchor into parsedData at analyze — see nativeWallStart below — so even if
// this gate is bypassed the report stays self-consistent rather than mixing
// one capture's hangs with another's clock.) The retryAction must include
// native-profiler-analyze: unlike analyze/profiler-load, which re-derive
// their data on retry, this report consumes the frozen parsedData that ONLY
// native-profiler-analyze rewrites, so "stop then re-run" alone would render
// the previous capture again.
if (isCaptureInFlight(nativeApi)) {
throw new FailureError(
inFlightGuardMessage(
nativeApi,
"run native-profiler-analyze, then re-run profiler-combined-report"
),
{
error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_ALREADY_RUNNING,
failure_stage: "profiler_combined_report_session_state",
failure_area: "tool_server",
error_kind: "validation",
}
);
}

// For iOS, the analyze step cached uiHangs + memoryLeaks in parsedData.
// For Android, drill-down re-queries the .pftrace, so we load the same
// shape on demand here.
Expand Down Expand Up @@ -126,7 +158,15 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c
}

const reactWallStart = onDisk.meta?.profileStartWallMs ?? null;
const nativeWallStart = nativeApi.wallClockStartMs;
// iOS anchors the FROZEN parsedData hangs, so it must use the anchor frozen
// WITH them (at analyze), not the live session field — a later
// native-profiler-start re-stamps the live field to a different capture and
// would shift every hang. Android re-derives hangs from the live traceFile
// (loadAndroidCombinedData above), so its live anchor stays consistent.
const nativeWallStart =
nativeApi.platform === "android"
? nativeApi.wallClockStartMs
: (nativeApi.parsedData?.wallClockStartMs ?? null);

if (!reactWallStart && !nativeWallStart) {
throw new FailureError(
Expand Down Expand Up @@ -330,7 +370,15 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c
.map((c) => c.componentName)
);

lines.push(...renderCombinedMemoryLeaks(memoryLeaks, mountComponents));
lines.push(
...renderCombinedMemoryLeaks(
memoryLeaks,
mountComponents,
// From parsedData, not the live session field: a recording started
// after the analyze must not re-label the data being rendered here.
nativeApi.parsedData?.mallocStackLogging ?? null
)
);
}

// Summary of opportunities
Expand Down Expand Up @@ -371,11 +419,16 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c
* heuristically tied to recently-mounted React components; unattributed leaks
* (`<Call stack limit reached>` under `xctrace --attach`) are collapsed into one
* low-confidence YELLOW caveat so the simulator's benign system-allocation noise
* can't masquerade as a wall of confirmed leaks. Exported for unit testing.
* can't masquerade as a wall of confirmed leaks. The caveat line comes from
* render.ts's shared renderUnattributedLeaksNote, so its capture-mode handling
* (and wording) cannot drift from the analyze report. `mallocStackLogging` is
* the session's actual capture mode when known; null/undefined falls back to
* inferring it from the attributed count. Exported for unit testing.
*/
export function renderCombinedMemoryLeaks(
memoryLeaks: MemoryLeak[],
mountComponents: Set<string>
mountComponents: Set<string>,
mallocStackLogging?: boolean | null
): string[] {
if (memoryLeaks.length === 0) return [];

Expand All @@ -402,15 +455,9 @@ export function renderCombinedMemoryLeaks(
}

if (unattributedLeaks.length > 0) {
const objs = unattributedLeaks.reduce((s, b) => s + b.count, 0);
const bytes = unattributedLeaks.reduce((s, b) => s + b.totalSizeBytes, 0);
lines.push(
``,
`> 🟡 **${unattributedLeaks.length} unattributed leak group(s)** ` +
`(${objs} object(s), ${formatBytes(bytes)}): responsible frame \`<Call stack limit reached>\`, no library. ` +
`Argent records via \`xctrace --attach\`, which has no malloc-stack history, so these are most likely ` +
`benign system allocations rather than confirmed app leaks. For attributed stacks, capture with malloc ` +
`stack logging enabled at launch.`
renderUnattributedLeaksNote(unattributedLeaks, attributedLeaks.length, mallocStackLogging)
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@ const zodSchema = z.object({
"iOS-only: path to an Instruments .tracetemplate file (defaults to bundled Argent template). " +
"Ignored on Android."
),
malloc_stack_logging: z
.boolean()
.optional()
.describe(
"iOS-only. When true, cold-launches the app under the profiler with Malloc Stack Logging " +
"enabled so memory leaks carry an allocation backtrace (responsible frame + library). " +
"Without it, leaks are still detected but unattributable — Instruments reports " +
"'<Call stack limit reached>'. Trade-offs: this RESTARTS the app (current state is lost), " +
"adds memory/CPU overhead, and makes the app noticeably slow to launch (every startup " +
"allocation records a backtrace), so leave it off for pure CPU/hang profiling. Requires a " +
"non-degraded Xcode: on Xcode 26.4 and later the cold-launch path is broken, so the call is " +
"rejected up front (re-run without the flag, or set ARGENT_IOS_CAPTURE=device to override if the " +
Comment thread
latekvo marked this conversation as resolved.
"device path works on your host). ARGENT_IOS_CAPTURE=all-processes — e.g. exported globally " +
"for the normal capture path — also rejects this flag up front, since that fallback cannot " +
"cold-launch; unset it (or set it to device) first. " +
"Ignored on Android."
),
});

const capability = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,26 @@ export async function startNativeProfilerAndroid(
.slice(0, 15);
const hostTracePath = path.join(debugDir, `native-profiler-${timestamp}.pftrace`);

api.recordingTimedOut = false;
api.recordingExitedUnexpectedly = false;
api.lastExitInfo = null;
api.appProcess = appPackage;
api.traceFile = hostTracePath;

// Start perfetto BEFORE mutating any session state: a failed start (adb
// error, device offline, spawn failure) must be non-destructive. If a prior
// capture hit the 10-min cap or exited early, its partial trace is still
// recoverable via native-profiler-stop, and its recordingTimedOut/
// recordingExitedUnexpectedly/traceFile fields must survive an unrelated
// failed start attempt — otherwise the pending recovery is silently burned.
// (Same contract as the iOS start path.)
const { pid, onDeviceTracePath, child } = await startPerfetto({
serial: params.device_id,
appPackage,
timestamp,
});

// Perfetto is up — this capture now owns the session; stamp its descriptors
// and clear any prior capture's recovery flags (superseded on success only).
api.recordingTimedOut = false;
api.recordingExitedUnexpectedly = false;
api.lastExitInfo = null;
api.appProcess = appPackage;
api.traceFile = hostTracePath;
api.capturePid = pid;
api.captureProcess = child;
api.androidOnDeviceTracePath = onDeviceTracePath;
Expand Down
Loading