Skip to content

Reject gc=False on struct types with a weakref slot - #1207

Merged
sobolevn merged 8 commits into
mainfrom
fix-gc-false-weakref-crash
Sep 29, 2026
Merged

sobolevn merged 8 commits into
mainfrom
fix-gc-false-weakref-crash

Conversation

@Siyet

@Siyet Siyet commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

On Python 3.12 and later, releasing an instance of a struct type declared with both gc=False and weakref=True goes wrong. On default builds it reads memory outside the instance's allocation, and creating one instance is enough, since the read happens even when no weak reference was ever taken:

import msgspec

class Ex(msgspec.Struct, gc=False, weakref=True):
    x: int

Ex(1)

The process usually crashes. On free-threaded builds there is no immediate crash: the instance is released from the wrong address, the allocator's own state is corrupted, and the failure arrives later and somewhere else.

Since 3.12 the __weakref__ slot is not inside the object. It sits at a fixed negative offset, in the pre-header that is allocated in front of the instance. A gc=False type clears Py_TPFLAGS_HAVE_GC and releases the instance with PyObject_Free on the object itself, so the block is released from the wrong address. Outside free-threaded builds that pre-header is also smaller, which leaves the slot before the start of the allocation.

This rejects the combination at class creation, the way gc=False with dict=True is already rejected. The check also covers the other routes to it: defstruct, inheriting either option from a base class, and a gc=False struct whose base is a plain mixin with __slots__ = ("__weakref__",). The structs guide documents the combination as unsupported, and why.

The restriction is unconditional, so it also applies to 3.10 and 3.11, where the slot is inside the object and the combination works. That is deliberate: the same class definition then behaves the same way on every supported version, and an upgrade of Python cannot turn working code into an error.

An earlier revision of this branch kept such a type GC enabled instead, which also fixed the crash. The discussion below records why the restriction was chosen over it.

Since Python 3.12 the `__weakref__` slot is not inside the object. It sits at a
fixed negative offset, in the pre-header, next to the `PyGC_Head`. Clearing
`Py_TPFLAGS_HAVE_GC` for a `gc=False` type removes that `PyGC_Head` and shrinks
the pre-header, while the slot's offset stays the same, so the slot ends up
just below the allocation.

Keep such a type GC enabled so the offset stays valid, and untrack instances
explicitly in each construction path and on attribute assignment. Python 3.10,
3.11 and free-threaded builds keep the layout they had.
A `__weakref__` slot at a negative offset is in the pre-header on
free-threaded builds as well. Clearing `Py_TPFLAGS_HAVE_GC` there releases
the instance from the wrong address, which corrupts the allocator's state
instead of crashing at once, so the check is on the offset alone.
A `gc=False` struct type whose weakref slot lives in the GC pre-header
stays GC enabled, so its instances start out tracked. Untracking them at
the end of each construction path left a window during `__post_init__`
where `gc.is_tracked` returned True, and a copy taken there inherited
that state. Untrack once in `Struct_alloc` instead, so there is no such
window.

Also add coverage for the deallocation path, which was untested:
releasing such an instance with `PyObject_Free` leaves the young
generation counter climbing. Group the new tests with the other
`gc=False` ones.
@Siyet
Siyet requested a review from sobolevn September 25, 2026 11:16
Comment thread tests/unit/test_struct.py Outdated
if cls.__weakrefoffset__ < 0:
assert cls.__flags__ & Py_TPFLAGS_HAVE_GC
else:
assert not cls.__flags__ & Py_TPFLAGS_HAVE_GC

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.

Suggested change
assert not cls.__flags__ & Py_TPFLAGS_HAVE_GC
assert cls.__flags__ & Py_TPFLAGS_HAVE_GC == 0

Comment thread tests/unit/test_struct.py Outdated
assert cls.__flags__ & Py_TPFLAGS_HAVE_GC
else:
assert not cls.__flags__ & Py_TPFLAGS_HAVE_GC
t = cls([1, 2, 3])

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.

Suggested change
t = cls([1, 2, 3])
inst = cls([1, 2, 3])

t looks like a type variable to me :)

Comment thread tests/unit/test_struct.py Outdated
ref = weakref.ref(Test(1))
assert ref() is None
grew = gc.get_count()[0] - before
assert grew < 100

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 find this test a bit off: why does it use 100 as the threshold? Do we even need to test the GC count here? Why only the youngest generation?

@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.

I asked about this problem several of my collegues who understand gc and weakrefs better than I do :)

It was proposed to ban this combination alltogether. See python/cpython#134786

@provinzkraut

Copy link
Copy Markdown
Member

It was proposed to ban this combination alltogether. See python/cpython#134786

Seems reasonable. Maybe we could do this as well? Simply raise an error when both are being set, and explicitly document it as not supported due to ?

The earlier approach on this branch kept such a type GC enabled so that the
pre-header stayed valid. That fixed the crash, but on 3.12 and later left the
combination with no memory saving and no reduction in collection frequency,
and an instance of a `gc=False` class could still end up tracked through
`__class__` assignment or through resurrection from `__del__`.

Raise `ValueError` at class creation instead, in the same place as the
existing rejection of `gc=False` with `dict=True`. The check also covers a
weakref slot inherited from a base class.
@Siyet Siyet changed the title Fix a crash on Python 3.12+ for gc=False structs with a weakref slot Reject gc=False on struct types with a weakref slot Sep 27, 2026
@Siyet

Siyet commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Done, thanks for digging into this and for asking around. The structs guide documents it as unsupported, and why.

I had argued against the ban in the first version of the description, and one finding still cuts that way: the combination is not inert on 3.12+, since untracked instances keep each collection cheaper. But it saves no memory. An instance of a gc=False class could also end up tracked, through __class__ assignment and through resurrection from __del__.

The in-object slot that python/cpython#134786 suggests would not cover every route: an inherited weakref slot keeps the offset fixed by its base class.

I threw a pile of AI agents at GitHub code search and found no one using both options together outside this repository. That guarantees nothing, of course, but it is mildly reassuring.

@sobolevn your three comments were on tests this revision removes, so I have not carried them over.

Comment thread tests/unit/test_struct.py Outdated
class Test(Base, **opts):
pass

@pytest.mark.parametrize(

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.

If you have to parametrize cases, we can just make different tests :)

And just make the assert_whatever method to unify the check code.

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: each case is now its own test, and the check is in a shared assert_gc_false_with_weakref_rejected helper.

Comment thread src/msgspec/_core.c Outdated
goto cleanup;
}
else if (info.weakref == OPT_TRUE || info.already_has_weakref) {
PyErr_SetString(PyExc_ValueError, "Cannot set gc=False and weakref=True");

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 suggest using

`gc=False` and `weakref=True`

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.

adding a small qualifier why (something that says they interfere with each other) would also help. Cannot set gc=False and weakref=True might be interpreted that, for some reason, those two attributes are not settable, but it's unclear why.

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.

Updated both: the options are in backticks now, and the mesage includes the reason:

Cannot set `gc=False` and `weakref=True`: on Python 3.12+ the weakref slot is stored in a pre-header in front of the instance, and releasing a `gc=False` instance with that slot corrupts memory

Quote the options in the message and state why they cannot be combined.
In the message, the structs guide and the changelog, describe the
weakref slot as stored in a pre-header in front of the instance, since
on free-threaded builds that pre-header holds no GC header. Replace the
parametrized test with one test per route, with the check in a shared
helper.
@Siyet

Siyet commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

@sobolevn @provinzkraut, the trusted publisher is configured now (#1134), so 0.22.0 can be released. Since this PR is a breaking change I'd like to land it before we cut 0.22.0 rather than leave it for the next minor release. It should affect almost no one: I found no public project on GitHub that uses msgspec with both gc=False and weakref=True, and on 3.12+ releasing an instance of such a class already corrupts memory. The latest review comments are addressed. Could you take another look?

Comment thread src/msgspec/_core.c Outdated
else if (info.weakref == OPT_TRUE || info.already_has_weakref) {
PyErr_SetString(
PyExc_ValueError,
"Cannot set `gc=False` and `weakref=True`: on Python 3.12+ the "

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.

sorry, my suggestion was incorrect, no other error messages use backticks. the reason also seems a bit too verbose. I propose this wording:

Cannot set gc=False and weakref=True due to possible memory corruption

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 would still prefer something like Cannot set gc=False and weakref=True at the same time, due to possible memory corruption or Cannot set gc=False while also setting weakref=True, due to possible memory corruption, just to be very explicit that it's an issue with setting both at the same time.

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.

Changed it to Cannot set gc=False and weakref=True at the same time, due to possible memory corruption: no backticks, a short reason instead of the long one, and "at the same time" to make it clear that the combination is the problem.

Comment thread tests/unit/test_struct.py Outdated
assert not gc.is_tracked(NoGC([1]))
assert not gc.is_tracked(SubNoGC([1], [2]))

obj = WeakRef(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.

assert that it is tracked with the gc :)

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. An instance that holds only an int is not tracked, so the field is a list now, and there is an assertion that the instance is tracked.

Comment thread tests/unit/test_struct.py Outdated
class SubNoGC(NoGC, gc=False):
y: list

class WeakRef(Struct, weakref=True):

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.

Suggested change
class WeakRef(Struct, weakref=True):
class WithWeakRef(Struct, weakref=True):

I was a bit confused that WeakRef is a struct at first :)

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.

Renamed, thx. It reads beter this way.

Siyet and others added 2 commits September 29, 2026 09:46
Drop the backticks, as in the nearby `gc=False` messages, replace the
detailed reason with a short one and state that the two options cannot
be set at the same time. In the test for each option on its own, rename
the weakref struct, give it a `list` field and check that its instance
is tracked, since an instance that holds only an `int` is not.

@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.

Thank you!

@sobolevn
sobolevn added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit b2d1b7a Sep 29, 2026
24 checks passed
@sobolevn
sobolevn deleted the fix-gc-false-weakref-crash branch September 29, 2026 07:26
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 <[email protected]>
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.

3 participants