Skip to content

[mypyc] Memory leak: native attributes of compiled Exception subclasses are never freed #21716

Description

@treff7es

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugmypy got something wrong

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions