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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugmypy got something wrong

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions