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.4---56# Memory Leak Profiler & Debugger78## 1. System Architecture & Prerequisites910- Runtime binary: **Python 3.9 or newer** (relies on `tracemalloc.Statistic.traceback`, `gc.get_objects`, `weakref`, and `inspect`). Python 3.11+ is recommended.11- Standard library only: `tracemalloc`, `gc`, `weakref`, `inspect`, `importlib`, `atexit`, `argparse`, `json`, `sys`, `pathlib`, `dataclasses`, `typing`.12- 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.13- 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.1415## 2. Input/Output Data Contracts1617### 2.1 CLI input options (JSON Schema)1819```json20{21 "$schema": "http://json-schema.org/draft-07/schema#",22 "title": "leak_profiler_cli_options",23 "type": "object",24 "properties": {25 "target": { "type": "string", "description": "Zero-arg callable as 'module:func' or 'module.attr'." },26 "iterations": { "type": "integer", "default": 6, "minimum": 2, "description": "Number of paired snapshot iterations." },27 "min-growth": { "type": "integer", "default": 1048576, "description": "Bytes of cumulative growth required to classify a leak signature." },28 "key": { "type": "string", "enum": ["lineno", "filename", "traceback"], "default": "lineno" },29 "out": { "type": "string", "description": "Write the JSON report here." },30 "node": { "type": "boolean", "default": false, "description": "Print V8 heap-snapshot guidance and exit." }31 }32}33```3435### 2.2 Output artifacts3637- **JSON report** (stdout and optional `--out <path>`):3839```json40{41 "target": "leaky_demo",42 "iterations": 6,43 "min_growth_threshold_bytes": 1048576,44 "candidates": [45 {46 "type": "allocation",47 "size": 6291456,48 "growth": 5242880,49 "iterations": 6,50 "traceback": "leak_profiler.py:px <- leak_demo:py <- run:0"51 }52 ],53 "live_type_deltas": { "builtins.bytearray": 96 },54 "collections_fired": 12,55 "survived_weakrefs": [],56 "circular_clusters": [],57 "peak_traced_bytes": 838860858}59```6061- The instrumented target is never written to; only the report bytes and `tracemalloc`'s in-process tracing state are produced.6263## 3. Production Reference Implementation6465```python66"""Memory Leak Profiler & Debugger.6768Instruments a target callable under ``tracemalloc`` and the cyclic garbage69collector, then classifies sustained per-traceback allocation growth as70leak signatures.7172Pipeline:731. ``take_snapshot()`` returns a ``tracemalloc.Snapshot`` (starting tracing74 on first use).752. ``top_allocations()`` / ``compare()`` summarize snapshots and deltas.763. ``Detector.run(target, iterations)``:77 a. runs the target inside ``tracemalloc.start()`` and keeps paired78 Nth snapshots (before/after each iteration),79 b. accumulates per-traceback cumulative bytes across iterations and80 flags signatures whose cumulative size keeps growing and whose81 traceback repeats (leak signature),82 c. uses ``gc.get_objects()`` to count live objects per type and tracks83 ``weakref.ref`` objects that should have died but did not,84 d. reports circular-reference clusters via ``gc.get_referrers``.854. A ``LeakReport`` (list of ``LeakCandidate{type, size, iterations,86 traceback_fp}``) is emitted as JSON.8788No third-party dependencies. For Node.js/V8 the equivalent command is89documented in the node instructions section (``--heapsnapshot-near-heap-limit``).90"""9192from __future__ import annotations9394import argparse95import atexit96import gc97import importlib98import inspect99import json100import sys101import tracemalloc102import weakref103from dataclasses import dataclass, field104from pathlib import Path105from types import ModuleType106from typing import Any, Callable, Dict, List, Optional, Tuple, Union107108109def take_snapshot() -> tracemalloc.Snapshot:110 if not tracemalloc.is_tracing():111 tracemalloc.start(25)112 return tracemalloc.take_snapshot()113114115def top_allocations(snapshot: tracemalloc.Snapshot, n: int = 10,116 key: str = "lineno") -> List[tracemalloc.Statistic]:117 if key not in ("lineno", "filename", "traceback"):118 raise ValueError(f"key must be one of lineno/filename/traceback, got {key!r}")119 stats = snapshot.statistics(key)120 stats.sort(key=lambda s: s.size, reverse=True)121 return stats[:n]122123124def compare(snap1: tracemalloc.Snapshot, snap2: tracemalloc.Snapshot,125 n: int = 10, key: str = "lineno") -> List[tracemalloc.StatisticDiff]:126 if key not in ("lineno", "filename", "traceback"):127 raise ValueError(f"key must be one of lineno/filename/traceback, got {key!r}")128 diffs = snap2.compare_to(snap1, key)129 diffs.sort(key=lambda d: d.size_diff, reverse=True)130 out = [d for d in diffs[:n] if d.size_diff > 0]131 return out or [d for d in diffs if d.size_diff > 0][:1]132133134def _frames_of(stat) -> List[Tuple[str, int, str]]:135 """Best-effort (filename, lineno, name) frames for a statistic object."""136 tb = getattr(stat, "traceback", None)137 if tb is None:138 return []139 frames: List[Tuple[str, int, str]] = []140 try:141 for frame in iter(tb):142 if hasattr(frame, "filename"):143 frames.append((frame.filename, frame.lineno, getattr(frame, "name", "")))144 else:145 frames.append((frame[0], frame[1], frame[2] if len(frame) > 2 else ""))146 if len(frames) >= 8:147 break148 except (TypeError, IndexError, AttributeError):149 try:150 formatted = tb.format()151 frames = [(ln, 0, "") for ln in formatted[:8]]152 except Exception:153 frames = [(repr(stat), 0, "")]154 return frames155156157def _fingerprint(stat) -> Tuple[Tuple[str, int], ...]:158 fr = _frames_of(stat)159 if not fr:160 return (("<unknown>", 0),)161 return tuple((f[0], f[1]) for f in fr[:4])162163164def live_object_counts() -> Dict[str, int]:165 counts: Dict[str, int] = {}166 for obj in gc.get_objects():167 t = type(obj)168 key = f"{t.__module__}.{t.__qualname__}"169 counts[key] = counts.get(key, 0) + 1170 return counts171172173def _is_leaky(vec: List[int], iterations: int, min_growth: int, min_iterations: int) -> bool:174 if len(vec) < max(2, min_iterations):175 return False176 if vec[-1] - vec[0] < min_growth:177 return False178 growths = sum(1 for a, b in zip(vec, vec[1:]) if b > a)179 return growths >= max(2, len(vec) - 1)180181182def _candidate_from(fp, vec: List[int]) -> "LeakCandidate":183 tail = " <- ".join(f"{f[0]}:{f[1]}" for f in fp)184 return LeakCandidate(185 type="allocation",186 size=vec[-1],187 growth=vec[-1] - vec[0],188 iterations=len(vec),189 traceback_fp=tail,190 )191192193def find_circular_clusters(root: Any, max_depth: int = 8, limit: int = 250) -> List[str]:194 """Find reference cycles that keep ``root`` alive; render type chains."""195 clusters: List[str] = []196 visited = 0197198 def keep(referrer: Any) -> bool:199 return not (200 inspect.isframe(referrer) or inspect.iscode(referrer)201 or isinstance(referrer, (type, ModuleType))202 )203204 def walk(obj: Any, path: List[Any]) -> None:205 nonlocal visited206 if len(clusters) >= 5 or visited >= limit or len(path) >= max_depth:207 return208 for referrer in gc.get_referrers(obj):209 visited += 1210 if referrer is root:211 chain = [type(o).__name__ for o in path] + [type(obj).__name__, type(root).__name__]212 clusters.append(" -> ".join(chain))213 return214 if not keep(referrer):215 continue216 if any(ref is referrer for ref in path):217 continue218 walk(referrer, path + [obj])219220 walk(root, [])221 return list(dict.fromkeys(clusters))[:5]222223224@dataclass225class LeakCandidate:226 type: str = "allocation"227 size: int = 0228 growth: int = 0229 iterations: int = 1230 traceback_fp: str = ""231232 def to_dict(self) -> Dict[str, Any]:233 return {234 "type": self.type,235 "size": self.size,236 "growth": self.growth,237 "iterations": self.iterations,238 "traceback": self.traceback_fp,239 }240241242@dataclass243class LeakReport:244 target: str = ""245 iterations: int = 0246 min_growth: int = 0247 candidates: List[LeakCandidate] = field(default_factory=list)248 live_type_deltas: Dict[str, int] = field(default_factory=dict)249 collections_fired: int = 0250 survived_weakrefs: List[Dict[str, Any]] = field(default_factory=list)251 circular_clusters: List[str] = field(default_factory=list)252 peak_traced_bytes: int = 0253254 def to_json(self) -> Dict[str, Any]:255 return {256 "target": self.target,257 "iterations": self.iterations,258 "min_growth_threshold_bytes": self.min_growth,259 "candidates": [c.to_dict() for c in self.candidates],260 "live_type_deltas": self.live_type_deltas,261 "collections_fired": self.collections_fired,262 "survived_weakrefs": self.survived_weakrefs,263 "circular_clusters": self.circular_clusters,264 "peak_traced_bytes": self.peak_traced_bytes,265 }266267268class Detector:269 """Iterative tracemalloc + GC leak detection around one callable."""270271 def __init__(self, min_growth: int = 1 << 20,272 key: str = "lineno",273 min_iterations: int = 3):274 self.min_growth = int(min_growth)275 self.key = key276 self.min_iterations = min_iterations277 self.report = LeakReport(min_growth=self.min_growth)278 self._tracked: List[Tuple[weakref.ReferenceType, str]] = []279280 def track_lifetime(self, obj: Any, name: str) -> weakref.ReferenceType:281 """Register an object that SHOULD be garbage when target() returns."""282 ref = weakref.ref(obj)283 self._tracked.append((ref, name))284 return ref285286 def run(self, target: Callable[[], Any], iterations: int = 6) -> LeakReport:287 report = self.report288 report.target = getattr(target, "__qualname__",289 getattr(target, "__name__", repr(target)))290 report.iterations = iterations291292 gc.collect()293 tracemalloc.start(25)294 atexit.register(tracemalloc.stop)295 before_types = live_object_counts()296297 running: Dict[Tuple[Tuple[str, int], ...], int] = {}298 series: Dict[Tuple[Tuple[str, int], ...], List[int]] = {}299 collections = 0300 for _ in range(iterations):301 collections += gc.collect()302 before = take_snapshot()303 target()304 collections += gc.collect()305 after = take_snapshot()306 for d in compare(before, after, n=4096, key=self.key):307 fp = _fingerprint(d)308 running[fp] = running.get(fp, 0) + d.size_diff309 series.setdefault(fp, []).append(running[fp])310311 for fp, vec in series.items():312 if _is_leaky(vec, iterations, self.min_growth, self.min_iterations):313 report.candidates.append(_candidate_from(fp, vec))314 report.candidates.sort(key=lambda c: c.growth, reverse=True)315316 after_types = live_object_counts()317 delta = {t: after_types.get(t, 0) - before_types.get(t, 0)318 for t in set(after_types) | set(before_types)}319 top = sorted((t for t in delta.items() if t[1] > 0),320 key=lambda kv: kv[1], reverse=True)[:10]321 report.live_type_deltas = dict(top)322323 for ref, name in self._tracked:324 obj = ref()325 if obj is not None:326 clusters = find_circular_clusters(obj, max_depth=8)327 report.survived_weakrefs.append({328 "name": name,329 "surviving_type": f"{type(obj).__module__}.{type(obj).__qualname__}",330 "candidate_clusters": clusters,331 })332 report.circular_clusters.extend(clusters)333334 report.collections_fired = collections335 report.peak_traced_bytes = tracemalloc.get_traced_memory()[0]336 return report337338339# --------------------------------------------------------------------------340# demo targets341# --------------------------------------------------------------------------342_demo_holder: List[bytearray] = []343344345def leaky_demo() -> None:346 """Documented demo: appends 64 KiB blocks every call, unbounded growth."""347 for _ in range(16):348 _demo_holder.append(bytearray(1 << 16))349350351def make_closure_leak() -> Callable[[], None]:352 """Returns a callable that accumulates captured dicts (real-world shape)."""353 captured: List[Dict[str, Any]] = []354355 def bump() -> None:356 captured.append({"big": bytearray(1 << 20)})357358 return bump359360361# --------------------------------------------------------------------------362# resolution & CLI363# --------------------------------------------------------------------------364def resolve_target(spec: str) -> Callable[[], Any]:365 spec = spec.strip()366 if ":" in spec:367 module_name, _, attr = spec.partition(":")368 elif "." in spec:369 module_name, _, attr = spec.rpartition(".")370 else:371 raise ValueError("target must look like 'module:func' or 'module.attr'")372 if not module_name or not attr:373 raise ValueError("target must look like 'module:func' or 'module.attr'")374 module = importlib.import_module(module_name)375 target = getattr(module, attr)376 if not callable(target):377 raise TypeError(f"{spec} resolved to {type(target).__name__}, not callable")378 return target379380381NODE_GUIDANCE = """\382V8 / Node.js heap snapshot guidance383-----------------------------------3841. Start the server under heap profiling:385386 node --heapsnapshot-near-heap-limit=5 --heap-prof app.js387388 '--heapsnapshot-near-heap-limit=5' writes a .heapsnapshot file each389 time the heap approaches the configured limit, letting you diff390 snapshots across load-generating runs.3913922. For a targeted before/after pair, snapshot explicitly:393394 const v8 = require('v8');395 // after warmup, before the workload spike396 v8.writeHeapSnapshot('./heap-before.heapsnapshot');397 // run the workload under test, then:398 if (global.gc) global.gc();399 v8.writeHeapSnapshot('./heap-after.heapsnapshot');4004013. Inspect snapshots in Chrome DevTools (chrome://inspect -> Memory402 -> 'Save and load profiles') and diff the retained-size histograms403 of 'CONCATENATED_STRING', 'ARRAY', 'CLOSURE' and object class names.404 Watch for constructors whose count grows monotonically between dumps.4054064. Event-listener leaks: run with --trace-warnings and search the heap407 snapshot for 'EventListener' / 'Trigger' entries; prefer listeners408 registered with { once: true } or removed via .removeEventListener().4094105. Map findings back to code: a growing 'CLOSURE' count usually means an411 event emitter or Promise chain retains the callback; use Chrome's412 Retainers panel to walk the reference chain to the leak root.413414Use one profiler per runtime -- the Python pipeline in this module detects415the same class of leak from the interpreter side.416"""417418419def main(argv=None) -> int:420 parser = argparse.ArgumentParser(421 prog="leak_profiler",422 description="Detect sustained allocation growth in a zero-arg callable.",423 )424 parser.add_argument("--target", default="",425 help="module:func to run repeatedly (default: built-in demo)")426 parser.add_argument("--iterations", type=int, default=6,427 help="paired snapshot iterations (default 6)")428 parser.add_argument("--min-growth", type=int, default=1 << 20,429 help="cumulative byte threshold for a leak signature")430 parser.add_argument("--key", choices=("lineno", "filename", "traceback"),431 default="lineno", help="tracemalloc grouping key")432 parser.add_argument("--out", default="", help="write the JSON report here")433 parser.add_argument("--node", action="store_true",434 help="print V8/Node heap-snapshot guidance and exit")435 args = parser.parse_args(argv)436437 if args.node:438 print(NODE_GUIDANCE)439 return 0440441 if args.target:442 try:443 target = resolve_target(args.target)444 except (ImportError, AttributeError, ValueError, TypeError) as exc:445 print(f"leak_profiler: cannot load target: {exc}", file=sys.stderr)446 return 2447 else:448 target = leaky_demo449 print("no --target given, using built-in leaky_demo()", file=sys.stderr)450451 detector = Detector(min_growth=args.min_growth, key=args.key)452 report = detector.run(target, iterations=max(2, args.iterations))453 payload = json.dumps(report.to_json(), indent=2, sort_keys=True)454 print(payload)455 if args.out:456 Path(args.out).write_text(payload + "\n", encoding="utf-8")457 return 0458459460if __name__ == "__main__":461 sys.exit(main())462```463464## 4. Execution Protocol & Step-by-Step Workflow4654661. 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.4672. 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.4683. Run the detector:469 - `python leak_profiler.py --target "app.services:create_client" --iterations 6 --out leak_report.json`470 - `python leak_profiler.py --key traceback --min-growth 2097152` raises the bar to 2 MiB and groups by full traceback.471 - `python leak_profiler.py` runs the built-in `leaky_demo` to smoke-test the pipeline.4724. 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.4735. Corroborate with the other signals:474 - `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).475 - `survived_weakrefs` — objects the caller marked should die via `detector.track_lifetime(...)` that are still alive.476 - `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`.4776. 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).4787. 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.4798. 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`.480481## 5. Edge Cases & Error Handling482483- **`tracemalloc` overhead**: tracing multiplies allocation cost on hot paths; if wall-clock time matters, raise `--min-growth` and lower `--iterations` rather than disabling tracing.484- **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.485- **`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.486- **Frame/traceback objects hiding in `get_referrers`**: `find_circular_clusters` deliberately skips `type`/`ModuleType`/frames/code so it reports application cycles, not interpreter scaffolding.487- **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.488- **`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.489- **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.490- **`--key traceback` fingerprints are memory-hungry** (each comparison materializes full stacks); keep `iterations <= 8` in that mode.491- **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.492- **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.