diff --git a/CHANGELOG.md b/CHANGELOG.md index 02739ef6591fc..efe63effbfb4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,122 @@ ## Next Release +## Mypy 2.3 + +We've just uploaded mypy 2.3.0 to the Python Package Index ([PyPI](https://pypi.org/project/mypy/)). +Mypy is a static type checker for Python. This release includes new features, performance +improvements and bug fixes. You can install it as follows: + + python3 -m pip install -U mypy + +You can read the full documentation for this release on [Read the Docs](http://mypy.readthedocs.io). + +### The Upcoming Switch to the New Native Parser + +We are planning to enable the new native parser (`--native-parser`) by +default soon. We recommend that you test the native parser in your projects and report +any issues in the [mypy issue tracker](https://github.com/python/mypy/issues). + +### Mypyc Free-threading Memory Safety + +Free-threaded Python builds that don't have the GIL require additional synchronization +primitives or lock-free algorithms to ensure memory safety when there are race conditions +(for example, when a thread reads a list item while another thread writes the same list +item concurrently). This release greatly improves memory safety of free threading. + +List operations are now memory-safe on free threaded Python builds, even in the presence of +race conditions. This has some performance cost. For list-heavy workloads, using +`librt.vecs.vec` instead of list is often significantly faster, but note that `vec` is not +(and likely won't be) fully memory safe, and the user is expected to avoid race conditions. +The newly introduced `librt.threading.Lock` helps with this. Using variable-length tuples +can also be more efficient than lists, since tuples are immutable and don't require +expensive synchronization to ensure memory safety. + +Instance attribute access is also (mostly) memory safe now on free-threaded builds in +the presence of race conditions. We are planning to fix the remaining unsafe cases in a +future release. + +Full list of changes: + +- Make attribute access memory safe on free-threaded builds (Jukka Lehtosalo, PR [21705](https://github.com/python/mypy/pull/21705)) +- Fix unsafe borrowing of instance attributes with free-threading (Jukka Lehtosalo, PR [21688](https://github.com/python/mypy/pull/21688)) +- Make list get/set item more memory safe on free-threaded builds (Jukka Lehtosalo, PR [21683](https://github.com/python/mypy/pull/21683)) +- Don't borrow list items on free-threaded builds (Jukka Lehtosalo, PR [21679](https://github.com/python/mypy/pull/21679)) +- Make multiple assignment from list memory-safe on free-threaded builds (Jukka Lehtosalo, PR [21684](https://github.com/python/mypy/pull/21684)) +- Make `for` loop over list memory-safe on free-threaded builds (Jukka Lehtosalo, PR [21686](https://github.com/python/mypy/pull/21686)) +- Fix memory safety of `list.count` on free-threaded builds (Jukka Lehtosalo, PR [21680](https://github.com/python/mypy/pull/21680)) +- Make `vec` creation from list memory safe on free-threaded builds (Jukka Lehtosalo, PR [21681](https://github.com/python/mypy/pull/21681)) + +### librt.threading: Fast Native Lock Type + +Mypyc now supports `librt.threading.Lock`, which is a lock type optimized for use +in compiled code. It can be 2x to 4x faster than `threading.Lock`. + +This feature was contributed by Jukka Lehtosalo (PR [21690](https://github.com/python/mypy/pull/21690), PR [21697](https://github.com/python/mypy/pull/21697)). + +### Mypyc: Read-only Final Instance Attributes + +Instance attributes of native classes declared as `Final` are now read-only at runtime. +This enables additional optimizations, and it's now recommended to use `Final` for +all performance-sensitive attributes when feasible. + +Related changes: + +- Make instance attribute read-only at runtime if `Final` (Jukka Lehtosalo, PR [21666](https://github.com/python/mypy/pull/21666)) +- Borrow final attributes more aggressively (Jukka Lehtosalo, PR [21702](https://github.com/python/mypy/pull/21702)) +- Improve documentation of `Final` in mypyc (Jukka Lehtosalo, PR [21713](https://github.com/python/mypy/pull/21713)) + +### Mypyc Documentation Updates + +- Update documentation of race conditions under free threading (Jukka Lehtosalo, PR [21726](https://github.com/python/mypy/pull/21726)) +- Update mypyc free threading Python compatibility docs (Jukka Lehtosalo, PR [21711](https://github.com/python/mypy/pull/21711)) +- Document recent additions to `librt.strings`, such as `ispace` (Jukka Lehtosalo, PR [21696](https://github.com/python/mypy/pull/21696)) + +### Miscellaneous Mypyc Improvements + +- Fix reference leak when setting unboxed refcounted attributes (Tom Bannink, PR [21657](https://github.com/python/mypy/pull/21657)) +- Fix function wrapper memory leak (Piotr Sawicki, PR [21654](https://github.com/python/mypy/pull/21654)) +- Fix handling of invalid codepoint values in `librt.strings` (Jukka Lehtosalo, PR [21634](https://github.com/python/mypy/pull/21634)) +- Fix non-deterministic ordering of spilled registers (Jukka Lehtosalo, PR [21632](https://github.com/python/mypy/pull/21632)) +- Fix non-deterministic compiler output due to frozensets (Jukka Lehtosalo, PR [21631](https://github.com/python/mypy/pull/21631)) + +### Changes to Messages + +- Fix error code of note about unbound type variable (Jukka Lehtosalo, PR [21668](https://github.com/python/mypy/pull/21668)) + +### Other Notable Fixes and Improvements + +- Use `PYODIDE` environment variable for Emscripten cross-compilation detection (Agriya Khetarpal, PR [21714](https://github.com/python/mypy/pull/21714)) +- Narrow for frozendict membership check (Shantanu, PR [21709](https://github.com/python/mypy/pull/21709)) +- Fix custom equality handling for membership narrowing in static containers (Shantanu, PR [21706](https://github.com/python/mypy/pull/21706)) +- Infer `Coroutine` for unannotated async functions (Jingchen Ye, PR [21651](https://github.com/python/mypy/pull/21651)) +- Fix variance inference issues caused by dataclass replace (Shantanu, PR [21694](https://github.com/python/mypy/pull/21694)) +- Fix regression in dataclass narrowing for Python >= 3.13 (ygale, PR [21675](https://github.com/python/mypy/pull/21675)) +- Fix star import dependencies in mypy daemon (Jukka Lehtosalo, PR [21673](https://github.com/python/mypy/pull/21673)) +- Fix skipped imports considered stale (Piotr Sawicki, PR [21639](https://github.com/python/mypy/pull/21639)) +- Support `.ff` files with `--cache-map` (Jukka Lehtosalo, PR [21633](https://github.com/python/mypy/pull/21633)) + +### Typeshed Updates + +Please see [git log](https://github.com/python/typeshed/commits/main?after=f76037a1eb3923c67a8bc0e302ee9c016ffb3431+0&branch=main&path=stdlib) for full list of standard library typeshed stub changes. + +### Acknowledgements + +Thanks to all mypy contributors who contributed to this release: + +- Agriya Khetarpal +- Ethan Sarp +- Ivan Levkivskyi +- Jingchen Ye +- Jukka Lehtosalo +- Piotr Sawicki +- Shantanu +- Tom Bannink +- Viktor Szépe +- ygale + +I'd also like to thank my employer, Dropbox, for supporting mypy development. + ## Mypy 2.2 We've just uploaded mypy 2.2.0 to the Python Package Index ([PyPI](https://pypi.org/project/mypy/)). diff --git a/mypy/checker.py b/mypy/checker.py index 612d2face5b8a..122a7512b44c4 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -4408,8 +4408,10 @@ def check_multi_assignment_from_tuple( # inferred return type for an overloaded function # to be ambiguous. return - assert isinstance(reinferred_rvalue_type, TupleType) - rvalue_type = reinferred_rvalue_type + if isinstance(reinferred_rvalue_type, TupleType): + # This branch will usually be taken, but in some cases context can + # e.g. select a different overload + rvalue_type = reinferred_rvalue_type left_rv_types, star_rv_types, right_rv_types = self.split_around_star( rvalue_type.items, star_index, len(lvalues) diff --git a/mypy/version.py b/mypy/version.py index 13a4418314c25..b4d4e0d35d054 100644 --- a/mypy/version.py +++ b/mypy/version.py @@ -8,7 +8,7 @@ # - Release versions have the form "1.2.3". # - Dev versions have the form "1.2.3+dev" (PLUS sign to conform to PEP 440). # - Before 1.0 we had the form "0.NNN". -__version__ = "2.3.0+dev" +__version__ = "2.3.1" base_version = __version__ mypy_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) diff --git a/mypyc/codegen/emitclass.py b/mypyc/codegen/emitclass.py index 9baaec06a4611..aeea3374353f9 100644 --- a/mypyc/codegen/emitclass.py +++ b/mypyc/codegen/emitclass.py @@ -29,6 +29,7 @@ BITMAP_BITS, BITMAP_TYPE, CPYFUNCTION_NAME, + IS_FREE_THREADED, MYPYC_DEFAULTS_SETUP, NATIVE_PREFIX, PREFIX, @@ -43,7 +44,7 @@ FuncIR, get_text_signature, ) -from mypyc.ir.rtypes import RTuple, RType, object_rprimitive +from mypyc.ir.rtypes import RTuple, RType, is_simple_refcounted_pointer, object_rprimitive from mypyc.namegen import NameGenerator from mypyc.sametype import is_same_type @@ -251,7 +252,7 @@ def generate_class(cl: ClassIR, module: str, emitter: Emitter) -> None: getseters_name = f"{name_prefix}_getseters" vtable_name = f"{name_prefix}_vtable" traverse_name = f"{name_prefix}_traverse" - clear_name = f"{name_prefix}_clear" + clear_name = emitter.native_function_name(cl.clear) dealloc_name = f"{name_prefix}_dealloc" methods_name = f"{name_prefix}_methods" vtable_setup_name = f"{name_prefix}_trait_vtable_setup" @@ -270,7 +271,7 @@ def generate_class(cl: ClassIR, module: str, emitter: Emitter) -> None: fields["tp_dealloc"] = f"(destructor){name_prefix}_dealloc" if not cl.is_acyclic: fields["tp_traverse"] = f"(traverseproc){name_prefix}_traverse" - fields["tp_clear"] = f"(inquiry){name_prefix}_clear" + fields["tp_clear"] = f"(inquiry){clear_name}" # Populate .tp_finalize and generate a finalize method only if __del__ is defined for this class. del_method = next((e.method for e in cl.vtable_entries if e.name == "__del__"), None) if del_method: @@ -343,7 +344,11 @@ def emit_line() -> None: if not cl.is_acyclic: generate_traverse_for_class(cl, traverse_name, emitter) emit_line() - generate_clear_for_class(cl, clear_name, emitter) + generate_clear_for_class(cl, cl.clear, emitter) + emit_line() + generate_clear_for_class( + cl, cl.clear_on_completion, emitter, skip_attrs=cl.attrs_to_keep_alive_on_completion + ) emit_line() generate_dealloc_for_class(cl, dealloc_name, clear_name, bool(del_method), emitter) emit_line() @@ -894,12 +899,21 @@ def generate_traverse_for_class(cl: ClassIR, func_name: str, emitter: Emitter) - emitter.emit_line("}") -def generate_clear_for_class(cl: ClassIR, func_name: str, emitter: Emitter) -> None: - emitter.emit_line("static int") - emitter.emit_line(f"{func_name}({cl.struct_name(emitter.names)} *self)") +def generate_clear_for_class( + cl: ClassIR, func_decl: FuncDecl, emitter: Emitter, skip_attrs: set[str] | None = None +) -> None: + if skip_attrs is None: + skip_attrs = set() + emitter.emit_line("static " + native_function_header(func_decl, emitter)) emitter.emit_line("{") + emitter.emit_line( + f"{cl.struct_name(emitter.names)} *self = " + f"({cl.struct_name(emitter.names)} *)cpy_r_self;" + ) for base in reversed(cl.base_mro): for attr, rtype in base.attributes.items(): + if attr in skip_attrs: + continue emitter.emit_gc_clear(f"self->{emitter.attr(attr)}", rtype) base_args = "(PyObject *)self" if cl.builtin_base: @@ -950,7 +964,7 @@ def generate_dealloc_for_class( if not cl.is_acyclic: emitter.emit_line("PyObject_GC_UnTrack(self);") if cl.builtin_base: - emitter.emit_line(f"{clear_func_name}(self);") + emitter.emit_line(f"{clear_func_name}((PyObject *)self);") # For native subclasses of builtins such as dict, the base deallocator # is responsible for tearing down base-owned storage and freeing memory. # Re-track self if base is GC-aware to match cpython's subtype_dealloc. @@ -965,7 +979,7 @@ def generate_dealloc_for_class( emit_reuse_dealloc(cl, emitter) # The trashcan is needed to handle deep recursive deallocations emitter.emit_line(f"CPy_TRASHCAN_BEGIN(self, {dealloc_func_name})") - emitter.emit_line(f"{clear_func_name}(self);") + emitter.emit_line(f"{clear_func_name}((PyObject *)self);") emitter.emit_line("Py_TYPE(self)->tp_free((PyObject *)self);") emitter.emit_line("CPy_TRASHCAN_END(self)") emitter.emit_line("done: ;") @@ -1165,6 +1179,32 @@ def generate_getter(cl: ClassIR, attr: str, rtype: RType, emitter: Emitter) -> N emitter.emit_line("{") attr_expr = f"self->{attr_field}" + if IS_FREE_THREADED and is_simple_refcounted_pointer(rtype): + # In free-threaded builds, load the attribute and take a new reference + # atomically to avoid a use-after-free race with a concurrent setter. + # CPy_GetAttrRef returns NULL if the attribute is undefined (NULL field), + # which is exactly the error/undefined value for a 'PyObject *' field. + # + # Final attributes are never rebound (no setter), so there is no concurrent + # writer to race with: a plain load + incref is safe. Use the cheaper + # CPy_GetAttrRefFinal, which skips the try-incref and _Py_NewRefWithLock + # slow path entirely (an unconditional Py_INCREF needs no maybe-weakref). + # This getter is generated per defining class, so a direct membership test + # matches the read-only getset table above (no need to walk the MRO). + if attr in cl.final_attributes: + getattr_ref = f"CPy_GetAttrRefFinal((PyObject **)&{attr_expr})" + else: + getattr_ref = f"CPy_GetAttrRef((PyObject **)&{attr_expr})" + emitter.emit_line(f"PyObject *retval = {getattr_ref};") + emitter.emit_line("if (unlikely(retval == NULL)) {") + emitter.emit_line("PyErr_SetString(PyExc_AttributeError,") + emitter.emit_line(f' "attribute {repr(attr)} of {repr(cl.name)} undefined");') + emitter.emit_line("return NULL;") + emitter.emit_line("}") + emitter.emit_line("return retval;") + emitter.emit_line("}") + return + # HACK: Don't consider refcounted values as always defined, since it's possible to # access uninitialized values via 'gc.get_objects()'. Accessing non-refcounted # values is benign. @@ -1202,6 +1242,30 @@ def generate_setter(cl: ClassIR, attr: str, rtype: RType, emitter: Emitter) -> N emitter.emit_line("return -1;") emitter.emit_line("}") + if IS_FREE_THREADED and is_simple_refcounted_pointer(rtype): + # In free-threaded builds, publish the new value atomically via + # CPy_SetAttrRef so a concurrent reader (see CPy_GetAttrRef) never sees a + # torn pointer or a freed old value. CPy_SetAttrRef steals its value and + # reclaims the old one, so we cast/type-check the incoming value, take a + # new reference (the setter only borrows 'value'), then hand it over. + # A NULL value deletes the attribute (reclaims the old value, stores NULL). + if deletable: + emitter.emit_line("if (value != NULL) {") + if is_same_type(rtype, object_rprimitive): + emitter.emit_line("PyObject *tmp = value;") + else: + emitter.emit_cast("value", "tmp", rtype, declare_dest=True) + emitter.emit_lines("if (!tmp)", " return -1;") + emitter.emit_inc_ref("tmp", rtype) + emitter.emit_line(f"CPy_SetAttrRef((PyObject **)&self->{attr_field}, tmp);") + if deletable: + emitter.emit_line("} else {") + emitter.emit_line(f"CPy_SetAttrRef((PyObject **)&self->{attr_field}, NULL);") + emitter.emit_line("}") + emitter.emit_line("return 0;") + emitter.emit_line("}") + return + # HACK: Don't consider refcounted values as always defined, since it's possible to # access uninitialized values via 'gc.get_objects()'. Accessing non-refcounted # values is benign. diff --git a/mypyc/codegen/emitfunc.py b/mypyc/codegen/emitfunc.py index dcb606f6ab51b..4c3c17d047c8a 100644 --- a/mypyc/codegen/emitfunc.py +++ b/mypyc/codegen/emitfunc.py @@ -12,7 +12,13 @@ TracebackAndGotoHandler, c_array_initializer, ) -from mypyc.common import GENERATOR_ATTRIBUTE_PREFIX, HAVE_IMMORTAL, NATIVE_PREFIX, REG_PREFIX +from mypyc.common import ( + GENERATOR_ATTRIBUTE_PREFIX, + HAVE_IMMORTAL, + IS_FREE_THREADED, + NATIVE_PREFIX, + REG_PREFIX, +) from mypyc.ir.class_ir import ClassIR from mypyc.ir.func_ir import FUNC_CLASSMETHOD, FUNC_STATICMETHOD, FuncDecl, FuncIR, all_values from mypyc.ir.ops import ( @@ -83,6 +89,7 @@ is_int_rprimitive, is_none_rprimitive, is_pointer_rprimitive, + is_simple_refcounted_pointer, is_tagged, ) @@ -394,6 +401,35 @@ def get_attr_expr(self, obj: str, op: GetAttr | SetAttr, decl_cl: ClassIR) -> st cast = f"({decl_cl.struct_name(self.emitter.names)} *)" return f"({cast}{obj})->{self.emitter.attr(op.attr)}" + def emit_load_attr_take_ref( + self, dest: str, obj: str, op: GetAttr, cl: ClassIR, attr_rtype: RType, attr_expr: str + ) -> bool: + """Emit the load of a native attribute into 'dest', taking a new reference. + + On free-threaded builds, reading a single reference-counted 'PyObject *' field + and taking a new reference must be done atomically to avoid a use-after-free + race with a concurrent setter. CPy_GetAttrRef performs the load and incref + atomically and returns a new reference (or NULL if undefined), so callers must + NOT emit a separate inc_ref. Return True in that case so the caller can skip it. + + Final attributes are never rebound (no setter), so there is no concurrent writer + and no use-after-free window; an owned read uses the cheaper CPy_GetAttrRefFinal + (a plain load + incref). Borrowed reads keep the plain load: they are only emitted + for attributes safe to borrow on free-threaded builds (Final and vec attrs -- see + transform_member_expr in irbuild), whose values live as long as their container. + The default (GIL) build always takes the plain-load path and increfs separately. + """ + use_get_attr_ref = ( + IS_FREE_THREADED and is_simple_refcounted_pointer(attr_rtype) and not op.is_borrowed + ) + if use_get_attr_ref and cl.is_final_attr(op.attr): + self.emitter.emit_line(f"{dest} = CPy_GetAttrRefFinal((PyObject **)&{attr_expr});") + elif use_get_attr_ref: + self.emitter.emit_line(f"{dest} = CPy_GetAttrRef((PyObject **)&{attr_expr});") + else: + self.emitter.emit_line(f"{dest} = {attr_expr};") + return use_get_attr_ref + def visit_get_attr(self, op: GetAttr) -> None: if op.allow_error_value: self.get_attr_with_allow_error_value(op) @@ -427,7 +463,9 @@ def visit_get_attr(self, op: GetAttr) -> None: else: # Otherwise, use direct or offset struct access. attr_expr = self.get_attr_expr(obj, op, decl_cl) - self.emitter.emit_line(f"{dest} = {attr_expr};") + use_get_attr_ref = self.emit_load_attr_take_ref( + dest, obj, op, cl, attr_rtype, attr_expr + ) always_defined = cl.is_always_defined(op.attr) merged_branch = None if not always_defined: @@ -458,7 +496,7 @@ def visit_get_attr(self, op: GetAttr) -> None: ) ) - if attr_rtype.is_refcounted and not op.is_borrowed: + if attr_rtype.is_refcounted and not op.is_borrowed and not use_get_attr_ref: if not merged_branch and not always_defined: self.emitter.emit_line("} else {") self.emitter.emit_inc_ref(dest, attr_rtype) @@ -480,16 +518,20 @@ def get_attr_with_allow_error_value(self, op: GetAttr) -> None: cl = rtype.class_ir attr_rtype, decl_cl = cl.attr_details(op.attr) - # Direct struct access without NULL check attr_expr = self.get_attr_expr(obj, op, decl_cl) - self.emitter.emit_line(f"{dest} = {attr_expr};") - - # Only emit inc_ref if not NULL - if attr_rtype.is_refcounted and not op.is_borrowed: - check = self.error_value_check(op, "!=") - self.emitter.emit_line(f"if ({check}) {{") - self.emitter.emit_inc_ref(dest, attr_rtype) - self.emitter.emit_line("}") + # On free-threaded builds this takes a new reference atomically (see + # emit_load_attr_take_ref). CPy_GetAttrRef returns NULL when the field is + # undefined, which is precisely the error value here, so the "NULL without + # AttributeError" behavior is preserved (this op has error_kind ERR_NEVER). + # is_simple_refcounted_pointer excludes tuples/vecs, so error_value_check's + # special cases only matter on the plain-load path (where no ref was taken). + if not self.emit_load_attr_take_ref(dest, obj, op, cl, attr_rtype, attr_expr): + # Only emit inc_ref if not NULL + if attr_rtype.is_refcounted and not op.is_borrowed: + check = self.error_value_check(op, "!=") + self.emitter.emit_line(f"if ({check}) {{") + self.emitter.emit_inc_ref(dest, attr_rtype) + self.emitter.emit_line("}") def next_branch(self) -> Branch | None: if self.op_index + 1 < len(self.ops): @@ -529,6 +571,23 @@ def visit_set_attr(self, op: SetAttr) -> None: op.attr, ) ) + elif IS_FREE_THREADED and is_simple_refcounted_pointer(attr_rtype): + # In free-threaded builds, publishing a single reference-counted + # 'PyObject *' field must be atomic so a concurrent reader (see + # CPy_GetAttrRef) never observes a torn pointer or a freed value. + # Both helpers steal the reference to src. + attr_expr = self.get_attr_expr(obj, op, decl_cl) + if op.is_init: + # The attribute is known to be previously undefined (NULL), so + # there is no old value to reclaim; a relaxed store suffices + # (self's later publication provides the release barrier -- see + # CPy_InitAttrRef). + self.emitter.emit_line(f"CPy_InitAttrRef((PyObject **)&{attr_expr}, {src});") + else: + # Atomically swap in the new value and reclaim the old one. + self.emitter.emit_line(f"CPy_SetAttrRef((PyObject **)&{attr_expr}, {src});") + if op.error_kind == ERR_FALSE: + self.emitter.emit_line(f"{dest} = 1;") else: # ...and struct access for normal attributes. attr_expr = self.get_attr_expr(obj, op, decl_cl) diff --git a/mypyc/doc/differences_from_python.rst b/mypyc/doc/differences_from_python.rst index c65c330edbef3..7e6cf37154d3e 100644 --- a/mypyc/doc/differences_from_python.rst +++ b/mypyc/doc/differences_from_python.rst @@ -293,40 +293,26 @@ attribute tends to be faster than a plain global variable in compiled code:: Free threading -------------- -Mypyc supports free threading, but it doesn't provide the exact -memory safety guarantees as Python in compiled modules under -free threading when there are race conditions. - -Additionally, optimized primitive operations in compiled code may have -different atomicity properties compared to CPython. Use explicit -synchronization if code depends on operations being atomic. This is -already the recommended approach for normal Python code. - -Currently, compiled code must ensure that proper synchronization is -used to prevent data races involving non-final attributes in native -classes, unless the attribute has a value type such as ``bool``, -``float`` or ``i64``. You can use explicit -synchronization, such as via -:ref:`librt.threading.Lock ` (or -:py:class:`threading.Lock`, which is less efficient than -``librt.threading.Lock``) if there is a possibility of such a data -race. +Mypyc supports free threading. However, optimized primitive operations in +compiled code may have different atomicity properties compared to CPython. +Use explicit synchronization if code depends on operations being atomic and +race conditions are possible. This is already the recommended approach for +normal Python code. You can often use :ref:`librt.threading.Lock ` +(or :py:class:`threading.Lock`, which is less efficient than +``librt.threading.Lock``) to fix data races. + +Since mypyc 2.3, the vast majority of operations are memory safe even if +there are race conditions (unlike earlier mypyc releases). This includes +list operations and access to native instance attributes (except for +a few less common use cases that will be fixed in future releases). .. note:: - We are working on improving memory safety in free-threading - builds of Python, and hope to make all normal Python features - memory safe, while providing more efficient but less safe - opt-in, non-standard features. - -As libraries often won't be able to control the concurrent access by -user code, we recommend that modules document that multi-threaded -access is only supported via public interfaces that ensure correct -synchronization. Marking attributes as internal using an underscore -attribute prefix is another possibility, but this is not enforced at -runtime. Another option is to document that multithreaded access is -not supported, or that particular objects should not be used from -multiple threads concurrently. + Operations that aren't safe under race conditions in interpreted CPython + are not expected to be memory safe in compiled code either. + Some :ref:`librt ` features are heavily optimized for performance and + don't guarantee memory safety when there are race conditions + (notably the :ref:`vec ` type). It's always safe to perform read-only operations concurrently. Using objects with final attributes and tuple objects can help prevent diff --git a/mypyc/doc/librt_vecs.rst b/mypyc/doc/librt_vecs.rst index dad3be621ff4c..a7f5bc3ffbd96 100644 --- a/mypyc/doc/librt_vecs.rst +++ b/mypyc/doc/librt_vecs.rst @@ -1,3 +1,5 @@ +.. _librt-vecs: + librt.vecs ========== @@ -198,15 +200,21 @@ with no unnecessary temporary objects. Thread safety ------------- +The ``vec`` type is heavily optimized for performance, and this means that ``vec`` +gives fewer thread safety guarantees than built-in types such as ``list``. +``vec`` is a lower-level type where implicit synchronization would have a very +significant performance cost. However, since vec lengths are immutable, some race +conditions that lists can be susceptible to are not possible with vecs. + In free-threaded Python builds, it's unsafe to write or modify an item if other threads might be concurrently accessing *the same item*. For example, writing ``v[4]`` is not safe to do if another thread might be reading ``v[4]``. Similarly, two threads concurrently calling ``append`` or ``remove`` on the same vec object is not safe. -This is different from list objects, since vec is a lower-level type where implicit -synchronization would have a significant performance cost. However, since vec lengths -are immutable, some race conditions that lists can be susceptible to are not possible -with vecs. +Similarly, assigning to an instance attribute of a native class with a ``vec`` type +is unsafe if another thread might be accessing the same attribute concurrently +(in a free-threaded Python build only). Using ``Final`` attributes is a way +to prevent this race condition, since ``Final`` attributes cannot be rebound. Implementation details ---------------------- diff --git a/mypyc/ir/class_ir.py b/mypyc/ir/class_ir.py index 028df5898d920..3b54331cb2f07 100644 --- a/mypyc/ir/class_ir.py +++ b/mypyc/ir/class_ir.py @@ -7,7 +7,7 @@ from mypyc.common import PROPSET_PREFIX, JsonDict from mypyc.ir.func_ir import FuncDecl, FuncIR, FuncSignature, RuntimeArg from mypyc.ir.ops import DeserMaps, Value -from mypyc.ir.rtypes import RInstance, RType, deserialize_type, object_rprimitive +from mypyc.ir.rtypes import RInstance, RType, c_int_rprimitive, deserialize_type, object_rprimitive from mypyc.namegen import NameGenerator, exported_name # Some notes on the vtable layout: Each concrete class has a vtable @@ -143,8 +143,25 @@ def __init__( module_name, FuncSignature([RuntimeArg("type", object_rprimitive)], RInstance(self)), ) + self.clear = FuncDecl( + name + "_clear", + None, + module_name, + FuncSignature([RuntimeArg("self", RInstance(self))], c_int_rprimitive), + internal=True, + ) + self.clear_on_completion = FuncDecl( + name + "_clear_on_completion", + None, + module_name, + FuncSignature([RuntimeArg("self", RInstance(self))], c_int_rprimitive), + internal=True, + ) # Attributes defined in the class (not inherited) self.attributes: dict[str, RType] = {} + # Attributes that must survive generator/coroutine completion because + # escaped nested functions may still read them as closure variables. + self.attrs_to_keep_alive_on_completion: set[str] = set() # Final attributes defined in the class (not inherited) self.final_attributes: set[str] = set() # Deletable attributes @@ -269,6 +286,22 @@ def attr_details(self, name: str) -> tuple[RType, ClassIR]: def attr_type(self, name: str) -> RType: return self.attr_details(name)[0] + def is_final_attr(self, name: str) -> bool: + """Is the (possibly inherited) attribute Final, i.e. never rebound? + + A Final attribute is read-only at runtime (it has no setter) and is assigned + exactly once during construction, so it can never be reassigned afterwards. + This makes it safe to borrow on free-threaded builds (no concurrent store can + invalidate a borrowed reference) and lets reads skip the concurrent-writer + guard. Returns False for properties and for attributes this class doesn't have. + """ + for ir in self.mro: + if name in ir.attributes: + return name in ir.final_attributes + if name in ir.property_types: + return False + return False + def method_decl(self, name: str) -> FuncDecl: for ir in self.mro: if name in ir.method_decls: @@ -396,8 +429,11 @@ def serialize(self) -> JsonDict: "_serializable": self._serializable, "builtin_base": self.builtin_base, "ctor": self.ctor.serialize(), + "clear": self.clear.serialize(), + "clear_on_completion": self.clear_on_completion.serialize(), # We serialize dicts as lists to ensure order is preserved "attributes": [(k, t.serialize()) for k, t in self.attributes.items()], + "attrs_to_keep_alive_on_completion": sorted(self.attrs_to_keep_alive_on_completion), "final_attributes": sorted(self.final_attributes), # We try to serialize a name reference, but if the decl isn't in methods # then we can't be sure that will work so we serialize the whole decl. @@ -458,7 +494,10 @@ def deserialize(cls, data: JsonDict, ctx: DeserMaps) -> ClassIR: ir._serializable = data["_serializable"] ir.builtin_base = data["builtin_base"] ir.ctor = FuncDecl.deserialize(data["ctor"], ctx) + ir.clear = FuncDecl.deserialize(data["clear"], ctx) + ir.clear_on_completion = FuncDecl.deserialize(data["clear_on_completion"], ctx) ir.attributes = {k: deserialize_type(t, ctx) for k, t in data["attributes"]} + ir.attrs_to_keep_alive_on_completion = set(data["attrs_to_keep_alive_on_completion"]) ir.final_attributes = set(data["final_attributes"]) ir.method_decls = { k: ctx.functions[v].decl if isinstance(v, str) else FuncDecl.deserialize(v, ctx) diff --git a/mypyc/ir/rtypes.py b/mypyc/ir/rtypes.py index 1a5515d5621a8..afe3d3e9df39b 100644 --- a/mypyc/ir/rtypes.py +++ b/mypyc/ir/rtypes.py @@ -569,6 +569,19 @@ def is_native_rprimitive(rtype: RType) -> bool: return isinstance(rtype, RPrimitive) and rtype.name in KNOWN_NATIVE_TYPES +def is_simple_refcounted_pointer(rtype: RType) -> bool: + """Is rtype represented at runtime as a single, reference-counted 'PyObject *'? + + This covers 'object', 'str', containers, instances of native classes and + optional/union types -- everything whose C representation is exactly one + 'PyObject *' field that owns a reference. It excludes unboxed types (tagged + 'int', fixed-width ints, floats, bools), inline tuples ('RTuple'), vectors + ('RVec') and C structs ('RStruct'), which need different treatment for + free-threaded memory safety. + """ + return rtype.is_refcounted and not rtype.is_unboxed and not isinstance(rtype, RStruct) + + def is_tagged(rtype: RType) -> TypeGuard[RPrimitive]: return rtype is int_rprimitive or rtype is short_int_rprimitive diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index d8ae71e33bf59..f4e3745836cf5 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -1568,12 +1568,15 @@ def add_var_to_env_class( base: FuncInfo | ImplicitClass, reassign: bool = False, always_defined: bool = False, + keep_alive_on_completion: bool = False, prefix: str = "", ) -> AssignmentTarget: # First, define the variable name as an attribute of the environment class, and then # construct a target for that attribute. name = prefix + remangle_redefinition_name(var.name) self.fn_info.env_class.attributes[name] = rtype + if keep_alive_on_completion: + self.fn_info.env_class.attrs_to_keep_alive_on_completion.add(name) if always_defined: self.fn_info.env_class.attrs_with_defaults.add(name) attr_target = AssignmentTargetAttr(base.curr_env_reg, name) @@ -1642,13 +1645,11 @@ def is_final_native_attr_ref(self, expr: MemberExpr) -> bool: builds, since no concurrent store can invalidate the borrowed reference. """ obj_rtype = self.node_type(expr.expr) - if not (isinstance(obj_rtype, RInstance) and obj_rtype.class_ir.is_ext_class): - return False - # Find the class that defines the attribute and check whether it's Final there. - for ir in obj_rtype.class_ir.mro: - if expr.name in ir.attributes: - return expr.name in ir.final_attributes - return False + return ( + isinstance(obj_rtype, RInstance) + and obj_rtype.class_ir.is_ext_class + and obj_rtype.class_ir.is_final_attr(expr.name) + ) def root_is_reassigned(self, v: Value) -> bool: """Is the root local variable a borrow chain 'v' reads from reassigned this expression? diff --git a/mypyc/irbuild/classdef.py b/mypyc/irbuild/classdef.py index 4fd9a2e2cc748..61587b46161f7 100644 --- a/mypyc/irbuild/classdef.py +++ b/mypyc/irbuild/classdef.py @@ -760,7 +760,6 @@ def find_attr_initializers( if cls.builtin_base: return set(), [] - cls_type = dataclass_type(cdef) attrs_with_defaults: set[str] = set() default_assignments: list[tuple[AssignmentStmt, str]] = [] @@ -774,10 +773,11 @@ def find_attr_initializers( info_ir = builder.mapper.type_to_ir.get(info) if info_ir is None: continue + info_cls_type = dataclass_type(info.defn) for stmt in info.defn.defs.body: if not isinstance(stmt, AssignmentStmt): continue - name = default_attr_name(stmt, info_ir, cls_type) + name = default_attr_name(stmt, info_ir, info_cls_type) if name is None: continue attrs_with_defaults.add(name) diff --git a/mypyc/irbuild/env_class.py b/mypyc/irbuild/env_class.py index 3128543d4cd2c..c8c5f7fa68593 100644 --- a/mypyc/irbuild/env_class.py +++ b/mypyc/irbuild/env_class.py @@ -17,7 +17,7 @@ def g() -> int: from __future__ import annotations -from mypy.nodes import Argument, FuncDef, SymbolNode, Var +from mypy.nodes import Argument, FuncDef, FuncItem, SymbolNode, Var from mypyc.common import ( BITMAP_BITS, ENV_ATTR_NAME, @@ -60,6 +60,8 @@ class is generated, the function environment has not yet been # If the function is nested, its environment class must contain an environment # attribute pointing to its encapsulating functions' environment class. env_class.attributes[ENV_ATTR_NAME] = RInstance(builder.fn_infos[-2].env_class) + if builder.fn_info.contains_nested: + env_class.attrs_to_keep_alive_on_completion.add(ENV_ATTR_NAME) env_class.mro = [env_class] builder.fn_info.env_class = env_class builder.classes.append(env_class) @@ -229,7 +231,17 @@ def add_args_to_env( rtype = builder.type_to_rtype(arg.variable.type) assert base is not None, "base cannot be None for adding nonlocal args" builder.add_var_to_env_class( - arg.variable, rtype, base, reassign=reassign, prefix=prefix + arg.variable, + rtype, + base, + reassign=reassign, + keep_alive_on_completion=( + is_free_variable(builder, arg.variable) + or is_free_variable_in_nested_func( + builder, builder.fn_info.fitem, arg.variable + ) + ), + prefix=prefix, ) @@ -256,7 +268,12 @@ def add_vars_to_env(builder: IRBuilder, prefix: str = "") -> None: if isinstance(var, Var): rtype = builder.type_to_rtype(var.type) builder.add_var_to_env_class( - var, rtype, env_for_func, reassign=False, prefix=prefix + var, + rtype, + env_for_func, + reassign=False, + keep_alive_on_completion=True, + prefix=prefix, ) if builder.fn_info.fitem in builder.encapsulating_funcs: @@ -267,10 +284,16 @@ def add_vars_to_env(builder: IRBuilder, prefix: str = "") -> None: # the same name and signature across conditional blocks # will generate different callable classes, so the callable # class that gets instantiated must be generic. + nested_prefix = prefix if nested_fn.is_generator or nested_fn.is_coroutine: - prefix = GENERATOR_ATTRIBUTE_PREFIX + nested_prefix = GENERATOR_ATTRIBUTE_PREFIX builder.add_var_to_env_class( - nested_fn, object_rprimitive, env_for_func, reassign=False, prefix=prefix + nested_fn, + object_rprimitive, + env_for_func, + reassign=False, + keep_alive_on_completion=is_free_variable(builder, nested_fn), + prefix=nested_prefix, ) @@ -308,3 +331,14 @@ def setup_func_for_recursive_call( def is_free_variable(builder: IRBuilder, symbol: SymbolNode) -> bool: fitem = builder.fn_info.fitem return fitem in builder.free_variables and symbol in builder.free_variables[fitem] + + +def is_free_variable_in_nested_func( + builder: IRBuilder, fitem: FuncItem, symbol: SymbolNode +) -> bool: + for nested in builder.encapsulating_funcs.get(fitem, []): + if symbol in builder.free_variables.get(nested, set()): + return True + if is_free_variable_in_nested_func(builder, nested, symbol): + return True + return False diff --git a/mypyc/irbuild/generator.py b/mypyc/irbuild/generator.py index 82fc74f9b1ce5..555baaada0e81 100644 --- a/mypyc/irbuild/generator.py +++ b/mypyc/irbuild/generator.py @@ -23,6 +23,7 @@ Call, Goto, Integer, + LoadErrorValue, MethodCall, RaiseStandardError, Register, @@ -49,7 +50,7 @@ load_outer_envs, setup_func_for_recursive_call, ) -from mypyc.irbuild.nonlocalcontrol import ExceptNonlocalControl +from mypyc.irbuild.nonlocalcontrol import ExceptNonlocalControl, gen_generator_func_cleanup from mypyc.irbuild.prepare import GENERATOR_HELPER_NAME from mypyc.primitives.exc_ops import ( error_catch_op, @@ -107,6 +108,8 @@ class that implements the function (each function gets a separate class). setup_func_for_recursive_call( builder, fitem, builder.fn_info.generator_class, prefix=GENERATOR_ATTRIBUTE_PREFIX ) + cleanup_on_error = BasicBlock() + builder.builder.push_error_handler(cleanup_on_error) create_switch_for_generator_class(builder) add_raise_exception_blocks_to_generator_class(builder, fitem.line) @@ -114,6 +117,11 @@ class that implements the function (each function gets a separate class). builder.accept(fitem.body) builder.maybe_add_implicit_return() + builder.builder.pop_error_handler() + + builder.activate_block(cleanup_on_error) + gen_generator_func_cleanup(builder, fitem.line) + builder.add(Return(builder.add(LoadErrorValue(object_rprimitive)))) populate_switch_for_generator_class(builder) @@ -166,6 +174,12 @@ def setup_generator_class(builder: IRBuilder) -> ClassIR: builder.fn_info.env_class = generator_class_ir else: generator_class_ir.attributes[ENV_ATTR_NAME] = RInstance(builder.fn_info.env_class) + if not builder.fn_info.fitem.is_coroutine: + # After completion generators still need generator.__mypyc_env__ for subsequent + # __next__() calls to observe the terminal next-label and raise StopIteration. + # Coroutines can't be resumed after completion, so keeping the environment alive + # there would just extend local lifetimes unnecessarily. + generator_class_ir.attrs_to_keep_alive_on_completion.add(ENV_ATTR_NAME) builder.classes.append(generator_class_ir) return generator_class_ir diff --git a/mypyc/irbuild/nonlocalcontrol.py b/mypyc/irbuild/nonlocalcontrol.py index e0a23769770d7..009a1d6a986bc 100644 --- a/mypyc/irbuild/nonlocalcontrol.py +++ b/mypyc/irbuild/nonlocalcontrol.py @@ -8,10 +8,13 @@ from abc import abstractmethod from typing import TYPE_CHECKING +from mypyc.ir.class_ir import ClassIR from mypyc.ir.ops import ( + ERR_NEVER, NO_TRACEBACK_LINE_NO, BasicBlock, Branch, + Call, Goto, Integer, Register, @@ -90,10 +93,7 @@ class GeneratorNonlocalControl(BaseNonlocalControl): """Default nonlocal control in a generator function outside statements.""" def gen_return(self, builder: IRBuilder, value: Value, line: int) -> None: - # Assign an invalid next label number so that the next time - # __next__ is called, we jump to the case in which - # StopIteration is raised. - builder.assign(builder.fn_info.generator_class.next_label_target, Integer(-1), line) + gen_generator_func_cleanup(builder, line) # Raise a StopIteration containing a field for the value that # should be returned. Before doing so, create a new block @@ -132,6 +132,25 @@ def gen_return(self, builder: IRBuilder, value: Value, line: int) -> None: builder.add(Return(Integer(0, object_rprimitive))) +def gen_class_clear_on_completion(builder: IRBuilder, obj: Value, cl: ClassIR, line: int) -> None: + call = Call(cl.clear_on_completion, [obj], line) + call.error_kind = ERR_NEVER + builder.add(call) + + +def gen_generator_func_cleanup(builder: IRBuilder, line: int) -> None: + """Clear references held by a completed generator or coroutine.""" + cls = builder.fn_info.generator_class + + # Assign an invalid next label number so that the next time __next__ is + # called, we jump to the case in which StopIteration is raised. + builder.assign(cls.next_label_target, Integer(-1), line) + + gen_class_clear_on_completion(builder, cls.curr_env_reg, builder.fn_info.env_class, line) + if builder.fn_info.env_class is not cls.ir: + gen_class_clear_on_completion(builder, cls.self_reg, cls.ir, line) + + class CleanupNonlocalControl(NonlocalControl): """Abstract nonlocal control that runs some cleanup code.""" diff --git a/mypyc/lib-rt/pythonsupport.c b/mypyc/lib-rt/pythonsupport.c index 0a99f0ae2e29b..a8f5a4f4ad4ea 100644 --- a/mypyc/lib-rt/pythonsupport.c +++ b/mypyc/lib-rt/pythonsupport.c @@ -5,6 +5,27 @@ #include "pythonsupport.h" +#ifdef Py_GIL_DISABLED +// Cold slow path of CPy_GetAttrRef (declared in pythonsupport.h). Reached only +// when the inline fast-path try-incref fails: the value is owned by another +// thread, so taking a reference requires an atomic shared-refcount operation. +// Kept out-of-line so the inline fast path stays small. +// +// First try the lock-free shared-refcount CAS. If the value has not had +// maybe-weakref set yet (for example, it was published by CPy_InitAttrRef), force +// a cross-thread reference via _Py_NewRefWithLock, which cannot fail and sets +// maybe-weakref so subsequent reads take the fast path. The value was already +// observed in the field by CPy_GetAttrRef; CPy_SetAttrRef's QSBR-delayed decref +// keeps any replaced value alive long enough for this reader. +CPy_NOINLINE +PyObject *CPy_GetAttrRefSlow(PyObject *v) { + if (_Py_TryIncRefShared(v)) { + return v; + } + return _Py_NewRefWithLock(v); // sets maybe-weakref; cannot fail +} +#endif + ///////////////////////////////////////// // Adapted from bltinmodule.c in Python 3.7.0 PyObject* diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index 35f1e78df3915..33c5a596d1747 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -23,6 +23,10 @@ #include "internal/pycore_setobject.h" // _PySet_Update #endif +#ifdef Py_GIL_DISABLED +#include "internal/pycore_object.h" // _Py_TryIncrefFast, _Py_TryIncRefShared +#endif + #if CPY_3_12_FEATURES #include "internal/pycore_frame.h" #endif @@ -34,6 +38,136 @@ extern "C" { } // why isn't emacs smart enough to not indent this #endif +#ifdef Py_GIL_DISABLED +// Read a native attribute that is a single reference-counted 'PyObject *' field, +// returning a new reference (or NULL if the field is NULL/undefined). +// +// On free-threaded builds a plain load followed by an incref races with a +// concurrent setter that may decref the old value to zero and free it before the +// incref runs (use-after-free). CPy_SetAttrRef avoids that by reclaiming old +// values through QSBR-delayed decref, so a value observed in the field remains +// safe to touch while this reader is running. +// +// Only the hot case is inlined here: an incref of a value owned by this thread or +// immortal, via '_Py_TryIncrefFast' (no CAS, no loop). Everything colder -- the +// cross-thread shared-refcount CAS and the _Py_NewRefWithLock fallback -- lives +// out-of-line in 'CPy_GetAttrRefSlow'. Splitting it this way keeps each call +// site's fast path small enough to inline, which is measurably faster than +// letting the compiler auto-out-line the whole helper (that merges every read +// site's branch history into one shared copy and mispredicts). It is only used in +// free-threaded builds; the default (GIL) build keeps the plain load + incref +// generated inline by mypyc. +PyObject *CPy_GetAttrRefSlow(PyObject *v); + +static inline PyObject *CPy_GetAttrRef(PyObject **field) { + PyObject *v = (PyObject *)_Py_atomic_load_ptr_acquire(field); + if (v == NULL) { + return NULL; + } + if (_Py_TryIncrefFast(v)) { + return v; + } + return CPy_GetAttrRefSlow(v); +} + +// Read a native attribute that is a single reference-counted 'PyObject *' field +// AND is Final (assigned once during construction, never rebound -- mypyc emits no +// setter for it), returning a new reference (or NULL if undefined). +// +// A Final attribute has no concurrent writer after 'self' is published, so the +// use-after-free race that CPy_GetAttrRef guards against cannot happen: the field +// holds a strong reference for the object's whole lifetime, and any thread reading +// it necessarily holds 'self', which keeps the value alive. So the try-incref + +// _Py_NewRefWithLock fallback are unnecessary here -- a plain load + Py_INCREF is +// safe. A cross-thread Py_INCREF is an unconditional +// atomic add on ob_ref_shared, so (unlike CPy_GetAttrRef's try-incref) it needs no +// maybe-weakref and has no slow path. The load is relaxed rather than acquire: the +// reader reached 'self' through a synchronization edge (self's own publication) +// that already ordered the construction stores before it, exactly as with +// CPy_InitAttrRef's relaxed store. Relaxed keeps it TSan-clean at zero cost (plain +// mov/ldr). +static inline PyObject *CPy_GetAttrRefFinal(PyObject **field) { + PyObject *v = (PyObject *)_Py_atomic_load_ptr_relaxed(field); + if (v != NULL) { + Py_INCREF(v); + } + return v; +} + +// Reclaim the previous value of a native attribute after it has been replaced. +// +// CPy_GetAttrRef reads the field optimistically without holding any lock the +// writer also takes, so a reader can load the old pointer and then try to take a +// reference after this store. The old value must therefore stay alive until every +// thread has passed a quiescent point, which is exactly what a QSBR-deferred +// decref guarantees. So all mortal old values are +// reclaimed via _PyObject_XDecRefDelayed, matching CPython's own replace-a-slot +// paths (e.g. _PyObject_SetDict / _PyObject_SetManagedDict). +// +// We deliberately do NOT take a "local refcount > 1, owned by this thread" fast +// path: dropping the field's reference is not the only decref of the object, so a +// non-freeing local decrement here does not prevent an unrelated reference holder +// from driving the object to zero (a plain, non-deferred Py_DECREF -> _Py_Dealloc) +// while an in-flight reader still holds the stale pointer -- a use-after-free that +// only QSBR deferral closes. Immortal objects are never freed, so skipping their +// decref entirely is safe and avoids queuing a no-op onto the delayed-free list. +static inline void CPy_DecRefAttrOld(PyObject *op) { + if (op == NULL) { + return; + } + if (_Py_IsImmortal(op)) { + return; + } + _PyObject_XDecRefDelayed(op); +} + +// Set a native attribute that is a single reference-counted 'PyObject *' field, +// stealing the reference to 'value' (which may be NULL to delete the attribute) +// and safely reclaiming the previous value. +// +// Memory safety does NOT depend on SetMaybeWeakref here, so (unlike an earlier +// version) we do not call it. Two things keep this safe: +// - The old value is reclaimed via CPy_DecRefAttrOld, a QSBR-deferred decref, so +// it cannot be freed while an in-flight CPy_GetAttrRef still holds the stale +// pointer. +// - A concurrent cross-thread reader whose inline fast-path try-incref fails on +// an unflagged 'value' still cannot fail and never blocks on this writer: +// CPy_GetAttrRef falls into CPy_GetAttrRefSlow, which is fully lock-free. It +// retries with a lock-free shared-refcount CAS (_Py_TryIncRefShared) and, only +// if that also fails, forces a reference via _Py_NewRefWithLock, which cannot +// fail and lazily sets maybe-weakref so later cross-thread reads take the CAS. +// This mirrors CPy_InitAttrRef, which already omits SetMaybeWeakref for the same +// reason. Setting the flag here would only be a possible performance tuning knob +// (it would let that first cross-thread reader succeed on the cheaper +// _Py_TryIncRefShared CAS instead of falling through to _Py_NewRefWithLock); it is +// not needed for correctness. The atomic exchange publishes the new pointer and +// hands back the old one without a writer/writer race. +static inline void CPy_SetAttrRef(PyObject **field, PyObject *value) { + PyObject *old = (PyObject *)_Py_atomic_exchange_ptr(field, value); + CPy_DecRefAttrOld(old); +} + +// Initialize a native attribute that is known to be previously undefined (NULL), +// stealing the reference to 'value'. +// +// Initializer stores only happen while 'self' is still thread-local (the +// attribute-definedness analysis marks a SetAttr as an initializer only before +// 'self' can leak -- see mypyc/analysis/attrdefined.py). So there is no old value +// to reclaim, no competing writer, and the field store is not itself the +// publication point: 'self' is published later (when it escapes __init__ or is +// returned), and that publication carries the release barrier making all the +// construction stores visible. A relaxed store therefore suffices. +// +// Unlike CPy_SetAttrRef, this deliberately does NOT call SetMaybeWeakref (its CAS +// is pure overhead here, ~+2.6ns per fresh store, and construction-heavy code +// pays it on every attribute of every new object). The cost is moved off this hot +// path onto CPy_GetAttrRef's cold slow path, which sets maybe-weakref lazily on +// the first cross-thread read that needs it. +static inline void CPy_InitAttrRef(PyObject **field, PyObject *value) { + _Py_atomic_store_ptr_relaxed(field, value); +} +#endif + PyObject* update_bases(PyObject *bases); int init_subclass(PyTypeObject *type, PyObject *kwds); diff --git a/mypyc/test-data/irbuild-classes.test b/mypyc/test-data/irbuild-classes.test index 66caf0772ec40..8eaa63e4583b5 100644 --- a/mypyc/test-data/irbuild-classes.test +++ b/mypyc/test-data/irbuild-classes.test @@ -1276,6 +1276,46 @@ L0: r1 = r0.x return r1 +[case testCanBorrowFinalAttribute_nogil] +from typing import Final + +# On free-threaded builds native attribute reads are not borrowed (a concurrent +# store could free the old value), EXCEPT Final attributes, which can't be rebound. +# Here the intermediate 'd.c' is borrowed because 'c' is Final; contrast with +# testCannotBorrowAttribute_nogil, where the non-Final 'c' is read owned. The +# borrow decision uses the same ClassIR.is_final_attr predicate as codegen. +def f(d: D) -> int: + return d.c.xf + +class C: + def __init__(self, xf: int) -> None: + self.xf: Final = xf +class D: + def __init__(self, c: C) -> None: + self.c: Final = c +[out] +def f(d): + d :: __main__.D + r0 :: __main__.C + r1 :: int +L0: + r0 = borrow d.c + r1 = r0.xf + keep_alive d + return r1 +def C.__init__(self, xf): + self :: __main__.C + xf :: int +L0: + self.xf = xf + return 1 +def D.__init__(self, c): + self :: __main__.D + c :: __main__.C +L0: + self.c = c + return 1 + [case testNoBorrowOverPropertyAccess] class C: d: D diff --git a/mypyc/test-data/run-async.test b/mypyc/test-data/run-async.test index a7a08e7ced5f2..4c9fca54f4a2f 100644 --- a/mypyc/test-data/run-async.test +++ b/mypyc/test-data/run-async.test @@ -407,6 +407,12 @@ import asyncio import gc import platform +from testutil import is_gil_disabled + +def immediate_deallocation_checks_are_reliable() -> bool: + # Free-threaded builds can defer deallocation, so weakref liveness is not deterministic. + return not is_gil_disabled() + async def sleep() -> None: # Non-zero value hangs on Windows. time = 0 if platform.system() == "Windows" else 0.0001 @@ -512,6 +518,106 @@ async def stolen(n: int) -> int: async def test_stolen() -> None: await assert_no_leaks(lambda: stolen(200), 16) +async def retain_arg(c: set[int]) -> None: + async def nested() -> int: + await sleep() + return len(c) + + await nested() + +async def test_completed_coroutine_releases_environment() -> None: + if not immediate_deallocation_checks_are_reliable(): + return + + import weakref + + c = {1} + ref = weakref.ref(c) + coro = retain_arg(c) + c = {2} + await coro + assert ref() is None + +async def retain_arg_then_raise(c: set[int]) -> None: + async def nested() -> int: + await sleep() + return len(c) + + await nested() + raise ValueError + +async def test_failed_coroutine_releases_environment() -> None: + if not immediate_deallocation_checks_are_reliable(): + return + + import weakref + + c = {1} + ref = weakref.ref(c) + coro = retain_arg_then_raise(c) + c = {2} + try: + await coro + except ValueError: + pass + assert ref() is None + +async def close_retain_arg(c: set[int]) -> None: + await sleep() + len(c) + +async def test_closed_coroutine_releases_environment() -> None: + if not immediate_deallocation_checks_are_reliable(): + return + + import weakref + from typing import Any, cast + + c = {1} + ref = weakref.ref(c) + coro = cast(Any, close_retain_arg(c)) + coro.send(None) + c = {2} + coro.close() + assert ref() is None + +async def interpreted_leaf(c: set[int]) -> int: + await sleep() + return len(c) + +async def compiled_leaf(c: set[int]) -> int: + await sleep() + return len(c) + +async def compiled_awaits_interpreted(c: set[int]) -> int: + import interpreted + + return await interpreted.interpreted_leaf(c) + +async def interpreted_awaits_compiled(fn, c: set[int]) -> int: + return await fn(c) + +async def test_completed_mixed_coroutine_releases_environment() -> None: + if not immediate_deallocation_checks_are_reliable(): + return + + import interpreted + import weakref + + c = {1} + ref = weakref.ref(c) + coro = compiled_awaits_interpreted(c) + c = {2} + assert await coro == 1 + assert ref() is None + + c = {3} + ref = weakref.ref(c) + coro = interpreted.interpreted_awaits_compiled(compiled_leaf, c) + c = {4} + assert await coro == 1 + assert ref() is None + [file asyncio/__init__.pyi] async def sleep(t: float) -> None: ... @@ -1998,6 +2104,99 @@ for i in range(50): assert yields == ("hello",), yields assert val == "value" + str(i) + "world", repr(val) +[case testCoroutineClosureOutlivesCompletedCoroutine] +import asyncio +from typing import Callable + +async def make_reader() -> Callable[[], int]: + payload = [7] + await asyncio.sleep(0) + + def read_payload() -> int: + return payload[0] + + return read_payload + +async def make_arg_reader(payload: list[int]) -> Callable[[], int]: + await asyncio.sleep(0) + + def read_payload() -> int: + return payload[0] + + return read_payload + +async def make_indirect_reader() -> Callable[[], int]: + def leaf() -> int: + return 17 + + def reader() -> int: + return leaf() + + await asyncio.sleep(0) + return reader + +def test_closure_returned_from_completed_coroutine_keeps_capture() -> None: + read_payload = asyncio.run(make_reader()) + assert read_payload() == 7 + + payload = [13] + read_arg_payload = asyncio.run(make_arg_reader(payload)) + payload = [0] + assert read_arg_payload() == 13 + + indirect_reader = asyncio.run(make_indirect_reader()) + assert indirect_reader() == 17 + +async def test_closure_returned_from_awaited_coroutine_keeps_capture() -> None: + read_payload = await make_reader() + assert read_payload() == 7 + + payload = [13] + read_arg_payload = await make_arg_reader(payload) + payload = [0] + assert read_arg_payload() == 13 + + indirect_reader = await make_indirect_reader() + assert indirect_reader() == 17 + +[file asyncio/__init__.pyi] +from typing import Awaitable, TypeVar + +T = TypeVar("T") + +def run(x: Awaitable[T]) -> T: ... +async def sleep(t: float) -> None: ... + +[typing fixtures/typing-full.pyi] + +[case testNestedCoroutineOutlivesCompletedCoroutine] +import asyncio +from typing import Awaitable + +async def make_nested_coroutine() -> Awaitable[int]: + payload = [11] + await asyncio.sleep(0) + + async def read_payload() -> int: + await asyncio.sleep(0) + return payload[0] + + return read_payload() + +def test_nested_coroutine_returned_from_completed_coroutine_keeps_capture() -> None: + read_payload = asyncio.run(make_nested_coroutine()) + assert asyncio.run(read_payload) == 11 + +[file asyncio/__init__.pyi] +from typing import Awaitable, TypeVar + +T = TypeVar("T") + +def run(x: Awaitable[T]) -> T: ... +async def sleep(t: float) -> None: ... + +[typing fixtures/typing-full.pyi] + [case testBorrowedFinalAttrAcrossAsyncComprehension] import asyncio from typing import Final diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index 4e8b67a0061a8..56ad3673e2896 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -2865,6 +2865,58 @@ def test_rebind_inherited_via_setattr() -> None: assert d.x == 1 assert d.y == 2 +[case testFinalRefcountedAttributeRead] +# Exercises reading Final reference-counted ('PyObject *') attributes, which on +# free-threaded builds use a faster read path (CPy_GetAttrRefFinal) that skips the +# concurrent-writer guard, since a Final attribute is never rebound. +from typing import Final + +class C: + def __init__(self, s: str, items: list[int]) -> None: + self.s: Final = s + self.items: Final = items + + def read_s(self) -> str: + return self.s + + def read_items(self) -> list[int]: + return self.items + + def chained_len(self) -> int: + # borrowed intermediate read of a Final attr, then a call on it + return len(self.items) + +class D(C): + def __init__(self, s: str, items: list[int], t: str) -> None: + super().__init__(s, items) + self.t: Final = t + + def read_inherited(self) -> str: + return self.s + +def test_read_final_object_attrs() -> None: + c = C("hello", [1, 2, 3]) + # Read via getter (property access) and via native method bodies. + assert c.s == "hello" + assert c.read_s() == "hello" + assert c.items == [1, 2, 3] + assert c.read_items() == [1, 2, 3] + assert c.chained_len() == 3 + # Reading repeatedly must not corrupt the refcount / free the value. + for _ in range(1000): + assert c.read_s() == "hello" + assert c.read_items() == [1, 2, 3] + assert c.s == "hello" + assert c.read_items() is c.items + +def test_read_inherited_final_attr() -> None: + d = D("a", [4, 5], "b") + assert d.s == "a" + assert d.t == "b" + assert d.read_s() == "a" + assert d.read_inherited() == "a" + assert d.read_items() == [4, 5] + [case testClassDerivedFromIntEnum] from enum import IntEnum, auto diff --git a/mypyc/test-data/run-generators.test b/mypyc/test-data/run-generators.test index a23af2ed6eea7..f4cef93ea8f3e 100644 --- a/mypyc/test-data/run-generators.test +++ b/mypyc/test-data/run-generators.test @@ -1,7 +1,7 @@ # Test cases for generators and yield (compile and run) [case testYield] -from typing import Generator, Iterable, Union, Tuple, Dict +from typing import Callable, Dict, Generator, Iterable, Iterator, Tuple, Union def yield_three_times() -> Iterable[int]: yield 1 @@ -87,6 +87,19 @@ def return_tuple() -> Generator[int, None, Tuple[int, int]]: yield 0 return 1, 2 +def call_lambda(fn: Callable[[], None]) -> None: + fn() + +def get_iterator() -> Iterator[str]: + call_lambda(lambda: None) + yield "" + +def yield_twice_via_generator_reuse() -> Iterator[str]: + identified_imports = get_iterator() + yield from identified_imports + for identified_import in identified_imports: + yield identified_import + [file driver.py] from native import ( yield_three_times, @@ -99,6 +112,7 @@ from native import ( A, return_tuple, yield_dict_methods, + yield_twice_via_generator_reuse, ) from testutil import run_generator from collections import defaultdict @@ -114,6 +128,7 @@ assert run_generator(A(0).generator()) == ((0,), None) assert run_generator(return_tuple()) == ((0,), (1, 2)) assert run_generator(yield_dict_methods({}, {}, {})) == ((), None) assert run_generator(yield_dict_methods({1: 2}, {3: 4}, {5: 6})) == ((1, 3, 4, 6), None) +assert run_generator(yield_twice_via_generator_reuse()) == (("",), None) dd = defaultdict(int, {0: 1}) assert run_generator(yield_dict_methods(dd, dd, dd)) == ((0, 0, 1, 1), None) diff --git a/mypyc/test-data/run-python37.test b/mypyc/test-data/run-python37.test index 61d428c17a441..1245ad516a2d9 100644 --- a/mypyc/test-data/run-python37.test +++ b/mypyc/test-data/run-python37.test @@ -74,8 +74,15 @@ class Person5: friends: Set[str] = field(default_factory=set) parents: FrozenSet[str] = frozenset() +@dataclass +class HasFactoryDefault: + x: dict[str, int] = field(default_factory=dict) + +class InheritsDataclassInit(HasFactoryDefault): + pass + [file other.py] -from native import Person1, Person1b, Person2, Person3, Person4, Person5, testBool +from native import Person1, Person1b, Person2, Person3, Person4, Person5, testBool, InheritsDataclassInit i1 = Person1(age = 5, name = 'robot') assert i1.age == 5 assert i1.name == 'robot' @@ -125,6 +132,9 @@ assert Person1.__annotations__ == {'age': int, 'name': str} assert Person2.__annotations__ == {'age': int, 'name': str} assert Person5.__annotations__ == {'weight': float, 'friends': set, 'parents': frozenset} +v = InheritsDataclassInit() +assert isinstance(v.x, dict) +assert v.x == {} [file driver.py] import sys diff --git a/test-data/unit/check-tuples.test b/test-data/unit/check-tuples.test index bfbd2e631f5d8..79c6f630f4d98 100644 --- a/test-data/unit/check-tuples.test +++ b/test-data/unit/check-tuples.test @@ -426,6 +426,42 @@ class A: pass class B: pass [builtins fixtures/tuple.pyi] +[case testMultipleAssignmentWithOverloadReinferredAsHomogeneousTuple] +from typing import Any, TypeVar, overload + +T = TypeVar("T") + +@overload +def f(value: T) -> tuple[T, int]: ... +@overload +def f(*values: object) -> tuple[Any, ...]: ... +def f(*values: object, **kwargs: object) -> object: ... + +def g() -> None: + first: str + first, second = f(1) # E: Incompatible types in assignment (expression has type "int", variable has type "str") + reveal_type(second) # N: Revealed type is "builtins.int" + + last: str + _, last = f(1) # E: Incompatible types in assignment (expression has type "int", variable has type "str") +[builtins fixtures/tuple.pyi] + +[case testMultipleAssignmentWithOverloadReinferredAsNonTuple] +from typing import TypeVar, overload + +T = TypeVar("T") + +@overload +def f(value: T) -> tuple[T]: ... +@overload +def f(*values: object) -> int: ... +def f(*values: object, **kwargs: object) -> object: ... + +def g() -> None: + value: str + (value,) = f(1) # E: Incompatible types in assignment (expression has type "int", variable has type "str") +[builtins fixtures/tuple.pyi] + [case testMultipleAssignmentWithSquareBracketTuples] # flags: --no-strict-optional from typing import Tuple