Skip to content

Commit c56ab58

Browse files
authored
[mypyc] Generate more type methods for types with managed dicts (#21290)
Fixes #21133 Types with the `Py_TPFLAGS_MANAGED_DICT` flag must call `PyObject_VisitManagedDict` and `PyObject_ClearManagedDict` in their `tp_traverse` / `tp_clear` functions according to [docs](https://docs.python.org/3/c-api/typeobj.html#c.Py_TPFLAGS_MANAGED_DICT) but the types generated by mypyc currently don't do this. We don't generate these functions at all so they get inherited from the base class. Failure to call these functions may result in a segfault in python 3.14 when accessing the managed dict after its owner has been deallocated. I believe the crash happens because the logic for types with the `Py_TPFLAGS_INLINE_VALUES` flag in [`PyObject_ClearManagedDict`](https://github.com/python/cpython/blob/main/Objects/dictobject.c#L7803) is not run. The condition to add this flag has changed in [3.14](https://github.com/python/cpython/blob/3.14/Objects/typeobject.c#L8877) vs [3.13](https://github.com/python/cpython/blob/3.13/Objects/typeobject.c#L8171) so generated types with `Py_TPFLAGS_MANAGED_DICT` get it in 3.14. To fix, generate `tp_clear`, `tp_traverse`, and `tp_dealloc` for types with managed dicts. Also add a special case in these functions for classes with built-in bases to call the pointer of the base class.
1 parent 6e608f2 commit c56ab58

8 files changed

Lines changed: 245 additions & 40 deletions

File tree

mypyc/codegen/emit.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1398,6 +1398,12 @@ def emit_cpyfunction_instance(
13981398
self.emit_line(error_stmt)
13991399
return wrapper_name
14001400

1401+
def emit_base_tp_function_call(
1402+
self, derived_cl: ClassIR, tp_func: str, args: str, *, prefix: str = ""
1403+
) -> None:
1404+
type_obj = self.type_struct_name(derived_cl)
1405+
self.emit_line(f"{prefix}{type_obj}->tp_base->{tp_func}({args});")
1406+
14011407

14021408
def c_array_initializer(components: list[str], *, indented: bool = False) -> str:
14031409
"""Construct an initializer for a C array variable.

mypyc/codegen/emitclass.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,8 @@ def generate_class(cl: ClassIR, module: str, emitter: Emitter) -> None:
262262
if not cl.builtin_base:
263263
fields["tp_new"] = new_name
264264

265-
if generate_full:
265+
managed_dict = has_managed_dict(cl, emitter)
266+
if generate_full or managed_dict:
266267
fields["tp_dealloc"] = f"(destructor){name_prefix}_dealloc"
267268
if not cl.is_acyclic:
268269
fields["tp_traverse"] = f"(traverseproc){name_prefix}_traverse"
@@ -335,6 +336,14 @@ def emit_line() -> None:
335336
else:
336337
fields["tp_basicsize"] = base_size
337338

339+
if generate_full or managed_dict:
340+
if not cl.is_acyclic:
341+
generate_traverse_for_class(cl, traverse_name, emitter)
342+
emit_line()
343+
generate_clear_for_class(cl, clear_name, emitter)
344+
emit_line()
345+
generate_dealloc_for_class(cl, dealloc_name, clear_name, bool(del_method), emitter)
346+
emit_line()
338347
if generate_full:
339348
assert cl.setup is not None
340349
emitter.emit_line(native_function_header(cl.setup, emitter) + ";")
@@ -345,13 +354,6 @@ def emit_line() -> None:
345354
init_fn = cl.get_method("__init__")
346355
generate_new_for_class(cl, new_name, vtable_name, setup_name, init_fn, emitter)
347356
emit_line()
348-
if not cl.is_acyclic:
349-
generate_traverse_for_class(cl, traverse_name, emitter)
350-
emit_line()
351-
generate_clear_for_class(cl, clear_name, emitter)
352-
emit_line()
353-
generate_dealloc_for_class(cl, dealloc_name, clear_name, bool(del_method), emitter)
354-
emit_line()
355357

356358
if cl.allow_interpreted_subclasses:
357359
shadow_vtable_name: str | None = generate_vtables(
@@ -380,7 +382,7 @@ def emit_line() -> None:
380382
emit_line()
381383

382384
flags = ["Py_TPFLAGS_DEFAULT", "Py_TPFLAGS_HEAPTYPE", "Py_TPFLAGS_BASETYPE"]
383-
if generate_full and not cl.is_acyclic:
385+
if (generate_full or managed_dict) and not cl.is_acyclic:
384386
flags.append("Py_TPFLAGS_HAVE_GC")
385387
if cl.has_method("__call__"):
386388
fields["tp_vectorcall_offset"] = "offsetof({}, vectorcall)".format(
@@ -391,7 +393,7 @@ def emit_line() -> None:
391393
# This is just a placeholder to please CPython. It will be
392394
# overridden during setup.
393395
fields["tp_call"] = "PyVectorcall_Call"
394-
if has_managed_dict(cl, emitter):
396+
if managed_dict:
395397
flags.append("Py_TPFLAGS_MANAGED_DICT")
396398
fields["tp_flags"] = " | ".join(flags)
397399

@@ -868,8 +870,14 @@ def generate_traverse_for_class(cl: ClassIR, func_name: str, emitter: Emitter) -
868870
for base in reversed(cl.base_mro):
869871
for attr, rtype in base.attributes.items():
870872
emitter.emit_gc_visit(f"self->{emitter.attr(attr)}", rtype)
873+
base_args = "(PyObject *)self, visit, arg"
874+
emitter.emit_line("int rv = 0;")
875+
if cl.builtin_base:
876+
emitter.emit_base_tp_function_call(cl, "tp_traverse", base_args, prefix="rv = ")
877+
emitter.emit_line("if (rv != 0) return rv;")
871878
if has_managed_dict(cl, emitter):
872-
emitter.emit_line("PyObject_VisitManagedDict((PyObject *)self, visit, arg);")
879+
emitter.emit_line(f"rv = PyObject_VisitManagedDict({base_args});")
880+
emitter.emit_line("if (rv != 0) return rv;")
873881
elif cl.has_dict:
874882
struct_name = cl.struct_name(emitter.names)
875883
# __dict__ lives right after the struct and __weakref__ lives right after that
@@ -880,7 +888,7 @@ def generate_traverse_for_class(cl: ClassIR, func_name: str, emitter: Emitter) -
880888
f"*((PyObject **)((char *)self + sizeof(PyObject *) + sizeof({struct_name})))",
881889
object_rprimitive,
882890
)
883-
emitter.emit_line("return 0;")
891+
emitter.emit_line("return rv;")
884892
emitter.emit_line("}")
885893

886894

@@ -891,8 +899,11 @@ def generate_clear_for_class(cl: ClassIR, func_name: str, emitter: Emitter) -> N
891899
for base in reversed(cl.base_mro):
892900
for attr, rtype in base.attributes.items():
893901
emitter.emit_gc_clear(f"self->{emitter.attr(attr)}", rtype)
902+
base_args = "(PyObject *)self"
903+
if cl.builtin_base:
904+
emitter.emit_base_tp_function_call(cl, "tp_clear", base_args)
894905
if has_managed_dict(cl, emitter):
895-
emitter.emit_line("PyObject_ClearManagedDict((PyObject *)self);")
906+
emitter.emit_line(f"PyObject_ClearManagedDict({base_args});")
896907
elif cl.has_dict:
897908
struct_name = cl.struct_name(emitter.names)
898909
# __dict__ lives right after the struct and __weakref__ lives right after that
@@ -936,6 +947,18 @@ def generate_dealloc_for_class(
936947
emitter.emit_line("}")
937948
if not cl.is_acyclic:
938949
emitter.emit_line("PyObject_GC_UnTrack(self);")
950+
if cl.builtin_base:
951+
emitter.emit_line(f"{clear_func_name}(self);")
952+
# For native subclasses of builtins such as dict, the base deallocator
953+
# is responsible for tearing down base-owned storage and freeing memory.
954+
# Re-track self if base is GC-aware to match cpython's subtype_dealloc.
955+
base = f"{emitter.type_struct_name(cl)}->tp_base"
956+
base_arg = "(PyObject *)self"
957+
emitter.emit_line(f"if (PyType_IS_GC({base})) PyObject_GC_Track({base_arg});")
958+
emitter.emit_base_tp_function_call(cl, "tp_dealloc", base_arg)
959+
emitter.emit_line("done: ;")
960+
emitter.emit_line("}")
961+
return
939962
if cl.reuse_freed_instance:
940963
emit_reuse_dealloc(cl, emitter)
941964
# The trashcan is needed to handle deep recursive deallocations

mypyc/irbuild/prepare.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -424,22 +424,6 @@ def prepare_class_def(
424424
if attrs.get("acyclic") is True:
425425
ir.is_acyclic = True
426426

427-
free_list_len = attrs.get("free_list_len")
428-
if free_list_len is not None:
429-
line = attrs_lines["free_list_len"]
430-
if ir.is_trait:
431-
errors.error('"free_list_len" can\'t be used with traits', path, line)
432-
if ir.allow_interpreted_subclasses:
433-
errors.error(
434-
'"free_list_len" can\'t be used in a class that allows interpreted subclasses',
435-
path,
436-
line,
437-
)
438-
if free_list_len == 1:
439-
ir.reuse_freed_instance = True
440-
else:
441-
errors.error(f'Unsupported value for "free_list_len": {free_list_len}', path, line)
442-
443427
# Check for subclassing from builtin types
444428
for cls in info.mro:
445429
# Special case exceptions and dicts
@@ -468,6 +452,28 @@ def prepare_class_def(
468452
cdef.line,
469453
)
470454

455+
free_list_len = attrs.get("free_list_len")
456+
if free_list_len is not None:
457+
line = attrs_lines["free_list_len"]
458+
if ir.is_trait:
459+
errors.error('"free_list_len" can\'t be used with traits', path, line)
460+
if ir.allow_interpreted_subclasses:
461+
errors.error(
462+
'"free_list_len" can\'t be used in a class that allows interpreted subclasses',
463+
path,
464+
line,
465+
)
466+
if ir.builtin_base:
467+
errors.error(
468+
'"free_list_len" can\'t be used in a class that inherits from a built-in type',
469+
path,
470+
line,
471+
)
472+
if free_list_len == 1:
473+
ir.reuse_freed_instance = True
474+
else:
475+
errors.error(f'Unsupported value for "free_list_len": {free_list_len}', path, line)
476+
471477
# Set up the parent class
472478
bases = [mapper.type_to_ir[base.type] for base in info.bases if base.type in mapper.type_to_ir]
473479
if len(bases) > 1 and any(not c.is_trait for c in bases) and bases[0].is_trait:

mypyc/lib-rt/pythoncapi_compat.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,8 @@ PyObject_VisitManagedDict(PyObject *obj, visitproc visit, void *arg)
922922
{
923923
PyObject **dict = _PyObject_GetDictPtr(obj);
924924
if (dict == NULL || *dict == NULL) {
925-
return -1;
925+
// Nothing to do.
926+
return 0;
926927
}
927928
Py_VISIT(*dict);
928929
return 0;

mypyc/test-data/fixtures/ir.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def __pow__(self, other: T_contra, modulo: _M) -> T_co: ...
4040

4141
class object:
4242
__class__: type
43+
__dict__: dict[str, Any]
4344
def __new__(cls) -> Self: pass
4445
def __init__(self) -> None: pass
4546
def __init_subclass__(cls, **kwargs: object) -> None: pass

mypyc/test-data/irbuild-classes.test

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2138,6 +2138,10 @@ class NonNative:
21382138
class InterpSub:
21392139
pass
21402140

2141+
@mypyc_attr(free_list_len=1) # E: "free_list_len" can't be used in a class that inherits from a built-in type
2142+
class InheritsBuiltIn(dict):
2143+
pass
2144+
21412145
[case testAcyclicClassRequiresAcyclicBases]
21422146
from typing import Generic, TypeVar
21432147
from mypy_extensions import mypyc_attr, trait

mypyc/test-data/run-classes.test

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3325,20 +3325,39 @@ def test_function():
33253325
assert(isinstance(d.fitem, ForwardDefinedClass))
33263326
assert(isinstance(d.fitems, ForwardDefinedClass))
33273327

3328-
[case testDelForDictSubclass-xfail]
3329-
# The crash in issue mypy#19175 is fixed.
3330-
# But, for classes that derive from built-in Python classes, user-defined __del__ method is not
3331-
# being invoked.
3328+
[case testDelForDictSubclass]
3329+
events: list[str] = []
3330+
3331+
class Item:
3332+
def __del__(self) -> None:
3333+
events.append("deleting Item")
3334+
33323335
class DictSubclass(dict):
3333-
def __del__(self):
3334-
print("deleting DictSubclass...")
3336+
def __del__(self) -> None:
3337+
events.append("deleting DictSubclass")
3338+
3339+
def test_dict_subclass_dealloc() -> None:
3340+
d = DictSubclass()
3341+
d["item"] = Item()
3342+
del d
33353343

33363344
[file driver.py]
3337-
import native
3338-
native.DictSubclass()
3345+
import sys
33393346

3340-
[out]
3341-
deleting DictSubclass...
3347+
from native import events, test_dict_subclass_dealloc
3348+
3349+
test_dict_subclass_dealloc()
3350+
3351+
expected_events: list[str] = []
3352+
3353+
# TODO: Fix when compiling for older python.
3354+
# The user-defined __del__ method is currently only invoked when __dict__ is a managed dict
3355+
# because calling __del__ in tp_clear on older python crashes.
3356+
if sys.version_info >= (3, 12):
3357+
expected_events.append("deleting DictSubclass")
3358+
expected_events.append("deleting Item")
3359+
3360+
assert events == expected_events, events
33423361

33433362
[case testDel]
33443363
class A:

0 commit comments

Comments
 (0)