scripts/capture_sim_memgraph.sh
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Capture a memory graph from a running iOS simulator app.
Required:
--udid UDID Simulator UDID
--bundle-id ID App bundle identifier, e.g. com.example.app
Optional:
--out-dir DIR Output directory for the memgraph and leaks output
Example:
capture_sim_memgraph.sh --udid "$SIM" --bundle-id com.example.app --out-dir /tmp/codex-ios-memgraph
USAGE
}
require_value() {
local flag="$1"
local value="${2:-}"
if [[ -z "$value" ]]; then
echo "$flag requires a value" >&2
usage >&2
exit 2
fi
}
bundle_id=""
out_dir=""
udid=""
while [[ $# -gt 0 ]]; do
case "$1" in
--bundle-id)
require_value "$1" "${2:-}"
bundle_id="$2"
shift 2
;;
--out-dir)
require_value "$1" "${2:-}"
out_dir="$2"
shift 2
;;
--udid)
require_value "$1" "${2:-}"
udid="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "$udid" ]]; then
echo "--udid is required" >&2
usage >&2
exit 2
fi
if [[ -z "$bundle_id" ]]; then
echo "--bundle-id is required" >&2
usage >&2
exit 2
fi
if [[ -z "$out_dir" ]]; then
out_dir="$(mktemp -d "${TMPDIR:-/tmp}/codex-ios-memgraph.XXXXXX")"
fi
matching_processes="$(
xcrun simctl spawn "$udid" launchctl list |
awk -v bundle_id="$bundle_id" '
$1 == "-" {
next
}
$3 == bundle_id {
print $1 "\t" $3
next
}
index($3, "UIKitApplication:" bundle_id "[") == 1 {
print $1 "\t" $3
}
'
)"
if [[ -z "$matching_processes" ]]; then
echo "Could not find a running PID for $bundle_id on $udid" >&2
exit 1
fi
if [[ "$(printf '%s\n' "$matching_processes" | wc -l | tr -d ' ')" -ne 1 ]]; then
echo "Found multiple running PIDs for $bundle_id on $udid:" >&2
printf '%s\n' "$matching_processes" >&2
exit 1
fi
pid="$(printf '%s\n' "$matching_processes" | awk '{ print $1 }')"
process_label="$(printf '%s\n' "$matching_processes" | cut -f2-)"
mkdir -p "$out_dir"
timestamp="$(date +%Y%m%d-%H%M%S)"
safe_bundle="$(printf '%s' "$bundle_id" | tr -c 'A-Za-z0-9_.-' '_')"
memgraph="$out_dir/$safe_bundle-$pid-$timestamp.memgraph"
leaks_output="$out_dir/$safe_bundle-$pid-$timestamp.leaks.txt"
metadata="$out_dir/$safe_bundle-$pid-$timestamp.metadata.txt"
{
echo "date: $(date)"
echo "udid: $udid"
echo "bundle_id: $bundle_id"
echo "process_label: $process_label"
echo "pid: $pid"
echo "memgraph: $memgraph"
echo "leaks_output: $leaks_output"
} > "$metadata"
set +e
leaks "--outputGraph=$memgraph" "$pid" > "$leaks_output" 2>&1
leaks_status=$?
set -e
echo "leaks_exit_status: $leaks_status" >> "$metadata"
if [[ ! -f "$memgraph" ]]; then
echo "memgraph_missing: true" >> "$metadata"
echo "leaks failed to create a memgraph; see: $leaks_output" >&2
echo "metadata: $metadata" >&2
exit 1
fi
echo "memgraph: $memgraph"
echo "leaks output: $leaks_output"
echo "metadata: $metadata"
scripts/summarize_memgraph_leaks.py
#!/usr/bin/env python3
"""Summarize leaks output from an Apple .memgraph file."""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
LEAK_RE = re.compile(r"^Leak:\s+(?P<address>0x[0-9a-fA-F]+)\s+size=(?P<size>\d+)\s+(?P<rest>.*)$")
TOTAL_RE = re.compile(r"Process\s+\S+:\s+(?P<count>\d+)\s+leaks?\s+for\s+(?P<bytes>\d+)\s+total leaked bytes")
def run_leaks(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(["leaks", *args], text=True, capture_output=True, check=False)
def parse_leaks(output: str) -> tuple[str | None, list[dict[str, str]]]:
total = None
leaks: list[dict[str, str]] = []
for line in output.splitlines():
if total is None:
match = TOTAL_RE.search(line)
if match:
total = f"{match.group('count')} leaks / {match.group('bytes')} bytes"
match = LEAK_RE.match(line)
if match:
fields = match.groupdict()
rest = fields.pop("rest")
rest = re.sub(r"^zone:\s+\S+\s+", "", rest)
parts = re.split(r"\s{2,}", rest.strip(), maxsplit=2)
if len(parts) == 3:
fields["type"], fields["language"], fields["image"] = parts
elif len(parts) == 2:
fields["type"], fields["image"] = parts
fields["language"] = "<unknown>"
else:
fields["type"] = rest.strip() or "<unknown>"
fields["language"] = "<unknown>"
fields["image"] = "<unknown>"
leaks.append(fields)
return total, leaks
def trace_excerpt(memgraph: Path, address: str, max_lines: int) -> str:
result = run_leaks([f"--traceTree={address}", str(memgraph)])
text = result.stdout or result.stderr
lines = [line.rstrip() for line in text.splitlines() if line.strip()]
return "\n".join(lines[:max_lines])
def group_by_type_excerpt(memgraph: Path, max_lines: int) -> str:
result = run_leaks(["--groupByType", str(memgraph)])
text = result.stdout or result.stderr
lines = [line.rstrip() for line in text.splitlines() if line.strip()]
return "\n".join(lines[:max_lines])
def render(memgraph: Path, trace_limit: int, trace_lines: int, raw_output: str) -> str:
total, leaks = parse_leaks(raw_output)
by_type = Counter(leak["type"] for leak in leaks)
by_image = Counter(leak["image"] for leak in leaks)
lines: list[str] = []
lines.append(f"# Leak Summary: {memgraph}")
lines.append("")
lines.append(f"- Total: {total or 'not found'}")
lines.append(f"- Parsed leak entries: {len(leaks)}")
lines.append("")
if by_type:
lines.append("## Top Types")
for name, count in by_type.most_common(20):
lines.append(f"- {count}x {name}")
lines.append("")
if by_image:
lines.append("## Top Images")
for name, count in by_image.most_common(20):
lines.append(f"- {count}x {name}")
lines.append("")
if leaks:
lines.append("## Leak Entries")
for leak in leaks[:50]:
lines.append(
f"- {leak['address']} size={leak['size']} type={leak['type']} "
f"image={leak['image']}"
)
if len(leaks) > 50:
lines.append(f"- ... {len(leaks) - 50} more")
lines.append("")
if trace_limit > 0 and leaks:
lines.append("## TraceTree Excerpts")
for leak in leaks[:trace_limit]:
lines.append(f"### {leak['address']} {leak['type']}")
excerpt = trace_excerpt(memgraph, leak["address"], trace_lines)
lines.append("~~~text")
lines.append(excerpt or "<no trace output>")
lines.append("~~~")
lines.append("")
if leaks:
lines.append("## Grouped Leak Tree")
lines.append("Use this when `traceTree` has no roots, which is common for unreachable retain cycles.")
lines.append("~~~text")
lines.append(group_by_type_excerpt(memgraph, trace_lines) or "<no grouped leak output>")
lines.append("~~~")
lines.append("")
lines.append("## Raw Commands")
lines.append("~~~bash")
lines.append(f"leaks --list {memgraph}")
if leaks:
lines.append(f"leaks --groupByType {memgraph}")
if leaks:
lines.append(f"leaks --traceTree={leaks[0]['address']} {memgraph}")
lines.append("~~~")
lines.append("")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("memgraph", type=Path)
parser.add_argument("--trace-limit", type=int, default=0, help="Number of leaks to trace with --traceTree")
parser.add_argument("--trace-lines", type=int, default=80, help="Max lines per traceTree excerpt")
parser.add_argument("--out", type=Path, help="Write markdown summary to this file")
args = parser.parse_args()
if not args.memgraph.exists():
print(f"memgraph not found: {args.memgraph}", file=sys.stderr)
return 2
result = run_leaks(["--list", str(args.memgraph)])
raw = result.stdout or result.stderr
total, leaks = parse_leaks(raw)
if result.returncode != 0 and total is None and not leaks:
print(raw, file=sys.stderr, end="" if raw.endswith("\n") else "\n")
return result.returncode or 1
summary = render(args.memgraph, args.trace_limit, args.trace_lines, raw)
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(summary)
print(args.out)
else:
print(summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())
SKILL.md
---
name: ios-memgraph-leaks
description: Capture and inspect iOS leaks and memgraphs. Use when debugging leaked objects, retain cycles, memory growth, or before/after leak evidence.
---
# iOS Memgraph Leaks
Use this skill to prove iOS leaks from a live simulator process or an existing `.memgraph`. Pair it with `../ios-debugger-agent/SKILL.md` when the task also needs simulator build, install, launch, UI driving, logs, or screenshots.
## Core Workflow
1. Build, launch, and drive the exact flow that should release objects.
2. Capture a memgraph from the running simulator process with `scripts/capture_sim_memgraph.sh`.
3. Summarize leaks with `scripts/summarize_memgraph_leaks.py`.
4. For each app-owned leaked type, inspect ownership with `leaks --traceTree=<address> <file.memgraph>` and grouped leak evidence.
5. Make the smallest root-cause patch, then recapture the same flow on the same simulator when possible.
6. Report proof: before/after leak counts, disappeared root types, remaining leaks, memgraph paths, and test/build results.
Do not claim a leak fix from a smaller memgraph alone. A credible fix explains the ownership path that kept the object alive and shows that the same path or type disappears after the patch.
## Capture
Prefer capturing from the simulator already used for the reproduction. Resolve the simulator UDID and app bundle identifier, then capture the running app:
```bash
SKILL_DIR="<absolute path to this loaded skill folder>"
SIM="<simulator-udid>"
BUNDLE_ID="<app.bundle.identifier>"
MEMGRAPH_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codex-ios-memgraph.XXXXXX")"
"$SKILL_DIR/scripts/capture_sim_memgraph.sh" \
--udid "$SIM" \
--bundle-id "$BUNDLE_ID" \
--out-dir "$MEMGRAPH_DIR"
```
Do not derive `SKILL_DIR` from the target app repo's `pwd`; installed plugins usually live outside the app being debugged. Store captures in a run-specific temp or user-chosen folder, not under `SKILL_DIR`.
If the process cannot be found, confirm the bundle identifier and use `xcrun simctl spawn "$SIM" launchctl list` to inspect running labels.
## Summarize
Summarize an existing memgraph:
```bash
"$SKILL_DIR/scripts/summarize_memgraph_leaks.py" \
/path/to/app.memgraph \
--trace-limit 5 \
--out /path/to/leak-summary.md
```
Use `--trace-limit` sparingly. Trace trees are useful root-cause evidence, but large memgraphs can produce noisy output. If a trace tree says `Found 0 roots referencing`, treat it as an unreachable/self-retained leak candidate and use the summary's grouped leak tree or `leaks --groupByType <file.memgraph>` to identify the retained fields and payload chain.
## Root Cause Rules
- Identify the first app-owned leaked type in the leak output or trace.
- Determine the intended lifetime: process, session, account, view, request, or task.
- Treat lazy or deferred allocation as a scope reduction, not a leak fix, unless the original eager allocation itself violated the intended lifetime.
- Prove retain-cycle claims with either a `traceTree` ownership path or an isolated reproduction.
- For unreachable/self-cycle leaks, `traceTree` may have no root path; use `leaks --groupByType` plus source verification to find the self-retaining edge.
- Do not claim success just because total leak count went down; prove the specific type or path disappeared.
- Separate real root-cause branches from candidate/noise branches.
- Prefer deleting the retaining edge over adding broad cleanup code.
## Report
A useful leak report includes:
- the exact flow and simulator/app build
- the memgraph and summary paths
- app-owned leaked types and counts
- at least one ownership path, or grouped leak tree evidence when the object is unreachable from roots
- the smallest proposed or applied retaining-edge fix
- before/after evidence when a fix was made
If the memgraph shows only framework/runtime noise, say that and recommend the next narrower capture rather than inventing an app leak.