Skip to content

Fix a reference leak of a non-Struct base's type dict - #1199

Merged
Siyet merged 7 commits into
mainfrom
fix-type-dict-leak
Sep 29, 2026
Merged

Siyet merged 7 commits into
mainfrom
fix-type-dict-leak

Conversation

@Siyet

@Siyet Siyet commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

On Python 3.12+ MS_GET_TYPE_DICT expands to PyType_GetDict, which returns a new reference; in the comment above the macro the result is described as borrowed. At its only call site, in structmeta_collect_base, there is no matching release on either exit, so defining a Struct with a non-Struct base leaks one reference to that base's type dict. Nothing holds that reference afterwards, so the dict is never freed, and on 3.12 through 3.14 that keeps the base class itself alive along with everything in its namespace, for the rest of the process. typing.Generic counts as such a base, so generic Struct definitions leak one too, in both the explicit and the PEP 695 spelling; there the dict stays reachable regardless, so only the count grows.

Adds a release macro and applies it on both exits. It expands to Py_XDECREF on 3.12+ and to nothing before that, where the tp_dict slot is read directly and the reference is borrowed. Corrects the comment to match. The remaining tp_dict uses in the file are direct slot reads guarded against NULL and need no release.

A base that has not been readied yet, which a C extension can expose, has no type dict, and on main the __init__ and __new__ check crashes on such a base on every supported version. Such a base is now rejected with TypeError before its type dict or inherited slots are read, and TypeError is also raised if a readied base still has no type dict. The check that the base is a type now comes first.

Adds a regression test that the base's contents are released when the base passes the __init__ and __new__ check. On current main it fails on 3.12, 3.13 and 3.14, and passes on 3.10 and 3.11, where the leak does not exist. It also passes on main on 3.15, where the leaked dict no longer keeps the base class reachable, so the class is collected and its contents are released even though the dict itself is still retained.

Fixes #1198.

@sobolevn sobolevn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is correct :)

PyType_GetDict is defined as:

PyObject *
PyType_GetDict(PyTypeObject *self)
{
    PyObject *dict = lookup_tp_dict(self);
    return _Py_XNewRef(dict);
}

So, the result if is not NULL must be decrefed in the end.

But, I think that we miss the == NULL check after the MS_GET_TYPE_DICT call as well. It can be NULL is come rare cases. We must not crash in this case.

CPython does check for NULL:

static int
PyCFuncPtrType_init(PyObject *self, PyObject *args, PyObject *kwds)
{
    PyObject *attrdict = PyType_GetDict((PyTypeObject *)self);
    if (!attrdict) {
        return -1;
    }

And more note: the docs do not explicitly specify: when NULL can happen and if exception is set or not. This can be improved in the CPython repo.

@sobolevn sobolevn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, don't forget about NULL check :)

Comment thread tests/unit/test_struct.py Outdated


@pytest.mark.parametrize("rejected", [False, True])
def test_non_struct_base_type_dict_gains_no_references(rejected):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I advocate not to ever test refcounts :)
refcount is an implementation detail, it can change at almost any place in the future versions / build types.

Especially in such complex cases: a lot of things can go wrong. Types can get extra dict referents, like __annotations__ or something.

Comment thread tests/unit/test_struct.py
assert sys.getrefcount(data) <= 4


def test_struct_definition_does_not_leak_non_struct_base_dict():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test should be enough :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the reference count test. I kept the rejected-base test as well, since it covers the release on the rejection exit, which this one does not.

A base that has not been readied yet, which a C extension can expose,
has neither its type dict nor its inherited slots filled in, and
`PyType_GetDict` returns NULL for it without setting an exception
(before 3.12 the `tp_dict` slot is NULL), which crashed the `__init__`
and `__new__` check. Ready such a base before reading its slots and
dict, as type creation does for its bases, and raise `TypeError` if the
dict is still missing. The check that the base is a type now comes
before any of its slots are read, since only a type can be readied.
Remove the reference count test; the two tests that check the base's
contents stay.
@Siyet

Siyet commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Added the NULL check. PyType_GetDict returns NULL without setting an exception for a type that has not been readied yet, which a C extension can expose, and using such a type as a Struct base crashes on main. Such a base is now readied first, as type creation does for its bases, so its dict and inherited slots are filled in before they are read; if the dict is still NULL, TypeError is raised. A reliable regression test would need a C extension so there is none.

Comment thread src/msgspec/_core.c
info->already_has_dict = true;
}

if (!PyType_Check(base)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unrelated, but still does not look correct.
We first cast object to if ((PyTypeObject *)base and then check that it is a type.

Let's check that base is a type as the first step.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: the type check is now the first step.

Comment thread src/msgspec/_core.c Outdated
* has neither its type dict nor its inherited slots filled in. Ready it
* before reading them, as type creation does for its bases. */
if (!PyType_HasFeature((PyTypeObject *)base, Py_TPFLAGS_READY)) {
if (PyType_Ready((PyTypeObject *)base) < 0) return -1;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that it is correct to run PyType_Ready on types that we don't own. This can cause strange side-effects for users.

Just return -1 right away (with the exception set).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: defining a Struct with an unready base now raises TypeError: Base class ... is not ready right away, and the base is left untouched.

@sobolevn sobolevn mentioned this pull request Sep 29, 2026
Readying a type that belongs to another extension can have side
effects, so raise TypeError for a base that is not ready yet. Check
that the base is a type before anything else.
sobolevn
sobolevn previously approved these changes Sep 29, 2026
Comment thread tests/unit/test_struct.py Outdated
@Siyet
Siyet added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 6d3443f Sep 29, 2026
24 checks passed
@Siyet
Siyet deleted the fix-type-dict-leak branch September 29, 2026 12:40
pull Bot pushed a commit to Mu-L/msgspec that referenced this pull request Sep 29, 2026
Entries for msgspec#1194, msgspec#1196, msgspec#1197, msgspec#1199 and msgspec#1209 are missing from the
changelog of the 0.22.0 release. Of the changes merged after msgspec#1211
moved the unreleased entries into the 0.22.0 section, only msgspec#1207 added
its own entry. This adds the missing entries next to the related
entries in that section, and marks msgspec#1196 as a breaking change: objects
that expose `__dataclass_fields__` only through instance attribute
access are no longer encoded as dataclasses.

Co-authored-by: Tseluiko Aleksandr <4410812+Siyet@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
docs-preview — 701eba61 Deployed Sep 29, 2026 by sobolevn via Deploy preview #574
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Defining a Struct on a non-Struct base leaks that base's type dict

2 participants