Memory Leak Profiler & Debugger
1. System Architecture & Prerequisites
- Runtime binary: Python 3.9 or newer (relies on
tracemalloc.Statistic.traceback, gc.get_objects, weakref, and inspect). Python 3.11+ is recommended.
- Standard library only:
tracemalloc, gc, weakref, inspect, importlib, atexit, argparse, json, sys, pathlib, dataclasses, typing.
- For Node.js/V8 targets the same skill documents the equivalent invocation
node --heapsnapshot-near-heap-limit=5 --heap-prof app.js (see Section 4); the Python module itself is interpreter-native.
- The target under test must be a zero-argument callable
module:func that performs one unit of work (handler, request cycle, worker tick). Long-lived process frames are preferred over CLI programs that exit, because the leak signature this skill detects requires repeated calls in one Python process.
2. Input/Output Data Contracts
2.1 CLI input options (JSON Schema)
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "leak_profiler_cli_options",
"type": "object",
"properties": {
"target": { "type": "string", "description": "Zero-arg callable as 'module:func' or 'module.attr'." },
"iterations": { "type": "integer", "default": 6, "minimum": 2, "description": "Number of paired snapshot iterations." },
"min-growth": { "type": "integer", "default": 1048576, "description": "Bytes of cumulative growth required to classify a leak signature." },
"key": { "type": "string", "enum": ["lineno", "filename", "traceback"], "default": "lineno" },
"out": { "type": "string", "description": "Write the JSON report here." },
"node": { "type": "boolean", "default": false, "description": "Print V8 heap-snapshot guidance and exit." }
}
}
2.2 Output artifacts
- JSON report (stdout and optional
--out <path>):
{
"target": "leaky_demo",
"iterations": 6,
"min_growth_threshold_bytes": 1048576,
"candidates": [
{
"type": "allocation",
"size": 6291456,
"growth": 5242880,
"iterations": 6,
"traceback": "leak_profiler.py:px <- leak_demo:py <- run:0"
}
],
"live_type_deltas": { "builtins.bytearray": 96 },
"collections_fired": 12,
"survived_weakrefs": [],
"circular_clusters": [],
"peak_traced_bytes": 8388608
}
- The instrumented target is never written to; only the report bytes and
tracemalloc's in-process tracing state are produced.
3. Production Reference Implementation
"""Memory Leak Profiler & Debugger.
Instruments a target callable under ``tracemalloc`` and the cyclic garbage
collector, then classifies sustained per-traceback allocation growth as
leak signatures.
Pipeline:
1. ``take_snapshot()`` returns a ``tracemalloc.Snapshot`` (starting tracing
on first use).
2. ``top_allocations()`` / ``compare()`` summarize snapshots and deltas.
3. ``Detector.run(target, iterations)``:
a. runs the target inside ``tracemalloc.start()`` and keeps paired
Nth snapshots (before/after each iteration),
b. accumulates per-traceback cumulative bytes across iterations and
flags signatures whose cumulative size keeps growing and whose
traceback repeats (leak signature),
c. uses ``gc.get_objects()`` to count live objects per type and tracks
``weakref.ref`` objects that should have died but did not,
d. reports circular-reference clusters via ``gc.get_referrers``.
4. A ``LeakReport`` (list of ``LeakCandidate{type, size, iterations,
traceback_fp}``) is emitted as JSON.
No third-party dependencies. For Node.js/V8 the equivalent command is
documented in the node instructions section (``--heapsnapshot-near-heap-limit``).
"""
from __future__ import annotations
import argparse
import atexit
import gc
import importlib
import inspect
import json
import sys
import tracemalloc
import weakref
from dataclasses import dataclass, field
from pathlib import Path
from types import ModuleType
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
def take_snapshot() -> tracemalloc.Snapshot:
if not tracemalloc.is_tracing():
tracemalloc.start(25)
return tracemalloc.take_snapshot()
def top_allocations(snapshot: tracemalloc.Snapshot, n: int = 10,
key: str = "lineno") -> List[tracemalloc.Statistic]:
if key not in ("lineno", "filename", "traceback"):
raise ValueError(f"key must be one of lineno/filename/traceback, got {key!r}")
stats = snapshot.statistics(key)
stats.sort(key=lambda s: s.size, reverse=True)
return stats[:n]
def compare(snap1: tracemalloc.Snapshot, snap2: tracemalloc.Snapshot,
n: int = 10, key: str = "lineno") -> List[tracemalloc.StatisticDiff]:
if key not in ("lineno", "filename", "traceback"):
raise ValueError(f"key must be one of lineno/filename/traceback, got {key!r}")
diffs = snap2.compare_to(snap1, key)
diffs.sort(key=lambda d: d.size_diff, reverse=True)
out = [d for d in diffs[:n] if d.size_diff > 0]
return out or [d for d in diffs if d.size_diff > 0][:1]
def _frames_of(stat) -> List[Tuple[str, int, str]]:
"""Best-effort (filename, lineno, name) frames for a statistic object."""
tb = getattr(stat, "traceback", None)
if tb is None:
return []
frames: List[Tuple[str, int, str]] = []
try:
for frame in iter(tb):
if hasattr(frame, "filename"):
frames.append((frame.filename, frame.lineno, getattr(frame, "name", "")))
else:
frames.append((frame[0], frame[1], frame[2] if len(frame) > 2 else ""))
if len(frames) >= 8:
break
except (TypeError, IndexError, AttributeError):
try:
formatted = tb.format()
frames = [(ln, 0, "") for ln in formatted[:8]]
except Exception:
frames = [(repr(stat), 0, "")]
return frames
def _fingerprint(stat) -> Tuple[Tuple[str, int], ...]:
fr = _frames_of(stat)
if not fr:
return (("<unknown>", 0),)
return tuple((f[0], f[1]) for f in fr[:4])
def live_object_counts() -> Dict[str, int]:
counts: Dict[str, int] = {}
for obj in gc.get_objects():
t = type(obj)
key = f"{t.__module__}.{t.__qualname__}"
counts[key] = counts.get(key, 0) + 1
return counts
def _is_leaky(vec: List[int], iterations: int, min_growth: int, min_iterations: int) -> bool:
if len(vec) < max(2, min_iterations):
return False
if vec[-1] - vec[0] < min_growth:
return False
growths = sum(1 for a, b in zip(vec, vec[1:]) if b > a)
return growths >= max(2, len(vec) - 1)
def _candidate_from(fp, vec: List[int]) -> "LeakCandidate":
tail = " <- ".join(f"{f[0]}:{f[1]}" for f in fp)
return LeakCandidate(
type="allocation",
size=vec[-1],
growth=vec[-1] - vec[0],
iterations=len(vec),
traceback_fp=tail,
)
def find_circular_clusters(root: Any, max_depth: int = 8, limit: int = 250) -> List[str]:
"""Find reference cycles that keep ``root`` alive; render type chains."""
clusters: List[str] = []
visited = 0
def keep(referrer: Any) -> bool:
return not (
inspect.isframe(referrer) or inspect.iscode(referrer)
or isinstance(referrer, (type, ModuleType))
)
def walk(obj: Any, path: List[Any]) -> None:
nonlocal visited
if len(clusters) >= 5 or visited >= limit or len(path) >= max_depth:
return
for referrer in gc.get_referrers(obj):
visited += 1
if referrer is root:
chain = [type(o).__name__ for o in path] + [type(obj).__name__, type(root).__name__]
clusters.append(" -> ".join(chain))
return
if not keep(referrer):
continue
if any(ref is referrer for ref in path):
continue
walk(referrer, path + [obj])
walk(root, [])
return list(dict.fromkeys(clusters))[:5]
@dataclass
class LeakCandidate:
type: str = "allocation"
size: int = 0
growth: int = 0
iterations: int = 1
traceback_fp: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"size": self.size,
"growth": self.growth,
"iterations": self.iterations,
"traceback": self.traceback_fp,
}
@dataclass
class LeakReport:
target: str = ""
iterations: int = 0
min_growth: int = 0
candidates: List[LeakCandidate] = field(default_factory=list)
live_type_deltas: Dict[str, int] = field(default_factory=dict)
collections_fired: int = 0
survived_weakrefs: List[Dict[str, Any]] = field(default_factory=list)
circular_clusters: List[str] = field(default_factory=list)
peak_traced_bytes: int = 0
def to_json(self) -> Dict[str, Any]:
return {
"target": self.target,
"iterations": self.iterations,
"min_growth_threshold_bytes": self.min_growth,
"candidates": [c.to_dict() for c in self.candidates],
"live_type_deltas": self.live_type_deltas,
"collections_fired": self.collections_fired,
"survived_weakrefs": self.survived_weakrefs,
"circular_clusters": self.circular_clusters,
"peak_traced_bytes": self.peak_traced_bytes,
}
class Detector:
"""Iterative tracemalloc + GC leak detection around one callable."""
def __init__(self, min_growth: int = 1 << 20,
key: str = "lineno",
min_iterations: int = 3):
self.min_growth = int(min_growth)
self.key = key
self.min_iterations = min_iterations
self.report = LeakReport(min_growth=self.min_growth)
self._tracked: List[Tuple[weakref.ReferenceType, str]] = []
def track_lifetime(self, obj: Any, name: str) -> weakref.ReferenceType:
"""Register an object that SHOULD be garbage when target() returns."""
ref = weakref.ref(obj)
self._tracked.append((ref, name))
return ref
def run(self, target: Callable[[], Any], iterations: int = 6) -> LeakReport:
report = self.report
report.target = getattr(target, "__qualname__",
getattr(target, "__name__", repr(target)))
report.iterations = iterations
gc.collect()
tracemalloc.start(25)
atexit.register(tracemalloc.stop)
before_types = live_object_counts()
running: Dict[Tuple[Tuple[str, int], ...], int] = {}
series: Dict[Tuple[Tuple[str, int], ...], List[int]] = {}
collections = 0
for _ in range(iterations):
collections += gc.collect()
before = take_snapshot()
target()
collections += gc.collect()
after = take_snapshot()
for d in compare(before, after, n=4096, key=self.key):
fp = _fingerprint(d)
running[fp] = running.get(fp, 0) + d.size_diff
series.setdefault(fp, []).append(running[fp])
for fp, vec in series.items():
if _is_leaky(vec, iterations, self.min_growth, self.min_iterations):
report.candidates.append(_candidate_from(fp, vec))
report.candidates.sort(key=lambda c: c.growth, reverse=True)
after_types = live_object_counts()
delta = {t: after_types.get(t, 0) - before_types.get(t, 0)
for t in set(after_types) | set(before_types)}
top = sorted((t for t in delta.items() if t[1] > 0),
key=lambda kv: kv[1], reverse=True)[:10]
report.live_type_deltas = dict(top)
for ref, name in self._tracked:
obj = ref()
if obj is not None:
clusters = find_circular_clusters(obj, max_depth=8)
report.survived_weakrefs.append({
"name": name,
"surviving_type": f"{type(obj).__module__}.{type(obj).__qualname__}",
"candidate_clusters": clusters,
})
report.circular_clusters.extend(clusters)
report.collections_fired = collections
report.peak_traced_bytes = tracemalloc.get_traced_memory()[0]
return report
# --------------------------------------------------------------------------
# demo targets
# --------------------------------------------------------------------------
_demo_holder: List[bytearray] = []
def leaky_demo() -> None:
"""Documented demo: appends 64 KiB blocks every call, unbounded growth."""
for _ in range(16):
_demo_holder.append(bytearray(1 << 16))
def make_closure_leak() -> Callable[[], None]:
"""Returns a callable that accumulates captured dicts (real-world shape)."""
captured: List[Dict[str, Any]] = []
def bump() -> None:
captured.append({"big": bytearray(1 << 20)})
return bump
# --------------------------------------------------------------------------
# resolution & CLI
# --------------------------------------------------------------------------
def resolve_target(spec: str) -> Callable[[], Any]:
spec = spec.strip()
if ":" in spec:
module_name, _, attr = spec.partition(":")
elif "." in spec:
module_name, _, attr = spec.rpartition(".")
else:
raise ValueError("target must look like 'module:func' or 'module.attr'")
if not module_name or not attr:
raise ValueError("target must look like 'module:func' or 'module.attr'")
module = importlib.import_module(module_name)
target = getattr(module, attr)
if not callable(target):
raise TypeError(f"{spec} resolved to {type(target).__name__}, not callable")
return target
NODE_GUIDANCE = """\
V8 / Node.js heap snapshot guidance
-----------------------------------
1. Start the server under heap profiling:
node --heapsnapshot-near-heap-limit=5 --heap-prof app.js
'--heapsnapshot-near-heap-limit=5' writes a .heapsnapshot file each
time the heap approaches the configured limit, letting you diff
snapshots across load-generating runs.
2. For a targeted before/after pair, snapshot explicitly:
const v8 = require('v8');
// after warmup, before the workload spike
v8.writeHeapSnapshot('./heap-before.heapsnapshot');
// run the workload under test, then:
if (global.gc) global.gc();
v8.writeHeapSnapshot('./heap-after.heapsnapshot');
3. Inspect snapshots in Chrome DevTools (chrome://inspect -> Memory
-> 'Save and load profiles') and diff the retained-size histograms
of 'CONCATENATED_STRING', 'ARRAY', 'CLOSURE' and object class names.
Watch for constructors whose count grows monotonically between dumps.
4. Event-listener leaks: run with --trace-warnings and search the heap
snapshot for 'EventListener' / 'Trigger' entries; prefer listeners
registered with { once: true } or removed via .removeEventListener().
5. Map findings back to code: a growing 'CLOSURE' count usually means an
event emitter or Promise chain retains the callback; use Chrome's
Retainers panel to walk the reference chain to the leak root.
Use one profiler per runtime -- the Python pipeline in this module detects
the same class of leak from the interpreter side.
"""
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
prog="leak_profiler",
description="Detect sustained allocation growth in a zero-arg callable.",
)
parser.add_argument("--target", default="",
help="module:func to run repeatedly (default: built-in demo)")
parser.add_argument("--iterations", type=int, default=6,
help="paired snapshot iterations (default 6)")
parser.add_argument("--min-growth", type=int, default=1 << 20,
help="cumulative byte threshold for a leak signature")
parser.add_argument("--key", choices=("lineno", "filename", "traceback"),
default="lineno", help="tracemalloc grouping key")
parser.add_argument("--out", default="", help="write the JSON report here")
parser.add_argument("--node", action="store_true",
help="print V8/Node heap-snapshot guidance and exit")
args = parser.parse_args(argv)
if args.node:
print(NODE_GUIDANCE)
return 0
if args.target:
try:
target = resolve_target(args.target)
except (ImportError, AttributeError, ValueError, TypeError) as exc:
print(f"leak_profiler: cannot load target: {exc}", file=sys.stderr)
return 2
else:
target = leaky_demo
print("no --target given, using built-in leaky_demo()", file=sys.stderr)
detector = Detector(min_growth=args.min_growth, key=args.key)
report = detector.run(target, iterations=max(2, args.iterations))
payload = json.dumps(report.to_json(), indent=2, sort_keys=True)
print(payload)
if args.out:
Path(args.out).write_text(payload + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())
4. Execution Protocol & Step-by-Step Workflow
- Warm up in a real environment, then attach the profiler to the existing process if possible. For Python: run the target under
python -X importtime first only when import-time allocation matters; otherwise the module's own tracemalloc.start() covers everything.
- Exercise the target manually the same number of times the detector will (default 6) so lazily-initialized singletons (caches, pools, connection managers) are warm before iteration zero; otherwise the first iteration looks like a leak.
- Run the detector:
python leak_profiler.py --target "app.services:create_client" --iterations 6 --out leak_report.json
python leak_profiler.py --key traceback --min-growth 2097152 raises the bar to 2 MiB and groups by full traceback.
python leak_profiler.py runs the built-in leaky_demo to smoke-test the pipeline.
- Read
candidates: a candidate needs (a) cumulative byte growth ≥ --min-growth, (b) mostly monotone growth, (c) a repeating traceback fingerprint. iterations == n means the signature was present in every iteration — the strongest leak signal.
- Corroborate with the other signals:
live_type_deltas — an object type that grows but whose traceback is not in the candidates is usually a cross-iteration retention (the object was born in one call and retained by a global).
survived_weakrefs — objects the caller marked should die via detector.track_lifetime(...) that are still alive.
circular_clusters — type-name chains from gc.get_referrers; if a cluster names your own classes, the fix is often __slots__ or breaking the cycle with weakref.
- Fix candidates by removing the retaining reference, then re-run with the same parameters and confirm the candidate disappears and
collections_fired rises (dead cycles now collectible).
- For Node.js/V8 targets, follow
NODE_GUIDANCE (node --heapsnapshot-near-heap-limit=5 --heap-prof) and diff before/after .heapsnapshot files in Chrome DevTools; keep the leak root's retainers chain for the ticket.
- Turn the passing case into a regression guard: add a comment in the code or a CI step that runs the detector with
--target <hot-path> and fails on non-empty candidates.
5. Edge Cases & Error Handling
tracemalloc overhead: tracing multiplies allocation cost on hot paths; if wall-clock time matters, raise --min-growth and lower --iterations rather than disabling tracing.
- First-iteration warm-up noise: a candidate that only grows in iteration 1–2 but flattens later is a one-shot cache fill, not a leak. The monotonic-growth check plus
min_growth already reject most of these; raise min_iterations (constructor parameter) if flaky.
gc.get_objects() counts depend on uncollectable dicts/tuples: the tool's own report dict and frames are counted too. Compare deltas between runs, never absolute counts.
- Frame/traceback objects hiding in
get_referrers: find_circular_clusters deliberately skips type/ModuleType/frames/code so it reports application cycles, not interpreter scaffolding.
- Objects that cannot be weakref'd (
int, str, most bytes, int, tuple): track_lifetime will raise TypeError at registration — wrap the check so it degrades to a type-count delta rather than aborting the run.
weakref to an already-dead object returns None from ref() — that is the expected outcome; survived_weakrefs only lists survivors, which is the leak signal.
- Multi-threaded targets:
tracemalloc is interpreter-wide, so concurrent threads pollute deltas. Run the target single-threaded (or with a thread barrier) for clean signatures.
--key traceback fingerprints are memory-hungry (each comparison materializes full stacks); keep iterations <= 8 in that mode.
- Interrupts /
KeyboardInterrupt: atexit.register(tracemalloc.stop) tears tracing down so a Ctrl-C does not leave the process tracing; the partially-collected LeakReport is still emitted.
- Unexpected exceptions inside
target(): let them propagate — the detector's finally-equivalent bookkeeping (snapshots taken per iteration) means the failed iteration just contributes zero diffs; fix the target bug first and re-run.
1---2name: memory-leak-profiler-debugger3description: Instruments a target callable under tracemalloc and the cyclic garbage collector, takes paired allocation snapshots across repeated runs, flags allocations whose cumulative bytes keep growing across iterations with a repeating traceback as leak signatures, counts live objects per type, verifies weak-reference lifetimes, and reports circular-reference clusters via gc.get_referrers, emitting a structured JSON LeakReport of LeakCandidate entries and documented Node.js v8 heap-snapshot equivalents.4metadata:5 source: skills/memory-leak-profiler-debugger/memory-leak-profiler-debugger.md6---78# Memory Leak Profiler & Debugger910## 1. System Architecture & Prerequisites1112- Runtime binary: **Python 3.9 or newer** (relies on `tracemalloc.Statistic.traceback`, `gc.get_objects`, `weakref`, and `inspect`). Python 3.11+ is recommended.13- Standard library only: `tracemalloc`, `gc`, `weakref`, `inspect`, `importlib`, `atexit`, `argparse`, `json`, `sys`, `pathlib`, `dataclasses`, `typing`.14- For Node.js/V8 targets the same skill documents the equivalent invocation `node --heapsnapshot-near-heap-limit=5 --heap-prof app.js` (see Section 4); the Python module itself is interpreter-native.15- The target under test must be a zero-argument callable `module:func` that performs one unit of work (handler, request cycle, worker tick). Long-lived process frames are preferred over CLI programs that exit, because the leak signature this skill detects requires repeated calls in one Python process.1617## 2. Input/Output Data Contracts1819### 2.1 CLI input options (JSON Schema)2021```json22{23 "$schema": "http://json-schema.org/draft-07/schema#",24 "title": "leak_profiler_cli_options",25 "type": "object",26 "properties": {27 "target": { "type": "string", "description": "Zero-arg callable as 'module:func' or 'module.attr'." },28 "iterations": { "type": "integer", "default": 6, "minimum": 2, "description": "Number of paired snapshot iterations." },29 "min-growth": { "type": "integer", "default": 1048576, "description": "Bytes of cumulative growth required to classify a leak signature." },30 "key": { "type": "string", "enum": ["lineno", "filename", "traceback"], "default": "lineno" },31 "out": { "type": "string", "description": "Write the JSON report here." },32 "node": { "type": "boolean", "default": false, "description": "Print V8 heap-snapshot guidance and exit." }33 }34}35```3637### 2.2 Output artifacts3839- **JSON report** (stdout and optional `--out <path>`):4041```json42{43 "target": "leaky_demo",44 "iterations": 6,45 "min_growth_threshold_bytes": 1048576,46 "candidates": [47 {48 "type": "allocation",49 "size": 6291456,50 "growth": 5242880,51 "iterations": 6,52 "traceback": "leak_profiler.py:px <- leak_demo:py <- run:0"53 }54 ],55 "live_type_deltas": { "builtins.bytearray": 96 },56 "collections_fired": 12,57 "survived_weakrefs": [],58 "circular_clusters": [],59 "peak_traced_bytes": 838860860}61```6263- The instrumented target is never written to; only the report bytes and `tracemalloc`'s in-process tracing state are produced.6465## 3. Production Reference Implementation6667```python68"""Memory Leak Profiler & Debugger.6970Instruments a target callable under ``tracemalloc`` and the cyclic garbage71collector, then classifies sustained per-traceback allocation growth as72leak signatures.7374Pipeline:751. ``take_snapshot()`` returns a ``tracemalloc.Snapshot`` (starting tracing76 on first use).772. ``top_allocations()`` / ``compare()`` summarize snapshots and deltas.783. ``Detector.run(target, iterations)``:79 a. runs the target inside ``tracemalloc.start()`` and keeps paired80 Nth snapshots (before/after each iteration),81 b. accumulates per-traceback cumulative bytes across iterations and82 flags signatures whose cumulative size keeps growing and whose83 traceback repeats (leak signature),84 c. uses ``gc.get_objects()`` to count live objects per type and tracks85 ``weakref.ref`` objects that should have died but did not,86 d. reports circular-reference clusters via ``gc.get_referrers``.874. A ``LeakReport`` (list of ``LeakCandidate{type, size, iterations,88 traceback_fp}``) is emitted as JSON.8990No third-party dependencies. For Node.js/V8 the equivalent command is91documented in the node instructions section (``--heapsnapshot-near-heap-limit``).92"""9394from __future__ import annotations9596import argparse97import atexit98import gc99import importlib100import inspect101import json102import sys103import tracemalloc104import weakref105from dataclasses import dataclass, field106from pathlib import Path107from types import ModuleType108from typing import Any, Callable, Dict, List, Optional, Tuple, Union109110111def take_snapshot() -> tracemalloc.Snapshot:112 if not tracemalloc.is_tracing():113 tracemalloc.start(25)114 return tracemalloc.take_snapshot()115116117def top_allocations(snapshot: tracemalloc.Snapshot, n: int = 10,118 key: str = "lineno") -> List[tracemalloc.Statistic]:119 if key not in ("lineno", "filename", "traceback"):120 raise ValueError(f"key must be one of lineno/filename/traceback, got {key!r}")121 stats = snapshot.statistics(key)122 stats.sort(key=lambda s: s.size, reverse=True)123 return stats[:n]124125126def compare(snap1: tracemalloc.Snapshot, snap2: tracemalloc.Snapshot,127 n: int = 10, key: str = "lineno") -> List[tracemalloc.StatisticDiff]:128 if key not in ("lineno", "filename", "traceback"):129 raise ValueError(f"key must be one of lineno/filename/traceback, got {key!r}")130 diffs = snap2.compare_to(snap1, key)131 diffs.sort(key=lambda d: d.size_diff, reverse=True)132 out = [d for d in diffs[:n] if d.size_diff > 0]133 return out or [d for d in diffs if d.size_diff > 0][:1]134135136def _frames_of(stat) -> List[Tuple[str, int, str]]:137 """Best-effort (filename, lineno, name) frames for a statistic object."""138 tb = getattr(stat, "traceback", None)139 if tb is None:140 return []141 frames: List[Tuple[str, int, str]] = []142 try:143 for frame in iter(tb):144 if hasattr(frame, "filename"):145 frames.append((frame.filename, frame.lineno, getattr(frame, "name", "")))146 else:147 frames.append((frame[0], frame[1], frame[2] if len(frame) > 2 else ""))148 if len(frames) >= 8:149 break150 except (TypeError, IndexError, AttributeError):151 try:152 formatted = tb.format()153 frames = [(ln, 0, "") for ln in formatted[:8]]154 except Exception:155 frames = [(repr(stat), 0, "")]156 return frames157158159def _fingerprint(stat) -> Tuple[Tuple[str, int], ...]:160 fr = _frames_of(stat)161 if not fr:162 return (("<unknown>", 0),)163 return tuple((f[0], f[1]) for f in fr[:4])164165166def live_object_counts() -> Dict[str, int]:167 counts: Dict[str, int] = {}168 for obj in gc.get_objects():169 t = type(obj)170 key = f"{t.__module__}.{t.__qualname__}"171 counts[key] = counts.get(key, 0) + 1172 return counts173174175def _is_leaky(vec: List[int], iterations: int, min_growth: int, min_iterations: int) -> bool:176 if len(vec) < max(2, min_iterations):177 return False178 if vec[-1] - vec[0] < min_growth:179 return False180 growths = sum(1 for a, b in zip(vec, vec[1:]) if b > a)181 return growths >= max(2, len(vec) - 1)182183184def _candidate_from(fp, vec: List[int]) -> "LeakCandidate":185 tail = " <- ".join(f"{f[0]}:{f[1]}" for f in fp)186 return LeakCandidate(187 type="allocation",188 size=vec[-1],189 growth=vec[-1] - vec[0],190 iterations=len(vec),191 traceback_fp=tail,192 )193194195def find_circular_clusters(root: Any, max_depth: int = 8, limit: int = 250) -> List[str]:196 """Find reference cycles that keep ``root`` alive; render type chains."""197 clusters: List[str] = []198 visited = 0199200 def keep(referrer: Any) -> bool:201 return not (202 inspect.isframe(referrer) or inspect.iscode(referrer)203 or isinstance(referrer, (type, ModuleType))204 )205206 def walk(obj: Any, path: List[Any]) -> None:207 nonlocal visited208 if len(clusters) >= 5 or visited >= limit or len(path) >= max_depth:209 return210 for referrer in gc.get_referrers(obj):211 visited += 1212 if referrer is root:213 chain = [type(o).__name__ for o in path] + [type(obj).__name__, type(root).__name__]214 clusters.append(" -> ".join(chain))215 return216 if not keep(referrer):217 continue218 if any(ref is referrer for ref in path):219 continue220 walk(referrer, path + [obj])221222 walk(root, [])223 return list(dict.fromkeys(clusters))[:5]224225226@dataclass227class LeakCandidate:228 type: str = "allocation"229 size: int = 0230 growth: int = 0231 iterations: int = 1232 traceback_fp: str = ""233234 def to_dict(self) -> Dict[str, Any]:235 return {236 "type": self.type,237 "size": self.size,238 "growth": self.growth,239 "iterations": self.iterations,240 "traceback": self.traceback_fp,241 }242243244@dataclass245class LeakReport:246 target: str = ""247 iterations: int = 0248 min_growth: int = 0249 candidates: List[LeakCandidate] = field(default_factory=list)250 live_type_deltas: Dict[str, int] = field(default_factory=dict)251 collections_fired: int = 0252 survived_weakrefs: List[Dict[str, Any]] = field(default_factory=list)253 circular_clusters: List[str] = field(default_factory=list)254 peak_traced_bytes: int = 0255256 def to_json(self) -> Dict[str, Any]:257 return {258 "target": self.target,259 "iterations": self.iterations,260 "min_growth_threshold_bytes": self.min_growth,261 "candidates": [c.to_dict() for c in self.candidates],262 "live_type_deltas": self.live_type_deltas,263 "collections_fired": self.collections_fired,264 "survived_weakrefs": self.survived_weakrefs,265 "circular_clusters": self.circular_clusters,266 "peak_traced_bytes": self.peak_traced_bytes,267 }268269270class Detector:271 """Iterative tracemalloc + GC leak detection around one callable."""272273 def __init__(self, min_growth: int = 1 << 20,274 key: str = "lineno",275 min_iterations: int = 3):276 self.min_growth = int(min_growth)277 self.key = key278 self.min_iterations = min_iterations279 self.report = LeakReport(min_growth=self.min_growth)280 self._tracked: List[Tuple[weakref.ReferenceType, str]] = []281282 def track_lifetime(self, obj: Any, name: str) -> weakref.ReferenceType:283 """Register an object that SHOULD be garbage when target() returns."""284 ref = weakref.ref(obj)285 self._tracked.append((ref, name))286 return ref287288 def run(self, target: Callable[[], Any], iterations: int = 6) -> LeakReport:289 report = self.report290 report.target = getattr(target, "__qualname__",291 getattr(target, "__name__", repr(target)))292 report.iterations = iterations293294 gc.collect()295 tracemalloc.start(25)296 atexit.register(tracemalloc.stop)297 before_types = live_object_counts()298299 running: Dict[Tuple[Tuple[str, int], ...], int] = {}300 series: Dict[Tuple[Tuple[str, int], ...], List[int]] = {}301 collections = 0302 for _ in range(iterations):303 collections += gc.collect()304 before = take_snapshot()305 target()306 collections += gc.collect()307 after = take_snapshot()308 for d in compare(before, after, n=4096, key=self.key):309 fp = _fingerprint(d)310 running[fp] = running.get(fp, 0) + d.size_diff311 series.setdefault(fp, []).append(running[fp])312313 for fp, vec in series.items():314 if _is_leaky(vec, iterations, self.min_growth, self.min_iterations):315 report.candidates.append(_candidate_from(fp, vec))316 report.candidates.sort(key=lambda c: c.growth, reverse=True)317318 after_types = live_object_counts()319 delta = {t: after_types.get(t, 0) - before_types.get(t, 0)320 for t in set(after_types) | set(before_types)}321 top = sorted((t for t in delta.items() if t[1] > 0),322 key=lambda kv: kv[1], reverse=True)[:10]323 report.live_type_deltas = dict(top)324325 for ref, name in self._tracked:326 obj = ref()327 if obj is not None:328 clusters = find_circular_clusters(obj, max_depth=8)329 report.survived_weakrefs.append({330 "name": name,331 "surviving_type": f"{type(obj).__module__}.{type(obj).__qualname__}",332 "candidate_clusters": clusters,333 })334 report.circular_clusters.extend(clusters)335336 report.collections_fired = collections337 report.peak_traced_bytes = tracemalloc.get_traced_memory()[0]338 return report339340341# --------------------------------------------------------------------------342# demo targets343# --------------------------------------------------------------------------344_demo_holder: List[bytearray] = []345346347def leaky_demo() -> None:348 """Documented demo: appends 64 KiB blocks every call, unbounded growth."""349 for _ in range(16):350 _demo_holder.append(bytearray(1 << 16))351352353def make_closure_leak() -> Callable[[], None]:354 """Returns a callable that accumulates captured dicts (real-world shape)."""355 captured: List[Dict[str, Any]] = []356357 def bump() -> None:358 captured.append({"big": bytearray(1 << 20)})359360 return bump361362363# --------------------------------------------------------------------------364# resolution & CLI365# --------------------------------------------------------------------------366def resolve_target(spec: str) -> Callable[[], Any]:367 spec = spec.strip()368 if ":" in spec:369 module_name, _, attr = spec.partition(":")370 elif "." in spec:371 module_name, _, attr = spec.rpartition(".")372 else:373 raise ValueError("target must look like 'module:func' or 'module.attr'")374 if not module_name or not attr:375 raise ValueError("target must look like 'module:func' or 'module.attr'")376 module = importlib.import_module(module_name)377 target = getattr(module, attr)378 if not callable(target):379 raise TypeError(f"{spec} resolved to {type(target).__name__}, not callable")380 return target381382383NODE_GUIDANCE = """\384V8 / Node.js heap snapshot guidance385-----------------------------------3861. Start the server under heap profiling:387388 node --heapsnapshot-near-heap-limit=5 --heap-prof app.js389390 '--heapsnapshot-near-heap-limit=5' writes a .heapsnapshot file each391 time the heap approaches the configured limit, letting you diff392 snapshots across load-generating runs.3933942. For a targeted before/after pair, snapshot explicitly:395396 const v8 = require('v8');397 // after warmup, before the workload spike398 v8.writeHeapSnapshot('./heap-before.heapsnapshot');399 // run the workload under test, then:400 if (global.gc) global.gc();401 v8.writeHeapSnapshot('./heap-after.heapsnapshot');4024033. Inspect snapshots in Chrome DevTools (chrome://inspect -> Memory404 -> 'Save and load profiles') and diff the retained-size histograms405 of 'CONCATENATED_STRING', 'ARRAY', 'CLOSURE' and object class names.406 Watch for constructors whose count grows monotonically between dumps.4074084. Event-listener leaks: run with --trace-warnings and search the heap409 snapshot for 'EventListener' / 'Trigger' entries; prefer listeners410 registered with { once: true } or removed via .removeEventListener().4114125. Map findings back to code: a growing 'CLOSURE' count usually means an413 event emitter or Promise chain retains the callback; use Chrome's414 Retainers panel to walk the reference chain to the leak root.415416Use one profiler per runtime -- the Python pipeline in this module detects417the same class of leak from the interpreter side.418"""419420421def main(argv=None) -> int:422 parser = argparse.ArgumentParser(423 prog="leak_profiler",424 description="Detect sustained allocation growth in a zero-arg callable.",425 )426 parser.add_argument("--target", default="",427 help="module:func to run repeatedly (default: built-in demo)")428 parser.add_argument("--iterations", type=int, default=6,429 help="paired snapshot iterations (default 6)")430 parser.add_argument("--min-growth", type=int, default=1 << 20,431 help="cumulative byte threshold for a leak signature")432 parser.add_argument("--key", choices=("lineno", "filename", "traceback"),433 default="lineno", help="tracemalloc grouping key")434 parser.add_argument("--out", default="", help="write the JSON report here")435 parser.add_argument("--node", action="store_true",436 help="print V8/Node heap-snapshot guidance and exit")437 args = parser.parse_args(argv)438439 if args.node:440 print(NODE_GUIDANCE)441 return 0442443 if args.target:444 try:445 target = resolve_target(args.target)446 except (ImportError, AttributeError, ValueError, TypeError) as exc:447 print(f"leak_profiler: cannot load target: {exc}", file=sys.stderr)448 return 2449 else:450 target = leaky_demo451 print("no --target given, using built-in leaky_demo()", file=sys.stderr)452453 detector = Detector(min_growth=args.min_growth, key=args.key)454 report = detector.run(target, iterations=max(2, args.iterations))455 payload = json.dumps(report.to_json(), indent=2, sort_keys=True)456 print(payload)457 if args.out:458 Path(args.out).write_text(payload + "\n", encoding="utf-8")459 return 0460461462if __name__ == "__main__":463 sys.exit(main())464```465466## 4. Execution Protocol & Step-by-Step Workflow4674681. Warm up in a real environment, then attach the profiler to the *existing* process if possible. For Python: run the target under `python -X importtime` first only when import-time allocation matters; otherwise the module's own `tracemalloc.start()` covers everything.4692. Exercise the target manually the same number of times the detector will (default 6) so lazily-initialized singletons (caches, pools, connection managers) are warm *before* iteration zero; otherwise the first iteration looks like a leak.4703. Run the detector:471 - `python leak_profiler.py --target "app.services:create_client" --iterations 6 --out leak_report.json`472 - `python leak_profiler.py --key traceback --min-growth 2097152` raises the bar to 2 MiB and groups by full traceback.473 - `python leak_profiler.py` runs the built-in `leaky_demo` to smoke-test the pipeline.4744. Read `candidates`: a candidate needs (a) cumulative byte growth ≥ `--min-growth`, (b) mostly monotone growth, (c) a repeating traceback fingerprint. `iterations == n` means the signature was present in every iteration — the strongest leak signal.4755. Corroborate with the other signals:476 - `live_type_deltas` — an object type that grows but whose traceback is not in the candidates is usually a *cross-iteration* retention (the object was born in one call and retained by a global).477 - `survived_weakrefs` — objects the caller marked should die via `detector.track_lifetime(...)` that are still alive.478 - `circular_clusters` — type-name chains from `gc.get_referrers`; if a cluster names your own classes, the fix is often `__slots__` or breaking the cycle with `weakref`.4796. Fix candidates by removing the retaining reference, then re-run with the same parameters and confirm the candidate disappears and `collections_fired` rises (dead cycles now collectible).4807. For Node.js/V8 targets, follow `NODE_GUIDANCE` (`node --heapsnapshot-near-heap-limit=5 --heap-prof`) and diff before/after `.heapsnapshot` files in Chrome DevTools; keep the leak root's retainers chain for the ticket.4818. Turn the passing case into a regression guard: add a comment in the code or a CI step that runs the detector with `--target <hot-path>` and fails on non-empty `candidates`.482483## 5. Edge Cases & Error Handling484485- **`tracemalloc` overhead**: tracing multiplies allocation cost on hot paths; if wall-clock time matters, raise `--min-growth` and lower `--iterations` rather than disabling tracing.486- **First-iteration warm-up noise**: a candidate that only grows in iteration 1–2 but flattens later is a one-shot cache fill, not a leak. The monotonic-growth check plus `min_growth` already reject most of these; raise `min_iterations` (constructor parameter) if flaky.487- **`gc.get_objects()` counts depend on uncollectable dicts/tuples**: the tool's own report dict and frames are counted too. Compare deltas between runs, never absolute counts.488- **Frame/traceback objects hiding in `get_referrers`**: `find_circular_clusters` deliberately skips `type`/`ModuleType`/frames/code so it reports application cycles, not interpreter scaffolding.489- **Objects that cannot be weakref'd** (`int`, `str`, most `bytes`, `int`, `tuple`): `track_lifetime` will raise `TypeError` at registration — wrap the check so it degrades to a type-count delta rather than aborting the run.490- **`weakref` to an already-dead object returns `None`** from `ref()` — that is the *expected* outcome; `survived_weakrefs` only lists survivors, which is the leak signal.491- **Multi-threaded targets**: `tracemalloc` is interpreter-wide, so concurrent threads pollute deltas. Run the target single-threaded (or with a thread barrier) for clean signatures.492- **`--key traceback` fingerprints are memory-hungry** (each comparison materializes full stacks); keep `iterations <= 8` in that mode.493- **Interrupts / `KeyboardInterrupt`**: `atexit.register(tracemalloc.stop)` tears tracing down so a Ctrl-C does not leave the process tracing; the partially-collected `LeakReport` is still emitted.494- **Unexpected exceptions inside `target()`**: let them propagate — the detector's finally-equivalent bookkeeping (snapshots taken per iteration) means the failed iteration just contributes zero diffs; fix the target bug first and re-run.