Bug Report
(mypyc) A mypyc-compiled class that inherits from Exception (any BaseException subclass) and has
instance attributes leaks memory on every instantiation — the instance's attribute storage (its
__dict__ contents) is never freed. The pure-Python version of the same class does not leak, and a
bare Exception subclass with no attributes does not leak. The leak is unbounded, so a long-running
process grows in RSS until it OOMs.
Encountered in an open-source project: sqlglot compiled via mypyc (sqlglot[c] / the sqlglotc
package — https://github.com/tobymao/sqlglot). Its ParseError is an Exception subclass with
attributes, so every raised-and-caught parse error leaks ~1 KB, OOM-ing long-running parsers.
To Reproduce
leakmod.py:
class BareErr(Exception): # no attributes -> no leak
pass
class AttrErr(Exception): # has an instance attribute -> leaks
def __init__(self, msg: str) -> None:
self.data = msg
Compile with mypyc and run the driver (a mypy-play link isn't applicable — this is a mypyc runtime
issue, not a type-check result):
python -m mypyc leakmod.py
# run.py
import gc, psutil, leakmod
def rss_mb():
return psutil.Process().memory_info().rss / 1024 / 1024
def measure(cls):
for i in range(1000): # warm up
cls("payload " * 20 + str(i))
gc.collect()
base = rss_mb()
for i in range(40000): # construct + discard, never raised
cls("payload " * 20 + str(i))
gc.collect()
print(f"{cls.__name__}: {rss_mb() - base:+.1f} MB")
measure(leakmod.BareErr)
measure(leakmod.AttrErr)
Expected Behavior
Constructing and discarding exception instances should not accumulate memory — RSS stays flat, as it
does for the pure-Python module.
Actual Behavior
Compiled with mypyc, AttrErr leaks ~400 bytes per instance (unbounded); pure Python does not:
# compiled (python -m mypyc leakmod.py)
BareErr: +0.1 MB
AttrErr: +16.1 MB # over 40k constructions; grows linearly, never plateaus
# pure python (same file, not compiled)
BareErr: +0.0 MB
AttrErr: +0.0 MB
Additional observations:
- The leak happens on construction alone — no
raise/except required.
sys.getrefcount on a fresh instance is normal and gc.get_objects() reports 0 live
instances, so the exception object is freed; it's the attribute storage (__dict__ contents,
i.e. str values) that leaks. Since str/dict-keys aren't tracked by the cyclic GC, the leak is
invisible to gc and tracemalloc; it's visible in RSS / memray / heaptrack.
gc.collect() and malloc_trim(0) do not reclaim it.
Your Environment
- Mypy version used: 2.3.0+dev (current
master); also affects released mypyc
- Mypy command-line flags: compiled via
python -m mypyc leakmod.py
- Python version used: 3.11
- Operating system: Linux (reproduced on x86_64 and aarch64)
Suspected root cause (from reading mypyc/codegen/emitclass.py)
Whether the class gets its own tp_dealloc / tp_clear / tp_traverse is gated on:
# ~line 262
generate_full = not cl.is_trait and not cl.builtin_base
# ~lines 269 and 342
if generate_full or managed_dict:
fields["tp_dealloc"] = ...
fields["tp_traverse"] = ...
fields["tp_clear"] = ...
For a class inheriting Exception, cl.builtin_base == "PyBaseExceptionObject", so generate_full
is False; and has_managed_dict() returns False for PyBaseExceptionObject (~lines 1305-1308,
to avoid the Py_TPFLAGS_MANAGED_DICT / tp_dictoffset conflict). The gate is therefore False, so
mypyc neither generates nor installs its own dealloc/clear/traverse. The type falls back to
BaseException's inherited slots, which don't know about the subclass's __dict__ at the extra
tp_dictoffset (set at ~lines 322-338) — so the subclass's attribute storage is never freed.
Possibly a regression from #21290 ("Generate more type methods for types with managed dicts"), which
reworked the builtin_base dealloc path.
Notes from attempting a fix (to save you a dead end)
Naively enabling generation for builtin_base (or cl.builtin_base on the two gates) segfaults. gdb
points at the generated <Cls>_dealloc on its first PyObject_GC_UnTrack(self):
#0 _PyObject_GC_UNTRACK (op=<AttrErr ...>) at Include/internal/pycore_object.h:172
#1 PyObject_GC_UnTrack at Modules/gcmodule.c:2242
#2 AttrErr_dealloc () at build/__native.c
With the slots installed, the instances end up not GC-tracked (gc.is_tracked(AttrErr("x")) is
False), so the unconditional untrack in the builtin_base dealloc path (~lines 950-951) hits an
untracked object. The generated traverse/clear themselves look correct (they visit/clear the
__dict__ at sizeof(struct)). So a correct fix appears to need consistent
Py_TPFLAGS_HAVE_GC / GC-tracking handling for builtin_base instances, not just emitting the
functions.
Bug Report
(mypyc) A mypyc-compiled class that inherits from
Exception(anyBaseExceptionsubclass) and hasinstance attributes leaks memory on every instantiation — the instance's attribute storage (its
__dict__contents) is never freed. The pure-Python version of the same class does not leak, and abare
Exceptionsubclass with no attributes does not leak. The leak is unbounded, so a long-runningprocess grows in RSS until it OOMs.
Encountered in an open-source project:
sqlglotcompiled via mypyc (sqlglot[c]/ thesqlglotcpackage — https://github.com/tobymao/sqlglot). Its
ParseErroris anExceptionsubclass withattributes, so every raised-and-caught parse error leaks ~1 KB, OOM-ing long-running parsers.
To Reproduce
leakmod.py:Compile with mypyc and run the driver (a mypy-play link isn't applicable — this is a mypyc runtime
issue, not a type-check result):
Expected Behavior
Constructing and discarding exception instances should not accumulate memory — RSS stays flat, as it
does for the pure-Python module.
Actual Behavior
Compiled with mypyc,
AttrErrleaks ~400 bytes per instance (unbounded); pure Python does not:Additional observations:
raise/exceptrequired.sys.getrefcounton a fresh instance is normal andgc.get_objects()reports 0 liveinstances, so the exception object is freed; it's the attribute storage (
__dict__contents,i.e.
strvalues) that leaks. Sincestr/dict-keys aren't tracked by the cyclic GC, the leak isinvisible to
gcandtracemalloc; it's visible in RSS / memray / heaptrack.gc.collect()andmalloc_trim(0)do not reclaim it.Your Environment
master); also affects released mypycpython -m mypyc leakmod.pySuspected root cause (from reading
mypyc/codegen/emitclass.py)Whether the class gets its own
tp_dealloc/tp_clear/tp_traverseis gated on:For a class inheriting
Exception,cl.builtin_base == "PyBaseExceptionObject", sogenerate_fullis
False; andhas_managed_dict()returnsFalseforPyBaseExceptionObject(~lines 1305-1308,to avoid the
Py_TPFLAGS_MANAGED_DICT/tp_dictoffsetconflict). The gate is thereforeFalse, somypyc neither generates nor installs its own dealloc/clear/traverse. The type falls back to
BaseException's inherited slots, which don't know about the subclass's__dict__at the extratp_dictoffset(set at ~lines 322-338) — so the subclass's attribute storage is never freed.Possibly a regression from #21290 ("Generate more type methods for types with managed dicts"), which
reworked the
builtin_basedealloc path.Notes from attempting a fix (to save you a dead end)
Naively enabling generation for
builtin_base(or cl.builtin_baseon the two gates) segfaults. gdbpoints at the generated
<Cls>_deallocon its firstPyObject_GC_UnTrack(self):With the slots installed, the instances end up not GC-tracked (
gc.is_tracked(AttrErr("x"))isFalse), so the unconditional untrack in thebuiltin_basedealloc path (~lines 950-951) hits anuntracked object. The generated
traverse/clearthemselves look correct (they visit/clear the__dict__atsizeof(struct)). So a correct fix appears to need consistentPy_TPFLAGS_HAVE_GC/ GC-tracking handling forbuiltin_baseinstances, not just emitting thefunctions.