python-leak-hunter 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leak_hunter/__init__.py +39 -0
- leak_hunter/__main__.py +3 -0
- leak_hunter/cli.py +86 -0
- leak_hunter/growth.py +305 -0
- leak_hunter/memory.py +230 -0
- leak_hunter/py.typed +0 -0
- leak_hunter/pytest_plugin.py +20 -0
- leak_hunter/retention.py +284 -0
- python_leak_hunter-0.1.1.dist-info/METADATA +188 -0
- python_leak_hunter-0.1.1.dist-info/RECORD +13 -0
- python_leak_hunter-0.1.1.dist-info/WHEEL +4 -0
- python_leak_hunter-0.1.1.dist-info/entry_points.txt +5 -0
- python_leak_hunter-0.1.1.dist-info/licenses/LICENSE +21 -0
leak_hunter/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Find memory leaks in long-running Python services.
|
|
2
|
+
|
|
3
|
+
- `measure_growth` / `assert_no_leak`: which object types accumulate per unit of work.
|
|
4
|
+
- `why_alive`: what keeps them alive, down to "held by native code".
|
|
5
|
+
- `MemorySampler` / `diagnose`: is it really a leak, or fragmentation, or native memory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .growth import (
|
|
9
|
+
GrowthReport,
|
|
10
|
+
LeakError,
|
|
11
|
+
TypeGrowth,
|
|
12
|
+
assert_no_leak,
|
|
13
|
+
assert_no_leak_async,
|
|
14
|
+
count_objects,
|
|
15
|
+
measure_growth,
|
|
16
|
+
measure_growth_async,
|
|
17
|
+
)
|
|
18
|
+
from .memory import MemorySample, MemorySampler, diagnose, sample_memory
|
|
19
|
+
from .retention import RetentionPath, RetentionReport, why_alive
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"GrowthReport",
|
|
23
|
+
"LeakError",
|
|
24
|
+
"MemorySample",
|
|
25
|
+
"MemorySampler",
|
|
26
|
+
"RetentionPath",
|
|
27
|
+
"RetentionReport",
|
|
28
|
+
"TypeGrowth",
|
|
29
|
+
"assert_no_leak",
|
|
30
|
+
"assert_no_leak_async",
|
|
31
|
+
"count_objects",
|
|
32
|
+
"diagnose",
|
|
33
|
+
"measure_growth",
|
|
34
|
+
"measure_growth_async",
|
|
35
|
+
"sample_memory",
|
|
36
|
+
"why_alive",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.1"
|
leak_hunter/__main__.py
ADDED
leak_hunter/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Command line: `leak-hunter check` and `leak-hunter why`, aimed at a small harness function.
|
|
2
|
+
|
|
3
|
+
leak-hunter check harness:handle_one_request --iterations 30 --trace
|
|
4
|
+
leak-hunter why harness:handle_many_requests myapp.graph.Graph
|
|
5
|
+
|
|
6
|
+
`check` runs a zero-argument function repeatedly and exits 1 if objects accumulate.
|
|
7
|
+
`why` runs a setup function once, then explains what keeps instances of a type alive.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import gc
|
|
15
|
+
import importlib
|
|
16
|
+
import inspect
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .growth import awaited, measure_growth, type_name
|
|
23
|
+
from .retention import why_alive
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load(spec: str) -> Callable[[], Any]:
|
|
27
|
+
module_name, _, attribute = spec.partition(":")
|
|
28
|
+
if not module_name or not attribute:
|
|
29
|
+
raise SystemExit(f"expected module:function, got {spec!r}")
|
|
30
|
+
sys.path.insert(0, os.getcwd())
|
|
31
|
+
target: Any = importlib.import_module(module_name)
|
|
32
|
+
for part in attribute.split("."):
|
|
33
|
+
target = getattr(target, part)
|
|
34
|
+
if not callable(target):
|
|
35
|
+
raise SystemExit(f"{spec} is not callable")
|
|
36
|
+
return target # type: ignore[no-any-return] # verified callable above
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
prog="leak-hunter", description="Find memory leaks in long-running Python services."
|
|
42
|
+
)
|
|
43
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
44
|
+
|
|
45
|
+
check = commands.add_parser("check", help="find object types that accumulate per call")
|
|
46
|
+
check.add_argument("work", help="module:function taking no arguments (sync or async)")
|
|
47
|
+
check.add_argument("--iterations", type=int, default=20)
|
|
48
|
+
check.add_argument("--warmup", type=int, default=3)
|
|
49
|
+
check.add_argument("--min-per-call", type=float, default=0.5)
|
|
50
|
+
check.add_argument("--include", action="append", help="only report types with this name prefix")
|
|
51
|
+
check.add_argument("--ignore", action="append", default=[], help="skip types with this name prefix")
|
|
52
|
+
check.add_argument("--trace", action="store_true", help="also report allocation sites (tracemalloc)")
|
|
53
|
+
|
|
54
|
+
why = commands.add_parser("why", help="explain what keeps objects of a type alive")
|
|
55
|
+
why.add_argument("setup", help="module:function to run first, e.g. one that handles 50 requests")
|
|
56
|
+
why.add_argument("type_name", help="type name as printed by `check`, e.g. myapp.Graph")
|
|
57
|
+
why.add_argument("--samples", type=int, default=5)
|
|
58
|
+
|
|
59
|
+
args = parser.parse_args(argv)
|
|
60
|
+
if args.command == "check":
|
|
61
|
+
report = measure_growth(
|
|
62
|
+
_load(args.work),
|
|
63
|
+
iterations=args.iterations,
|
|
64
|
+
warmup=args.warmup,
|
|
65
|
+
min_per_call=args.min_per_call,
|
|
66
|
+
include=args.include,
|
|
67
|
+
ignore=args.ignore,
|
|
68
|
+
trace_allocations=args.trace,
|
|
69
|
+
)
|
|
70
|
+
print(report.format())
|
|
71
|
+
return 1 if report.leaked else 0
|
|
72
|
+
|
|
73
|
+
setup = _load(args.setup)
|
|
74
|
+
# Only trace instances the setup created, so `why ... list` follows the leaked lists.
|
|
75
|
+
gc.collect()
|
|
76
|
+
existing = {id(obj) for obj in gc.get_objects() if type_name(obj) == args.type_name}
|
|
77
|
+
result = setup()
|
|
78
|
+
if inspect.isawaitable(result):
|
|
79
|
+
asyncio.run(awaited(result))
|
|
80
|
+
del result
|
|
81
|
+
print(why_alive(args.type_name, samples=args.samples, exclude=existing).format())
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
raise SystemExit(main())
|
leak_hunter/growth.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Find object types that keep accumulating when the same unit of work runs repeatedly.
|
|
2
|
+
|
|
3
|
+
The measurement runs the work in two equal windows after a warm-up. A type only counts as
|
|
4
|
+
leaking when its live count grows in *both* windows: one-off growth such as a cache filling
|
|
5
|
+
up or a lazy import shows up in one window, not both.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import gc
|
|
12
|
+
import inspect
|
|
13
|
+
import os
|
|
14
|
+
import tracemalloc
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
Work = Callable[[], Any]
|
|
22
|
+
AsyncWork = Callable[[], Awaitable[Any]]
|
|
23
|
+
|
|
24
|
+
_PACKAGE_DIR = str(Path(__file__).parent)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def type_name(obj: object) -> str:
|
|
28
|
+
"""Fully qualified type name, without the noise of `builtins.`."""
|
|
29
|
+
cls = type(obj)
|
|
30
|
+
module = cls.__module__
|
|
31
|
+
return cls.__qualname__ if module == "builtins" else f"{module}.{cls.__qualname__}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def count_objects() -> Counter[str]:
|
|
35
|
+
"""Count live objects tracked by the garbage collector, by type name.
|
|
36
|
+
|
|
37
|
+
Only container-like objects are tracked (class instances, dicts, lists, closures...).
|
|
38
|
+
Plain strings, ints and bytes are not, so they never appear here; `trace_allocations`
|
|
39
|
+
covers them by allocation site instead.
|
|
40
|
+
"""
|
|
41
|
+
gc.collect()
|
|
42
|
+
counts: Counter[str] = Counter()
|
|
43
|
+
for obj in gc.get_objects():
|
|
44
|
+
counts[type_name(obj)] += 1
|
|
45
|
+
return counts
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class TypeGrowth:
|
|
50
|
+
type_name: str
|
|
51
|
+
first_window: int
|
|
52
|
+
second_window: int
|
|
53
|
+
iterations: int
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def per_call(self) -> float:
|
|
57
|
+
return self.second_window / self.iterations
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class AllocationGrowth:
|
|
62
|
+
location: str
|
|
63
|
+
size_bytes: int
|
|
64
|
+
blocks: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class GrowthReport:
|
|
69
|
+
iterations: int
|
|
70
|
+
growing: tuple[TypeGrowth, ...]
|
|
71
|
+
allocations: tuple[AllocationGrowth, ...] = ()
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def leaked(self) -> bool:
|
|
75
|
+
return bool(self.growing)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def suspect(self) -> str | None:
|
|
79
|
+
"""The type to trace next: the first one defined in a module, not a builtin like `list`."""
|
|
80
|
+
for growth in self.growing:
|
|
81
|
+
if "." in growth.type_name:
|
|
82
|
+
return growth.type_name
|
|
83
|
+
return self.growing[0].type_name if self.growing else None
|
|
84
|
+
|
|
85
|
+
def format(self, limit: int = 10) -> str:
|
|
86
|
+
lines = []
|
|
87
|
+
if not self.leaked:
|
|
88
|
+
lines.append(f"No object type grew steadily over 2 x {self.iterations} calls.")
|
|
89
|
+
else:
|
|
90
|
+
shown = self.growing[:limit]
|
|
91
|
+
width = max(len(g.type_name) for g in shown)
|
|
92
|
+
lines.append(f"Objects accumulating across calls (2 windows x {self.iterations} calls):")
|
|
93
|
+
for g in shown:
|
|
94
|
+
lines.append(
|
|
95
|
+
f" {g.type_name:<{width}} +{_number(g.per_call)} per call"
|
|
96
|
+
f" (window 1: {g.first_window:+d}, window 2: {g.second_window:+d})"
|
|
97
|
+
)
|
|
98
|
+
if len(self.growing) > limit:
|
|
99
|
+
lines.append(f" ... and {len(self.growing) - limit} more types")
|
|
100
|
+
if self.allocations:
|
|
101
|
+
# Covers what the object count cannot: bytes, str and other untracked objects.
|
|
102
|
+
lines.append(f"Allocation sites still holding memory from the last {self.iterations} calls:")
|
|
103
|
+
for a in self.allocations:
|
|
104
|
+
per_call = a.size_bytes / self.iterations / 1024
|
|
105
|
+
lines.append(f" {a.location} +{_number(per_call)} KiB per call ({a.blocks:+d} blocks)")
|
|
106
|
+
if self.suspect is not None:
|
|
107
|
+
lines.append("Next, see what keeps them alive:")
|
|
108
|
+
lines.append(f" leak-hunter why <module:function that runs many calls> {self.suspect}")
|
|
109
|
+
lines.append(f" or in Python: why_alive({self.suspect!r})")
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _number(value: float) -> str:
|
|
114
|
+
return f"{value:.2f}".rstrip("0").rstrip(".")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _short_path(path: str) -> str:
|
|
118
|
+
"""Relative to the working directory when inside it, so report lines stay readable."""
|
|
119
|
+
relative = os.path.relpath(path)
|
|
120
|
+
return path if relative.startswith("..") else relative
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class LeakError(AssertionError):
|
|
124
|
+
"""Raised by `assert_no_leak` when objects keep accumulating."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, report: GrowthReport) -> None:
|
|
127
|
+
super().__init__(report.format())
|
|
128
|
+
self.report = report
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _Windows:
|
|
132
|
+
"""Takes the three object counts around the two measured windows."""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
iterations: int,
|
|
138
|
+
min_per_call: float,
|
|
139
|
+
include: Iterable[str] | None,
|
|
140
|
+
ignore: Iterable[str],
|
|
141
|
+
trace_allocations: bool,
|
|
142
|
+
) -> None:
|
|
143
|
+
if iterations < 1:
|
|
144
|
+
raise ValueError("iterations must be at least 1")
|
|
145
|
+
self._iterations = iterations
|
|
146
|
+
self._threshold = min_per_call * iterations
|
|
147
|
+
self._include = tuple(include) if include is not None else None
|
|
148
|
+
self._ignore = tuple(ignore)
|
|
149
|
+
self._trace = trace_allocations
|
|
150
|
+
self._started_tracing = False
|
|
151
|
+
self._before: Counter[str] = Counter()
|
|
152
|
+
self._middle: Counter[str] = Counter()
|
|
153
|
+
self._middle_snapshot: tracemalloc.Snapshot | None = None
|
|
154
|
+
|
|
155
|
+
def start(self) -> None:
|
|
156
|
+
if self._trace and not tracemalloc.is_tracing():
|
|
157
|
+
tracemalloc.start()
|
|
158
|
+
self._started_tracing = True
|
|
159
|
+
self._before = count_objects()
|
|
160
|
+
|
|
161
|
+
def middle(self) -> None:
|
|
162
|
+
self._middle = count_objects()
|
|
163
|
+
if self._trace:
|
|
164
|
+
self._middle_snapshot = tracemalloc.take_snapshot()
|
|
165
|
+
|
|
166
|
+
def finish(self) -> GrowthReport:
|
|
167
|
+
after = count_objects()
|
|
168
|
+
allocations = self._allocations() if self._trace else ()
|
|
169
|
+
growing = []
|
|
170
|
+
for name, count in after.items():
|
|
171
|
+
first = self._middle[name] - self._before[name]
|
|
172
|
+
second = count - self._middle[name]
|
|
173
|
+
if first >= self._threshold and second >= self._threshold and self._wanted(name):
|
|
174
|
+
growing.append(TypeGrowth(name, first, second, self._iterations))
|
|
175
|
+
growing.sort(key=lambda g: g.second_window, reverse=True)
|
|
176
|
+
return GrowthReport(self._iterations, tuple(growing), allocations)
|
|
177
|
+
|
|
178
|
+
def close(self) -> None:
|
|
179
|
+
if self._started_tracing:
|
|
180
|
+
tracemalloc.stop()
|
|
181
|
+
self._started_tracing = False
|
|
182
|
+
|
|
183
|
+
def _wanted(self, name: str) -> bool:
|
|
184
|
+
if any(name.startswith(prefix) for prefix in self._ignore):
|
|
185
|
+
return False
|
|
186
|
+
return self._include is None or any(name.startswith(prefix) for prefix in self._include)
|
|
187
|
+
|
|
188
|
+
def _allocations(self, limit: int = 5) -> tuple[AllocationGrowth, ...]:
|
|
189
|
+
if self._middle_snapshot is None:
|
|
190
|
+
return ()
|
|
191
|
+
exclude = [
|
|
192
|
+
tracemalloc.Filter(False, tracemalloc.__file__),
|
|
193
|
+
tracemalloc.Filter(False, f"{_PACKAGE_DIR}/*"),
|
|
194
|
+
]
|
|
195
|
+
after = tracemalloc.take_snapshot().filter_traces(exclude)
|
|
196
|
+
before = self._middle_snapshot.filter_traces(exclude)
|
|
197
|
+
found = []
|
|
198
|
+
for stat in after.compare_to(before, "lineno"):
|
|
199
|
+
if stat.size_diff > 0 and stat.count_diff > 0:
|
|
200
|
+
frame = stat.traceback[0]
|
|
201
|
+
location = f"{_short_path(frame.filename)}:{frame.lineno}"
|
|
202
|
+
found.append(AllocationGrowth(location, stat.size_diff, stat.count_diff))
|
|
203
|
+
if len(found) == limit:
|
|
204
|
+
break
|
|
205
|
+
return tuple(found)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def measure_growth(
|
|
209
|
+
work: Work,
|
|
210
|
+
*,
|
|
211
|
+
iterations: int = 20,
|
|
212
|
+
warmup: int = 3,
|
|
213
|
+
min_per_call: float = 0.5,
|
|
214
|
+
include: Iterable[str] | None = None,
|
|
215
|
+
ignore: Iterable[str] = (),
|
|
216
|
+
trace_allocations: bool = False,
|
|
217
|
+
) -> GrowthReport:
|
|
218
|
+
"""Run `work` repeatedly and report object types that keep accumulating.
|
|
219
|
+
|
|
220
|
+
`work` takes no arguments and may be a plain function or return an awaitable (an
|
|
221
|
+
`async def` works); awaitables run on a private event loop. Inside an already running
|
|
222
|
+
loop, use `measure_growth_async` instead.
|
|
223
|
+
|
|
224
|
+
A type is reported when it gains at least `min_per_call` objects per call in both
|
|
225
|
+
windows. `include` / `ignore` filter by type-name prefix, e.g. `include=["myapp."]`.
|
|
226
|
+
"""
|
|
227
|
+
windows = _Windows(
|
|
228
|
+
iterations=iterations,
|
|
229
|
+
min_per_call=min_per_call,
|
|
230
|
+
include=include,
|
|
231
|
+
ignore=ignore,
|
|
232
|
+
trace_allocations=trace_allocations,
|
|
233
|
+
)
|
|
234
|
+
loop = asyncio.new_event_loop()
|
|
235
|
+
|
|
236
|
+
def run(times: int) -> None:
|
|
237
|
+
for _ in range(times):
|
|
238
|
+
result = work()
|
|
239
|
+
if inspect.isawaitable(result):
|
|
240
|
+
loop.run_until_complete(awaited(result))
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
run(warmup)
|
|
244
|
+
windows.start()
|
|
245
|
+
run(iterations)
|
|
246
|
+
windows.middle()
|
|
247
|
+
run(iterations)
|
|
248
|
+
return windows.finish()
|
|
249
|
+
finally:
|
|
250
|
+
windows.close()
|
|
251
|
+
loop.close()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def measure_growth_async(
|
|
255
|
+
work: AsyncWork,
|
|
256
|
+
*,
|
|
257
|
+
iterations: int = 20,
|
|
258
|
+
warmup: int = 3,
|
|
259
|
+
min_per_call: float = 0.5,
|
|
260
|
+
include: Iterable[str] | None = None,
|
|
261
|
+
ignore: Iterable[str] = (),
|
|
262
|
+
trace_allocations: bool = False,
|
|
263
|
+
) -> GrowthReport:
|
|
264
|
+
"""`measure_growth` for code that already runs inside an event loop."""
|
|
265
|
+
windows = _Windows(
|
|
266
|
+
iterations=iterations,
|
|
267
|
+
min_per_call=min_per_call,
|
|
268
|
+
include=include,
|
|
269
|
+
ignore=ignore,
|
|
270
|
+
trace_allocations=trace_allocations,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
async def run(times: int) -> None:
|
|
274
|
+
for _ in range(times):
|
|
275
|
+
await work()
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
await run(warmup)
|
|
279
|
+
windows.start()
|
|
280
|
+
await run(iterations)
|
|
281
|
+
windows.middle()
|
|
282
|
+
await run(iterations)
|
|
283
|
+
return windows.finish()
|
|
284
|
+
finally:
|
|
285
|
+
windows.close()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def assert_no_leak(work: Work, **options: Any) -> GrowthReport:
|
|
289
|
+
"""Fail with a readable report if `work` leaks objects. Takes `measure_growth`'s options."""
|
|
290
|
+
report = measure_growth(work, **options)
|
|
291
|
+
if report.leaked:
|
|
292
|
+
raise LeakError(report)
|
|
293
|
+
return report
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def assert_no_leak_async(work: AsyncWork, **options: Any) -> GrowthReport:
|
|
297
|
+
"""`assert_no_leak` for code that already runs inside an event loop."""
|
|
298
|
+
report = await measure_growth_async(work, **options)
|
|
299
|
+
if report.leaked:
|
|
300
|
+
raise LeakError(report)
|
|
301
|
+
return report
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def awaited(awaitable: Awaitable[Any]) -> Any:
|
|
305
|
+
return await awaitable
|
leak_hunter/memory.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Sample process memory over time and tell real retention apart from allocator effects.
|
|
2
|
+
|
|
3
|
+
RSS alone cannot distinguish "objects are being kept" from "freed memory is not returned to
|
|
4
|
+
the OS" (fragmentation) or "a native library is allocating outside malloc". On glibc,
|
|
5
|
+
`mallinfo2()` splits malloc's heap into bytes in use and bytes free, which separates them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import gc
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from types import TracebackType
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class MemorySample:
|
|
25
|
+
time: float
|
|
26
|
+
rss_bytes: int | None
|
|
27
|
+
heap_in_use_bytes: int | None
|
|
28
|
+
heap_free_bytes: int | None
|
|
29
|
+
gc_objects: int
|
|
30
|
+
open_fds: int | None
|
|
31
|
+
|
|
32
|
+
def to_json(self) -> str:
|
|
33
|
+
return json.dumps(asdict(self))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _MallInfo2(ctypes.Structure):
|
|
37
|
+
_fields_ = [
|
|
38
|
+
(field, ctypes.c_size_t)
|
|
39
|
+
for field in (
|
|
40
|
+
"arena",
|
|
41
|
+
"ordblks",
|
|
42
|
+
"smblks",
|
|
43
|
+
"hblks",
|
|
44
|
+
"hblkhd",
|
|
45
|
+
"usmblks",
|
|
46
|
+
"fsmblks",
|
|
47
|
+
"uordblks",
|
|
48
|
+
"fordblks",
|
|
49
|
+
"keepcost",
|
|
50
|
+
)
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_mallinfo2() -> Callable[[], _MallInfo2] | None:
|
|
55
|
+
if not sys.platform.startswith("linux"):
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
fn = ctypes.CDLL(None).mallinfo2
|
|
59
|
+
except (OSError, AttributeError): # musl, or glibc older than 2.33
|
|
60
|
+
return None
|
|
61
|
+
fn.restype = _MallInfo2
|
|
62
|
+
fn.argtypes = []
|
|
63
|
+
return fn
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_mallinfo2 = _load_mallinfo2()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _rss_bytes() -> int | None:
|
|
70
|
+
try:
|
|
71
|
+
import psutil # type: ignore[import-untyped, unused-ignore] # optional dependency
|
|
72
|
+
|
|
73
|
+
return int(psutil.Process().memory_info().rss)
|
|
74
|
+
except ImportError:
|
|
75
|
+
pass
|
|
76
|
+
try:
|
|
77
|
+
with open("/proc/self/statm") as statm:
|
|
78
|
+
return int(statm.read().split()[1]) * os.sysconf("SC_PAGE_SIZE")
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
try: # macOS and other BSDs without psutil
|
|
82
|
+
out = subprocess.run(
|
|
83
|
+
["ps", "-o", "rss=", "-p", str(os.getpid())], capture_output=True, text=True, check=True
|
|
84
|
+
)
|
|
85
|
+
return int(out.stdout.strip()) * 1024
|
|
86
|
+
except (OSError, ValueError, subprocess.CalledProcessError):
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _open_fds() -> int | None:
|
|
91
|
+
for path in ("/proc/self/fd", "/dev/fd"):
|
|
92
|
+
try:
|
|
93
|
+
return len(os.listdir(path))
|
|
94
|
+
except OSError:
|
|
95
|
+
continue
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sample_memory() -> MemorySample:
|
|
100
|
+
"""One reading of this process's memory. Heap fields are None off glibc."""
|
|
101
|
+
heap_in_use = heap_free = None
|
|
102
|
+
if _mallinfo2 is not None:
|
|
103
|
+
info = _mallinfo2()
|
|
104
|
+
heap_in_use = info.uordblks + info.hblkhd
|
|
105
|
+
heap_free = info.fordblks
|
|
106
|
+
return MemorySample(
|
|
107
|
+
time=time.time(),
|
|
108
|
+
rss_bytes=_rss_bytes(),
|
|
109
|
+
heap_in_use_bytes=heap_in_use,
|
|
110
|
+
heap_free_bytes=heap_free,
|
|
111
|
+
gc_objects=len(gc.get_objects()),
|
|
112
|
+
open_fds=_open_fds(),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class MemorySampler:
|
|
117
|
+
"""Samples memory on a background thread, e.g. once a minute in a running service.
|
|
118
|
+
|
|
119
|
+
Each sample goes to `on_sample` (log it as one JSON line with `sample.to_json()`) and is
|
|
120
|
+
kept in `samples` for `diagnose()`. Use as a context manager or call start()/stop().
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
interval: float = 60.0,
|
|
126
|
+
on_sample: Callable[[MemorySample], None] | None = None,
|
|
127
|
+
keep: int = 1_440,
|
|
128
|
+
) -> None:
|
|
129
|
+
self.interval = interval
|
|
130
|
+
self.samples: list[MemorySample] = []
|
|
131
|
+
self._on_sample = on_sample
|
|
132
|
+
self._keep = keep
|
|
133
|
+
self._stop = threading.Event()
|
|
134
|
+
self._thread: threading.Thread | None = None
|
|
135
|
+
|
|
136
|
+
def start(self) -> MemorySampler:
|
|
137
|
+
if self._thread is None:
|
|
138
|
+
self._stop.clear()
|
|
139
|
+
self._thread = threading.Thread(target=self._run, name="leak-hunter-sampler", daemon=True)
|
|
140
|
+
self._thread.start()
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def stop(self) -> None:
|
|
144
|
+
self._stop.set()
|
|
145
|
+
if self._thread is not None:
|
|
146
|
+
self._thread.join()
|
|
147
|
+
self._thread = None
|
|
148
|
+
|
|
149
|
+
def __enter__(self) -> MemorySampler:
|
|
150
|
+
return self.start()
|
|
151
|
+
|
|
152
|
+
def __exit__(
|
|
153
|
+
self,
|
|
154
|
+
exc_type: type[BaseException] | None,
|
|
155
|
+
exc: BaseException | None,
|
|
156
|
+
tb: TracebackType | None,
|
|
157
|
+
) -> None:
|
|
158
|
+
self.stop()
|
|
159
|
+
|
|
160
|
+
def _run(self) -> None:
|
|
161
|
+
while True:
|
|
162
|
+
sample = sample_memory()
|
|
163
|
+
self.samples.append(sample)
|
|
164
|
+
del self.samples[: -self._keep]
|
|
165
|
+
if self._on_sample is not None:
|
|
166
|
+
self._on_sample(sample)
|
|
167
|
+
if self._stop.wait(self.interval):
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def slope_per_hour(samples: Sequence[MemorySample], field: str) -> float | None:
|
|
172
|
+
"""Least-squares growth of a numeric sample field, in units per hour."""
|
|
173
|
+
points = [(s.time, getattr(s, field)) for s in samples if getattr(s, field) is not None]
|
|
174
|
+
if len(points) < 3:
|
|
175
|
+
return None
|
|
176
|
+
mean_t = sum(t for t, _ in points) / len(points)
|
|
177
|
+
mean_v = sum(v for _, v in points) / len(points)
|
|
178
|
+
spread = sum((t - mean_t) ** 2 for t, _ in points)
|
|
179
|
+
if spread == 0:
|
|
180
|
+
return None
|
|
181
|
+
return float(sum((t - mean_t) * (v - mean_v) for t, v in points) / spread * 3600)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def diagnose(samples: Sequence[MemorySample], min_mb_per_hour: float = 10.0) -> str:
|
|
185
|
+
"""Classify memory growth from samples taken over a meaningful stretch of real traffic."""
|
|
186
|
+
mb = 1024 * 1024
|
|
187
|
+
rss = slope_per_hour(samples, "rss_bytes")
|
|
188
|
+
if rss is None:
|
|
189
|
+
return "Not enough samples: take at least 3, spread over time, with RSS available."
|
|
190
|
+
if rss < min_mb_per_hour * mb:
|
|
191
|
+
return f"RSS is flat ({rss / mb:+.1f} MB/h). No leak visible in this window."
|
|
192
|
+
|
|
193
|
+
lines = [f"RSS grows {rss / mb:+.1f} MB/h."]
|
|
194
|
+
in_use = slope_per_hour(samples, "heap_in_use_bytes")
|
|
195
|
+
free = slope_per_hour(samples, "heap_free_bytes")
|
|
196
|
+
objects = slope_per_hour(samples, "gc_objects")
|
|
197
|
+
fds = slope_per_hour(samples, "open_fds")
|
|
198
|
+
|
|
199
|
+
if in_use is not None and free is not None:
|
|
200
|
+
if in_use >= 0.5 * rss:
|
|
201
|
+
lines.append(
|
|
202
|
+
f"malloc heap in use grows {in_use / mb:+.1f} MB/h: memory is being retained, not "
|
|
203
|
+
"fragmented. Find what accumulates (measure_growth), then what holds it (why_alive)."
|
|
204
|
+
)
|
|
205
|
+
elif free >= 0.5 * rss:
|
|
206
|
+
lines.append(
|
|
207
|
+
f"malloc free space grows {free / mb:+.1f} MB/h while in-use is flat: fragmentation. "
|
|
208
|
+
"Try MALLOC_ARENA_MAX=2 or jemalloc before hunting objects."
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
lines.append(
|
|
212
|
+
"malloc's heap explains little of it: memory is allocated outside malloc (mmap by a "
|
|
213
|
+
"native library, thread stacks). Profile natively with memray --native or jemalloc."
|
|
214
|
+
)
|
|
215
|
+
elif objects is not None and objects > 0:
|
|
216
|
+
lines.append(
|
|
217
|
+
f"The live Python object count grows {objects:+.0f}/h: objects are accumulating. "
|
|
218
|
+
"Run measure_growth on one unit of work."
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
lines.append(
|
|
222
|
+
"The Python object count is flat, so the growth is native memory or fragmentation. "
|
|
223
|
+
"No mallinfo2 here (not glibc): use memray --native, or test under Linux."
|
|
224
|
+
)
|
|
225
|
+
if fds is not None and fds > 1:
|
|
226
|
+
lines.append(
|
|
227
|
+
f"Open file descriptors grow {fds:+.0f}/h: connections or files are not being closed "
|
|
228
|
+
"(clients created per request?)."
|
|
229
|
+
)
|
|
230
|
+
return "\n".join(lines)
|
leak_hunter/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""pytest plugin: the `leak_check` fixture, registered automatically when the package is installed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from .growth import GrowthReport, Work, assert_no_leak
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def leak_check() -> Callable[..., GrowthReport]:
|
|
15
|
+
"""`leak_check(work, **options)` fails the test if `work` leaks. Same options as `measure_growth`."""
|
|
16
|
+
|
|
17
|
+
def check(work: Work, **options: Any) -> GrowthReport:
|
|
18
|
+
return assert_no_leak(work, **options)
|
|
19
|
+
|
|
20
|
+
return check
|
leak_hunter/retention.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Explain what keeps an object alive by walking its referrers back to a root.
|
|
2
|
+
|
|
3
|
+
A root is a module global or a variable in a running frame. When the walk instead reaches an
|
|
4
|
+
object that nothing in Python refers to, yet it is still alive, the reference comes from
|
|
5
|
+
somewhere the garbage collector cannot see: usually a C extension (gRPC, a database driver,
|
|
6
|
+
an HTTP client) that stored the object without exposing it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import gc
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import types
|
|
15
|
+
from collections import Counter, deque
|
|
16
|
+
from collections.abc import Collection
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from .growth import type_name
|
|
20
|
+
|
|
21
|
+
_THIS_FILE = __file__
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class RetentionPath:
|
|
26
|
+
"""One chain of references, root first and the object being explained last."""
|
|
27
|
+
|
|
28
|
+
steps: tuple[str, ...]
|
|
29
|
+
root: str
|
|
30
|
+
outside_python: bool
|
|
31
|
+
|
|
32
|
+
def format(self) -> str:
|
|
33
|
+
lines = [f" {self.root}"]
|
|
34
|
+
lines += [f" -> {step}" for step in self.steps]
|
|
35
|
+
return "\n".join(lines)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class RetentionReport:
|
|
40
|
+
type_name: str
|
|
41
|
+
instances: int
|
|
42
|
+
paths: tuple[tuple[RetentionPath, int], ...]
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def outside_python(self) -> bool:
|
|
46
|
+
return any(path.outside_python for path, _ in self.paths)
|
|
47
|
+
|
|
48
|
+
def format(self) -> str:
|
|
49
|
+
if not self.paths:
|
|
50
|
+
return f"No live {self.type_name} objects found."
|
|
51
|
+
sampled = sum(count for _, count in self.paths)
|
|
52
|
+
lines = [f"{self.instances} live {self.type_name}; traced {sampled}:"]
|
|
53
|
+
for path, count in self.paths:
|
|
54
|
+
lines.append(f"{count} of {sampled} held by:")
|
|
55
|
+
lines.append(path.format())
|
|
56
|
+
return "\n".join(lines)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def why_alive(
|
|
60
|
+
target: object,
|
|
61
|
+
*,
|
|
62
|
+
samples: int = 5,
|
|
63
|
+
max_nodes: int = 5_000,
|
|
64
|
+
exclude: Collection[int] = (),
|
|
65
|
+
) -> RetentionReport:
|
|
66
|
+
"""Explain what keeps objects alive.
|
|
67
|
+
|
|
68
|
+
`target` is a type, a type name as printed by `measure_growth` (e.g. "myapp.Graph"), or
|
|
69
|
+
a single object. For a type, up to `samples` instances spread across the live ones are
|
|
70
|
+
traced, and identical chains are grouped, so one report covers the common case and the
|
|
71
|
+
exceptions. Only objects tracked by the garbage collector can be found by type.
|
|
72
|
+
|
|
73
|
+
`exclude` is a set of object ids to skip, e.g. the instances that existed before the
|
|
74
|
+
code under test ran. That matters for builtin types: tracing "list" otherwise samples
|
|
75
|
+
arbitrary lists from all over the process.
|
|
76
|
+
"""
|
|
77
|
+
gc.collect() # unreachable cycles are garbage, not leaks: free them before looking
|
|
78
|
+
caller = sys._getframe(1)
|
|
79
|
+
skip_frames = {id(caller)} if not isinstance(target, (type, str)) else set()
|
|
80
|
+
|
|
81
|
+
if isinstance(target, (type, str)):
|
|
82
|
+
name = target if isinstance(target, str) else _qualified(target)
|
|
83
|
+
picked, total = _pick_instances(name, samples, exclude)
|
|
84
|
+
else:
|
|
85
|
+
name = type_name(target)
|
|
86
|
+
picked = [target]
|
|
87
|
+
total = 1
|
|
88
|
+
|
|
89
|
+
module_dicts = {id(vars(m)): m_name for m_name, m in list(sys.modules.items()) if m is not None}
|
|
90
|
+
frame_locals = _running_frame_locals(skip_frames)
|
|
91
|
+
del caller
|
|
92
|
+
# Group by the shape of the chain, so list[3] and list[7] count as the same cause.
|
|
93
|
+
first_of_shape: dict[tuple[str, tuple[str, ...]], RetentionPath] = {}
|
|
94
|
+
counts: Counter[tuple[str, tuple[str, ...]]] = Counter()
|
|
95
|
+
for obj in picked:
|
|
96
|
+
path = _trace(obj, {id(picked)}, skip_frames, module_dicts, frame_locals, max_nodes)
|
|
97
|
+
shape = (path.root, tuple(_INDEX.sub("[..]", step) for step in path.steps))
|
|
98
|
+
first_of_shape.setdefault(shape, path)
|
|
99
|
+
counts[shape] += 1
|
|
100
|
+
del picked
|
|
101
|
+
return RetentionReport(name, total, tuple((first_of_shape[s], n) for s, n in counts.most_common()))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_INDEX = re.compile(r"\[[^\]]*\]")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _running_frame_locals(skip_frames: set[int]) -> dict[int, str]:
|
|
108
|
+
"""Map id(value) to a description, for locals of functions still executing on any thread.
|
|
109
|
+
|
|
110
|
+
Since Python 3.11 the garbage collector does not report executing frames as referrers, so
|
|
111
|
+
without this an object held only by a running function's variable would look native.
|
|
112
|
+
"""
|
|
113
|
+
found: dict[int, str] = {}
|
|
114
|
+
for top in sys._current_frames().values():
|
|
115
|
+
frame: types.FrameType | None = top
|
|
116
|
+
while frame is not None:
|
|
117
|
+
code = frame.f_code
|
|
118
|
+
if id(frame) not in skip_frames and code.co_filename != _THIS_FILE:
|
|
119
|
+
where = f"in {code.co_name}() at {code.co_filename}:{frame.f_lineno}"
|
|
120
|
+
for var, value in frame.f_locals.items():
|
|
121
|
+
found.setdefault(id(value), f"variable {var} {where}")
|
|
122
|
+
frame = frame.f_back
|
|
123
|
+
return found
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _qualified(cls: type) -> str:
|
|
127
|
+
return cls.__qualname__ if cls.__module__ == "builtins" else f"{cls.__module__}.{cls.__qualname__}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _pick_instances(name: str, samples: int, exclude: Collection[int]) -> tuple[list[object], int]:
|
|
131
|
+
"""Up to `samples` evenly spaced instances of the named type, and how many are alive."""
|
|
132
|
+
found = [obj for obj in gc.get_objects() if type_name(obj) == name and id(obj) not in exclude]
|
|
133
|
+
total = len(found)
|
|
134
|
+
if total <= samples:
|
|
135
|
+
picked: list[object] = list(found)
|
|
136
|
+
else:
|
|
137
|
+
step = (total - 1) / (samples - 1) if samples > 1 else 0
|
|
138
|
+
picked = [found[round(i * step)] for i in range(samples)]
|
|
139
|
+
del found
|
|
140
|
+
return picked, total
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _trace(
|
|
144
|
+
target: object,
|
|
145
|
+
ignored: set[int],
|
|
146
|
+
skip_frames: set[int],
|
|
147
|
+
module_dicts: dict[int, str],
|
|
148
|
+
frame_locals: dict[int, str],
|
|
149
|
+
max_nodes: int,
|
|
150
|
+
) -> RetentionPath:
|
|
151
|
+
"""Breadth-first search from `target` up its referrers to the nearest root."""
|
|
152
|
+
nodes: dict[int, object] = {id(target): target}
|
|
153
|
+
# link[holder] = (held, how the holder refers to it), pointing back towards the target.
|
|
154
|
+
link: dict[int, tuple[int, str]] = {}
|
|
155
|
+
queue: deque[int] = deque([id(target)])
|
|
156
|
+
ours = {id(nodes), id(link), id(queue), *ignored}
|
|
157
|
+
dead_end: int | None = None
|
|
158
|
+
|
|
159
|
+
while queue and len(nodes) < max_nodes:
|
|
160
|
+
node_id = queue.popleft()
|
|
161
|
+
if node_id in frame_locals:
|
|
162
|
+
return _path(frame_locals[node_id], node_id, nodes, link, outside_python=False)
|
|
163
|
+
holders = [r for r in gc.get_referrers(nodes[node_id]) if not _is_ours(r, ours, skip_frames)]
|
|
164
|
+
ours.add(id(holders))
|
|
165
|
+
try:
|
|
166
|
+
if not holders and dead_end is None:
|
|
167
|
+
dead_end = node_id
|
|
168
|
+
for holder in holders:
|
|
169
|
+
holder_id = id(holder)
|
|
170
|
+
if holder_id in nodes:
|
|
171
|
+
continue
|
|
172
|
+
how = _how_held(holder, nodes[node_id])
|
|
173
|
+
if holder_id in module_dicts:
|
|
174
|
+
root = f"module {module_dicts[holder_id]}, global {how.strip('[]')}"
|
|
175
|
+
return _path(root, node_id, nodes, link, outside_python=False)
|
|
176
|
+
if isinstance(holder, types.FrameType):
|
|
177
|
+
code = holder.f_code
|
|
178
|
+
root = (
|
|
179
|
+
f"variable {how or '?'} in {code.co_name}() at {code.co_filename}:{holder.f_lineno}"
|
|
180
|
+
)
|
|
181
|
+
return _path(root, node_id, nodes, link, outside_python=False)
|
|
182
|
+
nodes[holder_id] = holder
|
|
183
|
+
link[holder_id] = (node_id, how)
|
|
184
|
+
queue.append(holder_id)
|
|
185
|
+
finally:
|
|
186
|
+
ours.discard(id(holders))
|
|
187
|
+
del holders
|
|
188
|
+
|
|
189
|
+
if dead_end is not None:
|
|
190
|
+
kind = type_name(nodes[dead_end])
|
|
191
|
+
root = (
|
|
192
|
+
f"outside Python: nothing the garbage collector can see refers to this {kind}, "
|
|
193
|
+
"yet it is alive (typically a C extension holding it)"
|
|
194
|
+
)
|
|
195
|
+
return _path(root, dead_end, nodes, link, outside_python=True)
|
|
196
|
+
return RetentionPath((type_name(target),), f"no root found within {max_nodes} objects", False)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _path(
|
|
200
|
+
root: str,
|
|
201
|
+
start: int,
|
|
202
|
+
nodes: dict[int, object],
|
|
203
|
+
link: dict[int, tuple[int, str]],
|
|
204
|
+
*,
|
|
205
|
+
outside_python: bool,
|
|
206
|
+
) -> RetentionPath:
|
|
207
|
+
"""Render the chain from `start` down to the target, merging `obj.__dict__['x']` into `obj.x`."""
|
|
208
|
+
hops: list[tuple[str, str]] = []
|
|
209
|
+
current = start
|
|
210
|
+
while True:
|
|
211
|
+
obj = nodes[current]
|
|
212
|
+
nxt = link.get(current)
|
|
213
|
+
hops.append((_label(obj), nxt[1] if nxt else ""))
|
|
214
|
+
if nxt is None:
|
|
215
|
+
break
|
|
216
|
+
current = nxt[0]
|
|
217
|
+
|
|
218
|
+
steps: list[str] = []
|
|
219
|
+
i = 0
|
|
220
|
+
while i < len(hops):
|
|
221
|
+
label, how = hops[i]
|
|
222
|
+
if how == ".__dict__" and i + 1 < len(hops) and hops[i + 1][1].startswith("["):
|
|
223
|
+
key = hops[i + 1][1][1:-1].strip("'\"")
|
|
224
|
+
steps.append(f"{label}.{key}")
|
|
225
|
+
i += 2
|
|
226
|
+
continue
|
|
227
|
+
steps.append(f"{label}{how}")
|
|
228
|
+
i += 1
|
|
229
|
+
steps[-1] += " <- the object being explained"
|
|
230
|
+
return RetentionPath(tuple(steps), root, outside_python)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _is_ours(obj: object, ours: set[int], skip_frames: set[int]) -> bool:
|
|
234
|
+
if id(obj) in ours or id(obj) in skip_frames:
|
|
235
|
+
return True
|
|
236
|
+
return isinstance(obj, types.FrameType) and obj.f_code.co_filename == _THIS_FILE
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _label(obj: object) -> str:
|
|
240
|
+
if isinstance(obj, type):
|
|
241
|
+
return f"class {obj.__qualname__}"
|
|
242
|
+
if isinstance(obj, types.MethodType):
|
|
243
|
+
return f"bound method {obj.__qualname__}"
|
|
244
|
+
if isinstance(obj, types.FunctionType):
|
|
245
|
+
return f"function {obj.__qualname__}"
|
|
246
|
+
return type_name(obj)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _how_held(holder: object, held: object) -> str:
|
|
250
|
+
"""How `holder` refers to `held`, e.g. "['key']", "[3]" or ".__dict__"."""
|
|
251
|
+
try:
|
|
252
|
+
if isinstance(holder, dict):
|
|
253
|
+
for key, value in holder.items():
|
|
254
|
+
if value is held:
|
|
255
|
+
return f"[{key!r}]"
|
|
256
|
+
if key is held:
|
|
257
|
+
return " (dict key)"
|
|
258
|
+
return ""
|
|
259
|
+
if isinstance(holder, (list, tuple)):
|
|
260
|
+
for index, value in enumerate(holder):
|
|
261
|
+
if value is held:
|
|
262
|
+
return f"[{index}]"
|
|
263
|
+
return ""
|
|
264
|
+
if isinstance(holder, (set, frozenset)):
|
|
265
|
+
return " (set member)"
|
|
266
|
+
if isinstance(holder, types.FrameType):
|
|
267
|
+
for key, value in holder.f_locals.items():
|
|
268
|
+
if value is held:
|
|
269
|
+
return key
|
|
270
|
+
return ""
|
|
271
|
+
if isinstance(holder, types.MethodType):
|
|
272
|
+
return ".__self__" if holder.__self__ is held else ".__func__"
|
|
273
|
+
if isinstance(holder, types.CellType):
|
|
274
|
+
return " (closure variable)"
|
|
275
|
+
if isinstance(held, dict) and (isinstance(holder, type) or getattr(holder, "__dict__", None) is held):
|
|
276
|
+
return ".__dict__"
|
|
277
|
+
attributes = getattr(holder, "__dict__", None)
|
|
278
|
+
if isinstance(attributes, dict):
|
|
279
|
+
for key, value in attributes.items():
|
|
280
|
+
if value is held:
|
|
281
|
+
return f".{key}"
|
|
282
|
+
except Exception: # an exotic holder (proxy, broken __getattr__) just gets no label
|
|
283
|
+
return ""
|
|
284
|
+
return ""
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: python-leak-hunter
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Find memory leaks in long-running Python services: what accumulates, what keeps it alive, and a test that keeps it fixed. Ships with an agent skill for Claude Code and Codex.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tariqamarneh/python-leak-hunter
|
|
6
|
+
Project-URL: The leak behind it, https://www.tariqamarneh.com/writing/grpc-contextvars-memory-leak
|
|
7
|
+
Author-email: Tariq Amarneh <tariqs.naser@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent skill,contextvars,debugging,gc,langgraph,memory leak,profiling,pytest
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: Pytest
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: psutil
|
|
21
|
+
Requires-Dist: psutil>=5.9; extra == 'psutil'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# python-leak-hunter
|
|
25
|
+
|
|
26
|
+
[](https://github.com/tariqamarneh/python-leak-hunter/actions/workflows/ci.yml)
|
|
27
|
+
|
|
28
|
+
Find memory leaks in long-running Python services: **what accumulates**, **what keeps it
|
|
29
|
+
alive** (down to "held by a C extension"), and **a test that keeps it fixed**. It ships with an
|
|
30
|
+
[agent skill](https://github.com/tariqamarneh/python-leak-hunter/blob/main/skills/python-leak-hunter/SKILL.md) that teaches Claude Code, Codex and other
|
|
31
|
+
coding agents the whole investigation, not just the tools.
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
$ leak-hunter why harness:many_requests app.RunTrace
|
|
35
|
+
|
|
36
|
+
43 live app.RunTrace; traced 5:
|
|
37
|
+
5 of 5 held by:
|
|
38
|
+
outside Python: nothing the garbage collector can see refers to this grpc._plugin_wrapping._Plugin, yet it is alive (typically a C extension holding it)
|
|
39
|
+
-> grpc._plugin_wrapping._Plugin._stored_ctx
|
|
40
|
+
-> _contextvars.Context
|
|
41
|
+
-> hamt
|
|
42
|
+
-> hamt_bitmap_node
|
|
43
|
+
-> app.RunTrace <- the object being explained
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
That output is from [a real grpcio reproduction](https://github.com/tariqamarneh/python-leak-hunter/blob/main/examples/grpc_cycle.py): a gRPC channel built
|
|
47
|
+
inside a request captures the request's `contextvars` context. When that context can reach
|
|
48
|
+
the channel back, the cycle runs through C code and is never freed. In production this pinned
|
|
49
|
+
whole AI-agent graphs in memory, about 2 GB every 6 hours per worker.
|
|
50
|
+
[The full story](https://www.tariqamarneh.com/writing/grpc-contextvars-memory-leak).
|
|
51
|
+
|
|
52
|
+
## Use it as an agent skill
|
|
53
|
+
|
|
54
|
+
The skill walks an agent through the method: confirm the growth is real, reproduce one unit
|
|
55
|
+
of work, find what accumulates, trace what holds it, fix it by pattern, then prove it and add a
|
|
56
|
+
regression test. It installs the package below as needed.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/tariqamarneh/python-leak-hunter
|
|
60
|
+
|
|
61
|
+
# Claude Code: for you, or for one project
|
|
62
|
+
cp -r python-leak-hunter/skills/python-leak-hunter ~/.claude/skills/
|
|
63
|
+
cp -r python-leak-hunter/skills/python-leak-hunter your-project/.claude/skills/
|
|
64
|
+
|
|
65
|
+
# Codex: per repository
|
|
66
|
+
cp -r python-leak-hunter/skills/python-leak-hunter your-project/.agents/skills/
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then ask: *"Memory in our worker keeps growing. Find the leak."*
|
|
70
|
+
|
|
71
|
+
## Use it directly
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install python-leak-hunter
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
No dependencies. Python 3.10+.
|
|
78
|
+
|
|
79
|
+
### 1. What accumulates?
|
|
80
|
+
|
|
81
|
+
Write a function that runs one unit of work (a request, a message, an agent turn), then:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
leak-hunter check harness:one_request --include myapp. --iterations 30
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
Objects accumulating across calls (2 windows x 30 calls):
|
|
89
|
+
myapp.RunTrace +1 per call (window 1: +30, window 2: +30)
|
|
90
|
+
Next: why_alive('myapp.RunTrace') shows what keeps them alive.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The work runs in two windows after a warm-up, and only types that grow in **both** are
|
|
94
|
+
reported, so a cache filling up once is not a leak. Async functions work too. Add `--trace` to
|
|
95
|
+
also list allocation sites, which catches `bytes` and `str` that the object count cannot see.
|
|
96
|
+
|
|
97
|
+
### 2. What keeps it alive?
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
leak-hunter why harness:many_requests myapp.RunTrace
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
It walks referrers back to a root and groups instances that share a chain:
|
|
104
|
+
|
|
105
|
+
- `module X, global 'name'`: a module-level cache or registry.
|
|
106
|
+
- `variable v in f() at file:line`: a long-running function's local.
|
|
107
|
+
- `outside Python: ...`: nothing in Python refers to that object, yet it is alive, so native
|
|
108
|
+
code holds it.
|
|
109
|
+
|
|
110
|
+
### 3. Keep it fixed
|
|
111
|
+
|
|
112
|
+
The `leak_check` pytest fixture is registered when the package is installed:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
def test_request_does_not_leak(leak_check):
|
|
116
|
+
leak_check(handle_one_request, include=["myapp."], iterations=30)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from leak_hunter import assert_no_leak, assert_no_leak_async, measure_growth, why_alive
|
|
121
|
+
|
|
122
|
+
report = measure_growth(handle_one_request, include=["myapp."])
|
|
123
|
+
print(report.format())
|
|
124
|
+
print(why_alive("myapp.RunTrace").format())
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Is it even a leak?
|
|
128
|
+
|
|
129
|
+
Sample memory in the running service and let `diagnose` separate real retention from
|
|
130
|
+
fragmentation and native allocations:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
import logging
|
|
134
|
+
from leak_hunter import MemorySampler, diagnose
|
|
135
|
+
|
|
136
|
+
sampler = MemorySampler(interval=60, on_sample=lambda s: logging.info("memory %s", s.to_json()))
|
|
137
|
+
sampler.start()
|
|
138
|
+
...
|
|
139
|
+
print(diagnose(sampler.samples))
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
RSS grows +312.4 MB/h.
|
|
144
|
+
malloc heap in use grows +305.1 MB/h: memory is being retained, not fragmented. Find what accumulates (measure_growth), then what holds it (why_alive).
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
On glibc it reads `mallinfo2()` to split malloc's heap into bytes in use and bytes free. If
|
|
148
|
+
free space grows while in-use stays flat, the problem is fragmentation, and hunting objects
|
|
149
|
+
would be a waste of time.
|
|
150
|
+
|
|
151
|
+
## Try the demo
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
git clone https://github.com/tariqamarneh/python-leak-hunter && cd python-leak-hunter
|
|
155
|
+
uv run python examples/contextvars_leak/demo.py # no gRPC needed
|
|
156
|
+
uv run --with grpcio python examples/grpc_cycle.py # the real thing
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## How it works
|
|
160
|
+
|
|
161
|
+
- **Growth:** counts live objects tracked by the garbage collector, by type, after a full
|
|
162
|
+
collection, around two equal windows of calls.
|
|
163
|
+
- **Retention:** breadth-first search over `gc.get_referrers` from an instance to the nearest
|
|
164
|
+
module global or running frame, skipping the tool's own references. Since Python 3.11,
|
|
165
|
+
executing frames are not reported as referrers, so their locals are checked separately; an
|
|
166
|
+
object held by a running function is not mistaken for native memory. A chain that ends at
|
|
167
|
+
an object with no visible referrers is reported as held outside Python.
|
|
168
|
+
- **Memory:** RSS from `psutil` when installed, otherwise `/proc` or `ps`; heap stats from
|
|
169
|
+
glibc's `mallinfo2()`; open file descriptors.
|
|
170
|
+
|
|
171
|
+
## Limits
|
|
172
|
+
|
|
173
|
+
- Finding instances by type only works for objects the garbage collector tracks: class
|
|
174
|
+
instances and containers, not plain `str`, `bytes` or `int`. Use `--trace` for those.
|
|
175
|
+
- `why_alive` scans the heap at each step. Use it in a test or a staging process, not on a
|
|
176
|
+
hot path in production.
|
|
177
|
+
- The heap split needs glibc 2.33+ (most Linux images). Elsewhere, `diagnose` falls back to the
|
|
178
|
+
object count.
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
uv sync
|
|
184
|
+
uv run pytest
|
|
185
|
+
uv run ruff check . && uv run mypy
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
MIT licensed. Built by [Tariq Amarneh](https://www.tariqamarneh.com).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
leak_hunter/__init__.py,sha256=oHpF7bEtwQ-sWZe8qx3OqpmLE0mE9BAlRaJORntbFQM,980
|
|
2
|
+
leak_hunter/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
leak_hunter/cli.py,sha256=ldQwpFw0bseo7x-98hwUn25Wi_AV83KbfFb3_4MQipc,3438
|
|
4
|
+
leak_hunter/growth.py,sha256=r8CtNPyWj1lA9glUaFB0WJLNSrV4VEIvD4gYlGrCD5Y,10297
|
|
5
|
+
leak_hunter/memory.py,sha256=2sefNlUei3BgUGtBjMyu8OIOD3gXoe79-6LcJBR5ciM,7604
|
|
6
|
+
leak_hunter/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
leak_hunter/pytest_plugin.py,sha256=3R6sYS8b4v9y86oPJmmCGcPalVhZQjjrdlKdT9QoQ98,569
|
|
8
|
+
leak_hunter/retention.py,sha256=bjIE2lE0CmgqSLQdFMHzVRdZx2Ye7-VpuSfzHdC17qQ,10893
|
|
9
|
+
python_leak_hunter-0.1.1.dist-info/METADATA,sha256=bVFQulTBooaTL8ke4-MKi1WcUIGe6cb_6QUSccRPHkY,7323
|
|
10
|
+
python_leak_hunter-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
python_leak_hunter-0.1.1.dist-info/entry_points.txt,sha256=y3fhMVxLtEeUQcSEqUKFQQILfall0Pdp7pVAbDRCXyI,105
|
|
12
|
+
python_leak_hunter-0.1.1.dist-info/licenses/LICENSE,sha256=E3TQTPEnoy1kVP3olEtqKd2YRGIp7TWk7xHf2fqMhW8,1070
|
|
13
|
+
python_leak_hunter-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tariq Amarneh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|