From 0df8c8428bca52dd7aefb416ca4b6c758f12c256 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:21:11 +0900 Subject: [PATCH 01/94] __length_hint__ (#6636) * Initial plan * fix: prevent iterator length_hint deadlock Co-authored-by: youknowone <69878+youknowone@users.noreply.github.com> * fix * Auto-format: cargo fmt --all --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- crates/vm/src/builtins/iter.rs | 32 ++++++++----- extra_tests/snippets/builtin_iter.py | 71 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 extra_tests/snippets/builtin_iter.py diff --git a/crates/vm/src/builtins/iter.rs b/crates/vm/src/builtins/iter.rs index e9f1516b5..4e29df583 100644 --- a/crates/vm/src/builtins/iter.rs +++ b/crates/vm/src/builtins/iter.rs @@ -184,17 +184,27 @@ impl PySequenceIterator { } #[pymethod] - fn __length_hint__(&self, vm: &VirtualMachine) -> PyObjectRef { - let internal = self.internal.lock(); - if let IterStatus::Active(obj) = &internal.status { - let seq = obj.sequence_unchecked(); - seq.length(vm).map_or_else( - |_| vm.ctx.not_implemented(), - |x| PyInt::from(x).into_pyobject(vm), - ) - } else { - PyInt::from(0).into_pyobject(vm) - } + fn __length_hint__(&self, vm: &VirtualMachine) -> PyResult { + vm.with_recursion("in __length_hint__", || { + let (obj, position) = { + let internal = self.internal.lock(); + match &internal.status { + IterStatus::Active(obj) => (Some(obj.clone()), internal.position), + IterStatus::Exhausted => (None, 0), + } + }; + if let Some(obj) = obj { + let seq = obj.sequence_unchecked(); + match seq.length_opt(vm) { + Some(len) => { + len.map(|len| PyInt::from(len.saturating_sub(position)).into_pyobject(vm)) + } + None => Ok(vm.ctx.not_implemented()), + } + } else { + Ok(PyInt::from(0).into_pyobject(vm)) + } + }) } #[pymethod] diff --git a/extra_tests/snippets/builtin_iter.py b/extra_tests/snippets/builtin_iter.py new file mode 100644 index 000000000..02d469a47 --- /dev/null +++ b/extra_tests/snippets/builtin_iter.py @@ -0,0 +1,71 @@ +import queue +import threading + + +def make_iterator(): + holder = {} + + class Evil: + def __getitem__(self, index): + if index == 0: + return 0 + raise IndexError + + def __len__(self): + return holder["it"].__length_hint__() + + obj = Evil() + holder["it"] = iter(obj) + return holder["it"] + + +it = make_iterator() +q = queue.Queue() + + +def run(): + try: + it.__length_hint__() + except Exception as exc: # noqa: BLE001 + q.put(exc) + else: + q.put(None) + + +t = threading.Thread(target=run, daemon=True) +t.start() +t.join(1) + +assert not t.is_alive(), "iterator.__length_hint__ deadlocked" +err = q.get_nowait() +assert isinstance(err, RecursionError) + + +class NoLen: + def __getitem__(self, index): + if index < 3: + return index + raise IndexError + + +no_len_it = iter(NoLen()) +assert no_len_it.__length_hint__() is NotImplemented +next(no_len_it) +assert no_len_it.__length_hint__() is NotImplemented + + +class Seq: + def __init__(self): + self.items = [1, 2, 3] + + def __getitem__(self, index): + return self.items[index] + + def __len__(self): + return len(self.items) + + +seq_it = iter(Seq()) +assert seq_it.__length_hint__() == 3 +next(seq_it) +assert seq_it.__length_hint__() == 2 From 3364c47a4b3da385b80206cb7e4fe2e30108a18d Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:12:30 +0900 Subject: [PATCH 02/94] Fix miri UB: use transmute_copy for function pointer identity checks (#8432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nightly miri now flags `fn_ptr as usize` and direct `fn_ptr == fn_ptr` as UB because both paths go through `FnPtr::addr()`, which attempts to dereference a function pointer's provenance — function items have no backing allocation in miri's model. Add `fn_addr(f: T) -> usize` that uses `transmute_copy` to read the address as plain integer bytes without triggering provenance checks. Replace all `f as usize` slot comparison patterns across the codebase with `fn_addr(f)`. Also add `-Zmiri-permissive-provenance` to CI MIRIFLAGS as a safety net for any remaining integer-pointer round-trips elsewhere. Assisted-by: Claude --- .github/workflows/ci.yaml | 5 ++++- crates/vm/src/builtins/object.rs | 8 ++++++-- crates/vm/src/builtins/set.rs | 8 ++++++-- crates/vm/src/builtins/type.rs | 6 ++++-- crates/vm/src/class.rs | 12 +++++------- crates/vm/src/frame.rs | 22 ++++++++++------------ crates/vm/src/stdlib/_thread.rs | 4 ++-- crates/vm/src/types/slot.rs | 29 +++++++++++++++++++++++++---- crates/vm/src/types/slot_defs.rs | 4 ++-- crates/vm/src/vm/method.rs | 4 ++-- crates/vm/src/vm/vm_ops.rs | 12 ++++++------ 11 files changed, 72 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7d0ab2e7c..dc7a1a3e7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -648,7 +648,10 @@ jobs: env: # miri-ignore-leaks because the type-object circular reference means that there will always be # a memory leak, at least until we have proper cyclic gc - MIRIFLAGS: "-Zmiri-ignore-leaks" + # miri-permissive-provenance because function pointer identity checks (slot comparisons) + # cast fn pointers to usize, which strips provenance — this is the standard pattern for + # fn pointer comparison in Rust and not a soundness issue + MIRIFLAGS: "-Zmiri-ignore-leaks -Zmiri-permissive-provenance" wasm: if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }} diff --git a/crates/vm/src/builtins/object.rs b/crates/vm/src/builtins/object.rs index 633eaf48a..151b753b2 100644 --- a/crates/vm/src/builtins/object.rs +++ b/crates/vm/src/builtins/object.rs @@ -122,8 +122,12 @@ impl Initializer for PyBaseObject { let typ = zelf.class(); let object_type = &vm.ctx.types.object_type; - let typ_init = typ.slots.init.load().map(|f| f as usize); - let object_init = object_type.slots.init.load().map(|f| f as usize); + let typ_init = typ.slots.init.load().map(|f| crate::types::fn_addr(f)); + let object_init = object_type + .slots + .init + .load() + .map(|f| crate::types::fn_addr(f)); // if (type->tp_init != object_init) → first error if typ_init != object_init { diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index 1481bc1b3..cd724abc5 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -960,7 +960,11 @@ impl Constructor for PyFrozenSet { fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { let is_exact_frozenset = cls.is(vm.ctx.types.frozenset_type); let is_frozenset_init = { - let cls_init = cls.slots.init.load().map(|init| init as usize); + let cls_init = cls + .slots + .init + .load() + .map(|init| crate::types::fn_addr(init)); let frozenset_init = vm .ctx .types @@ -968,7 +972,7 @@ impl Constructor for PyFrozenSet { .slots .init .load() - .map(|init| init as usize); + .map(|init| crate::types::fn_addr(init)); cls_init == frozenset_init }; diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 0ccfe155d..e2af7d21d 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -2829,7 +2829,8 @@ impl Callable for PyType { // path incorrectly. if zelf.slots.init.load().is_none() && !zelf.is(vm.ctx.types.type_type) - && slot_new as usize != crate::types::new_wrapper as crate::types::NewFunc as usize + && crate::types::fn_addr(slot_new) + != crate::types::fn_addr(crate::types::new_wrapper as crate::types::NewFunc) { return slot_new(zelf.to_owned(), args, vm); } @@ -3066,7 +3067,8 @@ pub(crate) fn call_slot_new( // Check if staticbase's tp_new differs from typ's tp_new let typ_new = typ.slots.new.load(); let staticbase_new = staticbase.slots.new.load(); - if typ_new.map(|f| f as usize) != staticbase_new.map(|f| f as usize) { + if typ_new.map(|f| crate::types::fn_addr(f)) != staticbase_new.map(|f| crate::types::fn_addr(f)) + { return Err(vm.new_type_error(format!( "{}.__new__({}) is not safe, use {}.__new__()", typ.slot_name(), diff --git a/crates/vm/src/class.rs b/crates/vm/src/class.rs index 364ae7211..d9f8b848d 100644 --- a/crates/vm/src/class.rs +++ b/crates/vm/src/class.rs @@ -5,7 +5,7 @@ use crate::{ builtins::{PyBaseObject, PyType, PyTypeRef, descriptor::PyWrapper}, function::PyMethodDef, object::Py, - types::{PyTypeFlags, PyTypeSlots, SLOT_DEFS, hash_not_implemented}, + types::{PyTypeFlags, PyTypeSlots, SLOT_DEFS, fn_addr, hash_not_implemented}, vm::Context, }; use rustpython_common::static_cell; @@ -24,11 +24,9 @@ pub fn add_operators(class: &'static Py, ctx: &Context) { // Special handling for __hash__ = None if def.name == "__hash__" - && class - .slots - .hash - .load() - .is_some_and(|h| h as usize == hash_not_implemented as *const () as usize) + && class.slots.hash.load().is_some_and(|h| { + fn_addr(h) == fn_addr(hash_not_implemented as crate::types::HashFunc) + }) { class.set_attr(ctx.names.__hash__, ctx.none.clone().into()); continue; @@ -205,7 +203,7 @@ pub trait PyClassImpl: PyClassDef { let object_new = ctx.types.object_type.slots.new.load(); let is_object_itself = core::ptr::eq(class, ctx.types.object_type); let is_inherited_from_object = !is_object_itself - && object_new.is_some_and(|obj_new| slot_new as usize == obj_new as usize); + && object_new.is_some_and(|obj_new| fn_addr(slot_new) == fn_addr(obj_new)); if !is_inherited_from_object { let bound_new = diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 5c21ad29c..a05fc7184 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -8932,11 +8932,10 @@ impl ExecutingFrame<'_> { } // Only specialize if getattro is the default (PyBaseObject::getattro) - let is_default_getattro = cls - .slots - .getattro - .load() - .is_some_and(|f| f as usize == PyBaseObject::getattro as *const () as usize); + let is_default_getattro = cls.slots.getattro.load().is_some_and(|f| { + crate::types::fn_addr(f) + == crate::types::fn_addr(PyBaseObject::getattro as crate::types::GetattroFunc) + }); if !is_default_getattro { let getattribute = cls.get_attr(identifier!(_vm, __getattribute__)); if !oparg.is_method() @@ -9953,8 +9952,8 @@ impl ExecutingFrame<'_> { let cls_alloc = cls.slots.alloc.load(); if let (Some(cls_new_fn), Some(obj_new_fn), Some(cls_alloc_fn), Some(obj_alloc_fn)) = (cls_new, object_new, cls_alloc, object_alloc) - && cls_new_fn as usize == obj_new_fn as usize - && cls_alloc_fn as usize == obj_alloc_fn as usize + && crate::types::fn_addr(cls_new_fn) == crate::types::fn_addr(obj_new_fn) + && crate::types::fn_addr(cls_alloc_fn) == crate::types::fn_addr(obj_alloc_fn) { if type_version == 0 { unsafe { @@ -10614,11 +10613,10 @@ impl ExecutingFrame<'_> { } // Only specialize if setattr is the default (generic_setattr) - let is_default_setattr = cls - .slots - .setattro - .load() - .is_some_and(|f| f as usize == PyBaseObject::slot_setattro as *const () as usize); + let is_default_setattr = cls.slots.setattro.load().is_some_and(|f| { + crate::types::fn_addr(f) + == crate::types::fn_addr(PyBaseObject::slot_setattro as crate::types::SetattroFunc) + }); if !is_default_setattr { unsafe { self.code.instructions.write_adaptive_counter( diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index e9d75dc02..64e5c596b 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -997,8 +997,8 @@ pub(crate) mod _thread { .slots .init .load() - .map(|init| init as usize); - (Some(cls_init as usize) != object_init).then_some(cls_init) + .map(|init| crate::types::fn_addr(init)); + (Some(crate::types::fn_addr(cls_init)) != object_init).then_some(cls_init) } fn create_dict(&self, vm: &VirtualMachine) -> (PyDictRef, bool) { diff --git a/crates/vm/src/types/slot.rs b/crates/vm/src/types/slot.rs index a5d7a41d3..d834406cf 100644 --- a/crates/vm/src/types/slot.rs +++ b/crates/vm/src/types/slot.rs @@ -877,8 +877,8 @@ impl PyType { .iter() .find(|cls| cls.attributes.read().contains_key(name)) .is_some_and(|cls| { - cls.slots.new.load().map(|f| f as usize) - == Some(new_wrapper as NewFunc as usize) + cls.slots.new.load().map(|f| fn_addr(f)) + == Some(fn_addr(new_wrapper as NewFunc)) }) }; if needs_wrapper { @@ -953,7 +953,7 @@ impl PyType { self.slots.setattro.store(Some(setattro_wrapper)); } (NativeSlot(set), NativeSlot(del)) => { - let func = if set as usize == del as usize { + let func = if fn_addr(set) == fn_addr(del) { set } else { setattro_wrapper @@ -988,7 +988,7 @@ impl PyType { self.slots.descr_set.store(Some(descr_set_wrapper)); } (NativeSlot(set), NativeSlot(delete)) => { - let func = if set as usize == delete as usize { + let func = if fn_addr(set) == fn_addr(delete) { set } else { descr_set_wrapper @@ -2171,3 +2171,24 @@ where debug_assert!(prev.is_some()); // slot_iter would be set } } + +/// Extract the raw address of a function pointer as `usize` without +/// triggering miri's "pointer not dereferenceable" UB. +/// +/// The standard `fn_ptr as usize` cast goes through `FnPtr::addr()` +/// which attempts to dereference the function pointer's provenance — +/// miri considers this UB for function items. `transmute_copy` bypasses +/// that path and reads the address as plain integer bytes. +/// +/// The result is suitable for identity comparison only: two function +/// pointers with the same address are the same function. The converse +/// is not always guaranteed (the compiler may merge identical function +/// bodies), but this matches CPython's slot comparison semantics. +#[inline(always)] +pub(crate) fn fn_addr(f: T) -> usize { + assert!( + core::mem::size_of::() == core::mem::size_of::(), + "fn_addr: T must be pointer-sized" + ); + unsafe { core::mem::transmute_copy::(&f) } +} diff --git a/crates/vm/src/types/slot_defs.rs b/crates/vm/src/types/slot_defs.rs index 34d62147e..69c7bb610 100644 --- a/crates/vm/src/types/slot_defs.rs +++ b/crates/vm/src/types/slot_defs.rs @@ -2,7 +2,7 @@ //! //! This module provides a centralized array of all slot definitions, -use super::{PyComparisonOp, PyTypeSlots}; +use super::{PyComparisonOp, PyTypeSlots, fn_addr}; use crate::builtins::descriptor::SlotFunc; /// Slot operation type @@ -609,7 +609,7 @@ impl SlotAccessor { && let Some(base_val) = base.slots.init.load() { let slot_defined = base.base.deref().is_none_or(|bb| { - bb.slots.init.load().map(|v| v as usize) != Some(base_val as usize) + bb.slots.init.load().map(|v| fn_addr(v)) != Some(fn_addr(base_val)) }); if slot_defined { typ.slots.init.store(Some(base_val)); diff --git a/crates/vm/src/vm/method.rs b/crates/vm/src/vm/method.rs index 9e4f71855..2beeb95ce 100644 --- a/crates/vm/src/vm/method.rs +++ b/crates/vm/src/vm/method.rs @@ -6,7 +6,7 @@ use crate::{ builtins::{PyBaseObject, PyStr, PyStrInterned, descriptor::PyMethodDescriptor}, function::{IntoFuncArgs, PyMethodFlags}, object::{AsObject, Py, PyObject, PyObjectRef, PyResult}, - types::PyTypeFlags, + types::{GetattroFunc, PyTypeFlags, fn_addr}, }; #[derive(Debug)] @@ -22,7 +22,7 @@ impl PyMethod { pub(crate) fn get(obj: PyObjectRef, name: &Py, vm: &VirtualMachine) -> PyResult { let cls = obj.class(); let getattro = cls.slots.getattro.load().unwrap(); - if getattro as usize != PyBaseObject::getattro as *const () as usize { + if fn_addr(getattro) != fn_addr(PyBaseObject::getattro as GetattroFunc) { return obj.get_attr(name, vm).map(Self::Attribute); } diff --git a/crates/vm/src/vm/vm_ops.rs b/crates/vm/src/vm/vm_ops.rs index 8cb00d4a1..692444fc7 100644 --- a/crates/vm/src/vm/vm_ops.rs +++ b/crates/vm/src/vm/vm_ops.rs @@ -180,13 +180,13 @@ impl VirtualMachine { // Number slots are inherited, direct access is O(1) let slot_a = class_a.slots.as_number.left_binary_op(op_slot); - let slot_a_addr = slot_a.map(|x| x as usize); + let slot_a_addr = slot_a.map(|x| crate::types::fn_addr(x)); let mut slot_b = None; let left_b_addr = if class_a.is(class_b) { slot_a_addr } else { let slot_bb = class_b.slots.as_number.right_binary_op(op_slot); - if slot_bb.map(|x| x as usize) != slot_a_addr { + if slot_bb.map(|x| crate::types::fn_addr(x)) != slot_a_addr { slot_b = slot_bb; } @@ -194,7 +194,7 @@ impl VirtualMachine { .slots .as_number .left_binary_op(op_slot) - .map(|x| x as usize) + .map(|x| crate::types::fn_addr(x)) }; if let Some(slot_a) = slot_a { @@ -302,13 +302,13 @@ impl VirtualMachine { // Number slots are inherited, direct access is O(1) let slot_a = class_a.slots.as_number.left_ternary_op(op_slot); - let slot_a_addr = slot_a.map(|x| x as usize); + let slot_a_addr = slot_a.map(|x| crate::types::fn_addr(x)); let mut slot_b = None; let left_b_addr = if class_a.is(class_b) { slot_a_addr } else { let slot_bb = class_b.slots.as_number.right_ternary_op(op_slot); - if slot_bb.map(|x| x as usize) != slot_a_addr { + if slot_bb.map(|x| crate::types::fn_addr(x)) != slot_a_addr { slot_b = slot_bb; } @@ -316,7 +316,7 @@ impl VirtualMachine { .slots .as_number .left_ternary_op(op_slot) - .map(|x| x as usize) + .map(|x| crate::types::fn_addr(x)) }; if let Some(slot_a) = slot_a { From 4ad64a99a2ae5ddd761d665cfe92fad548e1fa1d Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:38:44 +0900 Subject: [PATCH 03/94] ssl: store SSL socket owners as weak references (#8423) Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_asyncio/test_ssl.py | 2 -- Lib/test/test_ssl.py | 1 - crates/stdlib/src/ssl.rs | 39 ++++++++++++++++++++++++------- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_asyncio/test_ssl.py b/Lib/test/test_asyncio/test_ssl.py index 932b1dace..ca15fc3bd 100644 --- a/Lib/test/test_asyncio/test_ssl.py +++ b/Lib/test/test_asyncio/test_ssl.py @@ -738,7 +738,6 @@ class TestSSL(test_utils.TestCase): asyncio.wait_for(client(srv.addr), timeout=support.SHORT_TIMEOUT)) - @unittest.expectedFailure # TODO: RUSTPYTHON; - gc.collect() doesn't release SSLContext properly def test_create_connection_memory_leak(self): HELLO_MSG = b'1' * self.PAYLOAD_SIZE @@ -1617,7 +1616,6 @@ class TestSSL(test_utils.TestCase): else: self.fail('Unexpected ResourceWarning: {}'.format(cm.warning)) - @unittest.expectedFailure # TODO: RUSTPYTHON; - gc.collect() doesn't release SSLContext properly def test_handshake_timeout_handler_leak(self): s = socket.socket(socket.AF_INET) s.bind(('127.0.0.1', 0)) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 107aec7e6..8dad1ba73 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -1479,7 +1479,6 @@ class ContextTests(unittest.TestCase): ctx.set_servername_callback(None) ctx.set_servername_callback(dummycallback) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Expected 'mock' to not have been called. Called 1 times. def test_sni_callback_on_dead_references(self): # See https://github.com/python/cpython/issues/146080. c_ctx = make_test_context() diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 7c4257877..454fdcba8 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -48,7 +48,7 @@ mod _ssl { VirtualMachine, builtins::{ PyBaseExceptionRef, PyByteArray, PyBytesRef, PyListRef, PyStrRef, PyType, - PyTypeRef, PyUtf8StrRef, + PyTypeRef, PyUtf8StrRef, PyWeak, }, convert::IntoPyException, function::{ @@ -1920,7 +1920,12 @@ mod _ssl { connection: PyMutex::new(None), handshake_done: PyMutex::new(false), session_was_reused: PyMutex::new(false), - owner: PyRwLock::new(args.owner.into_option()), + owner: PyRwLock::new( + args.owner + .into_option() + .map(|o| o.downgrade(None, vm)) + .transpose()?, + ), // Filter out Python None objects - only store actual SSLSession objects session: PyRwLock::new(args.session.into_option().filter(|s| !vm.is_none(s))), incoming_bio: None, @@ -1997,7 +2002,12 @@ mod _ssl { connection: PyMutex::new(None), handshake_done: PyMutex::new(false), session_was_reused: PyMutex::new(false), - owner: PyRwLock::new(args.owner.into_option()), + owner: PyRwLock::new( + args.owner + .into_option() + .map(|o| o.downgrade(None, vm)) + .transpose()?, + ), // Filter out Python None objects - only store actual SSLSession objects session: PyRwLock::new(args.session.into_option().filter(|s| !vm.is_none(s))), incoming_bio: Some(args.incoming), @@ -2377,7 +2387,7 @@ mod _ssl { #[pytraverse(skip)] session_was_reused: PyMutex, // Owner (SSLSocket instance that owns this _SSLSocket) - owner: PyRwLock>, + owner: PyRwLock>>, // Session for resumption session: PyRwLock>, // MemoryBIO mode (optional) @@ -2734,7 +2744,19 @@ mod _ssl { return Ok(()); }; - let ssl_sock = self.owner.read().clone().unwrap_or_else(|| vm.ctx.none()); + let ssl_sock = self + .owner + .read() + .as_ref() + .and_then(|owner| owner.upgrade()) + .ok_or_else(|| { + super::compat::SslError::create_ssl_error_with_reason( + vm, + Some("SSL"), + "CALLBACK_FAILED", + "[SSL: CALLBACK_FAILED] callback failed", + ) + })?; let server_name_py: PyObjectRef = match sni_name { Some(name) => vm.ctx.new_str(name.to_string()).into(), None => vm.ctx.none(), @@ -3955,12 +3977,13 @@ mod _ssl { #[pygetset] fn owner(&self) -> Option { - self.owner.read().clone() + self.owner.read().as_ref().and_then(|owner| owner.upgrade()) } #[pygetset(setter)] - fn set_owner(&self, owner: PyObjectRef, _vm: &VirtualMachine) { - *self.owner.write() = Some(owner); + fn set_owner(&self, owner: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + *self.owner.write() = Some(owner.downgrade(None, vm)?); + Ok(()) } #[pygetset] From 74254421e48e887333780a82f39943e4c12fe204 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:00:04 +0900 Subject: [PATCH 04/94] Split InterpreterFrame hot/cold fields into FrameColdData (#8434) Move 10 rarely-used fields (trace, trace_lines, trace_opcodes, temporary_refs, f_locals_hidden_overlay, f_extra_locals, escaped, retained_back, pending_stack_pops, pending_unwind_from_stack) from InterpreterFrame into a lazily-allocated FrameColdData struct. InterpreterFrame now carries a single UnsafeCell>> (8 bytes) instead of ~200+ bytes of cold fields. The cold() accessor allocates on first access; frames that never trigger tracing or debugging pay no allocation cost. GC traversal skips cold data when it has not been allocated. Assisted-by: Claude --- crates/vm/src/builtins/frame.rs | 44 ++++--- crates/vm/src/builtins/type.rs | 7 +- crates/vm/src/frame.rs | 185 ++++++++++++++++------------- crates/vm/src/object/ext.rs | 8 +- crates/vm/src/protocol/callable.rs | 9 +- crates/vm/src/stdlib/_thread.rs | 4 +- crates/vm/src/vm/mod.rs | 14 ++- 7 files changed, 154 insertions(+), 117 deletions(-) diff --git a/crates/vm/src/builtins/frame.rs b/crates/vm/src/builtins/frame.rs index 70ff99979..94ec827b7 100644 --- a/crates/vm/src/builtins/frame.rs +++ b/crates/vm/src/builtins/frame.rs @@ -636,8 +636,14 @@ impl FrameObject { } else { self.iframe() }; - target.pending_stack_pops.store(pop_count as u32, Relaxed); - target.pending_unwind_from_stack.store(start_stack, Relaxed); + target + .cold() + .pending_stack_pops + .store(pop_count as u32, Relaxed); + target + .cold() + .pending_unwind_from_stack + .store(start_stack, Relaxed); target.lasti.store(best_addr as u32, Relaxed); Ok(()) } @@ -647,9 +653,9 @@ impl FrameObject { // Read from live source iframe if available. let live = self.find_live_source_iframe(); let trace = if !live.is_null() { - unsafe { &*live }.trace.lock().clone() + unsafe { &*live }.cold().trace.lock().clone() } else { - self.iframe().trace.lock().clone() + self.iframe().cold().trace.lock().clone() }; trace.unwrap_or_else(|| vm.ctx.none()) } @@ -667,13 +673,13 @@ impl FrameObject { PySetterValue::Delete => None, }; // Set on the materialized FrameObject. - (*self.iframe().trace.lock()).clone_from(&trace); + (*self.iframe().cold().trace.lock()).clone_from(&trace); // Also propagate to the live source iframe if this is a // materialized copy of a stack-allocated frame, so pdb's // f_trace assignment takes effect on the executing frame. let live = self.find_live_source_iframe(); if !live.is_null() { - *unsafe { &*live }.trace.lock() = trace; + *unsafe { &*live }.cold().trace.lock() = trace; } } @@ -682,7 +688,7 @@ impl FrameObject { fn f_trace_lines(vm: &VirtualMachine, zelf: PyObjectRef) -> PyResult { let zelf: FrameObjectRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); - let boxed = zelf.iframe().trace_lines.lock(); + let boxed = zelf.iframe().cold().trace_lines.lock(); Ok(vm.ctx.new_bool(*boxed).into()) } @@ -701,11 +707,11 @@ impl FrameObject { .map_err(|_| vm.new_type_error("attribute value type must be bool"))?; let val = !value.as_bigint().is_zero(); - *zelf.iframe().trace_lines.lock() = val; + *zelf.iframe().cold().trace_lines.lock() = val; // Propagate to live source iframe. let live = zelf.find_live_source_iframe(); if !live.is_null() { - *unsafe { &*live }.trace_lines.lock() = val; + *unsafe { &*live }.cold().trace_lines.lock() = val; } Ok(()) @@ -718,7 +724,7 @@ impl FrameObject { #[pymember(type = "bool")] fn f_trace_opcodes(vm: &VirtualMachine, zelf: PyObjectRef) -> PyResult { let zelf: FrameObjectRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); - let trace_opcodes = zelf.iframe().trace_opcodes.lock(); + let trace_opcodes = zelf.iframe().cold().trace_opcodes.lock(); Ok(vm.ctx.new_bool(*trace_opcodes).into()) } @@ -737,11 +743,11 @@ impl FrameObject { .map_err(|_| vm.new_type_error("attribute value type must be bool"))?; let val = !value.as_bigint().is_zero(); - *zelf.iframe().trace_opcodes.lock() = val; + *zelf.iframe().cold().trace_opcodes.lock() = val; // Propagate to live source iframe. let live = zelf.find_live_source_iframe(); if !live.is_null() { - *unsafe { &*live }.trace_opcodes.lock() = val; + *unsafe { &*live }.cold().trace_opcodes.lock() = val; } // TODO: Implement the equivalent of _PyEval_SetOpcodeTrace() @@ -798,10 +804,10 @@ impl Py { self.clear_stack_and_cells(); // Clear temporary refs - self.iframe().temporary_refs.lock().clear(); - self.iframe().f_locals_hidden_overlay.lock().take(); - self.iframe().f_extra_locals.lock().take(); - self.iframe().retained_back.lock().take(); + self.iframe().cold().temporary_refs.lock().clear(); + self.iframe().cold().f_locals_hidden_overlay.lock().take(); + self.iframe().cold().f_extra_locals.lock().take(); + self.iframe().cold().retained_back.lock().take(); Ok(()) } @@ -853,7 +859,7 @@ impl Py { } if prev.is_null() { // Check retained_back for frames whose callers have returned - let retained = self.iframe().retained_back.lock().clone(); + let retained = self.iframe().cold().retained_back.lock().clone(); if let Some(frame) = retained { frame.mark_escaped(); return Some(frame); @@ -879,7 +885,7 @@ impl Py { } // The caller already returned — check retained_back - let retained = self.iframe().retained_back.lock().clone(); + let retained = self.iframe().cold().retained_back.lock().clone(); if let Some(frame) = retained { frame.mark_escaped(); return Some(frame); @@ -906,7 +912,7 @@ impl Py { let iframe = unsafe { &*cur }; let fo = iframe.materialize(vm).to_owned(); if let Some(child) = child_fo.take() { - let mut guard = child.iframe().retained_back.lock(); + let mut guard = child.iframe().cold().retained_back.lock(); if guard.is_none() { *guard = Some(fo.clone()); } diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index e2af7d21d..177627075 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -1513,7 +1513,12 @@ impl PyType { // temporary refs so they never see a dangling pointer. let keep_alive = |type_ref: PyTypeRef, retired: &mut Vec| { if let Some(frame) = vm.current_frame() { - frame.iframe().temporary_refs.lock().push(type_ref.into()); + frame + .iframe() + .cold() + .temporary_refs + .lock() + .push(type_ref.into()); } else { retired.push(type_ref.into()); } diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index a05fc7184..bf276ec45 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -792,6 +792,39 @@ unsafe impl Traverse for FrameLocals { } } +/// Cold fields of InterpreterFrame that are only accessed during tracing, +/// debugging, frame inspection, or GC. Lazily allocated on first access +/// to keep the hot InterpreterFrame small. +pub(crate) struct FrameColdData { + pub trace: PyMutex>, + pub trace_lines: PyMutex, + pub trace_opcodes: PyMutex, + pub temporary_refs: PyMutex>, + pub f_locals_hidden_overlay: PyMutex>, + pub f_extra_locals: PyMutex>, + pub escaped: atomic::AtomicBool, + pub retained_back: PyMutex>, + pub pending_stack_pops: PyAtomic, + pub pending_unwind_from_stack: PyAtomic, +} + +impl Default for FrameColdData { + fn default() -> Self { + Self { + trace: PyMutex::new(None), + trace_lines: PyMutex::new(true), + trace_opcodes: PyMutex::new(false), + temporary_refs: PyMutex::new(Vec::new()), + f_locals_hidden_overlay: PyMutex::new(None), + f_extra_locals: PyMutex::new(None), + escaped: atomic::AtomicBool::new(false), + retained_back: PyMutex::new(None), + pending_stack_pops: Default::default(), + pending_unwind_from_stack: Default::default(), + } + } +} + /// Lightweight execution frame. Not a PyObject. /// Analogous to CPython's `_PyInterpreterFrame`. /// @@ -813,18 +846,10 @@ pub struct InterpreterFrame { /// index of last instruction ran pub lasti: PyAtomic, - /// Per-frame tracer function. `None` means no per-frame trace is set - /// (equivalent to `f_trace = None` in Python). This avoids a refcounted - /// None clone on every frame init. - pub trace: PyMutex>, /// Previous line number for LINE event suppression. pub(crate) prev_line: core::cell::Cell, - // member - pub trace_lines: PyMutex, - pub trace_opcodes: PyMutex, - pub temporary_refs: PyMutex>, /// Back-reference to owning generator/coroutine/async generator. /// Borrowed reference (not ref-counted) to avoid Generator↔FrameObject cycle. /// Cleared by the generator's Drop impl. @@ -836,32 +861,14 @@ pub struct InterpreterFrame { /// Used by `frame.clear()` to reject clearing an executing frame, /// even when called from a different thread. pub(crate) owner: atomic::AtomicI8, - /// Persistent overlay for `frame.f_locals` when hidden locals need a - /// snapshot separate from the backing locals mapping. - pub(crate) f_locals_hidden_overlay: PyMutex>, - /// Side storage for `f_locals` proxy keys that do not name a fast local. - /// Lazily created on first non-fast-key write. Mirrors `f_extra_locals`. - pub(crate) f_extra_locals: PyMutex>, - /// Set once a durable Python-level reference to this frame is handed out - /// (`f_locals` proxy, `sys._getframe`, `f_back`). A closed generator keeps - /// its locals alive while this is set, mirroring `frame_obj` ownership. - pub(crate) escaped: atomic::AtomicBool, - /// Strong reference to the caller frame, captured when this frame escapes - /// its execution so `f_back` still resolves after the caller returns and - /// leaves the live frame chain. - pub(crate) retained_back: PyMutex>, - /// Number of stack entries to pop after set_f_lineno returns to the - /// execution loop. set_f_lineno cannot pop directly because the - /// execution loop holds the state mutex. - pub(crate) pending_stack_pops: PyAtomic, - /// The encoded stack state that set_f_lineno wants to unwind *from*. - /// Used together with `pending_stack_pops` to identify Except entries - /// that need special exception-state handling. - pub(crate) pending_unwind_from_stack: PyAtomic, /// Pointer to the owning `Py`, or null for stack-allocated /// frames that have not been materialized yet. /// Stored as `usize` for `PyAtomic` compatibility. pub(crate) materialized: PyAtomic, + + /// Lazily-allocated cold data (tracing, debugging, frame inspection). + /// Not allocated until first access via `cold()`. + pub(crate) cold: OnceCell>, } // Raw pointers make InterpreterFrame !Send+!Sync by default. @@ -934,20 +941,11 @@ impl InterpreterFrame { locals, lasti: Radium::new(0), prev_line: core::cell::Cell::new(prev_line), - trace: PyMutex::new(None), - trace_lines: PyMutex::new(true), - trace_opcodes: PyMutex::new(false), - temporary_refs: PyMutex::new(vec![]), generator: PyAtomicBorrow::new(), previous: Radium::new(0), owner: atomic::AtomicI8::new(owner as i8), - f_locals_hidden_overlay: PyMutex::new(None), - f_extra_locals: PyMutex::new(None), - escaped: atomic::AtomicBool::new(false), - retained_back: PyMutex::new(None), - pending_stack_pops: Default::default(), - pending_unwind_from_stack: Default::default(), materialized: Radium::new(0), + cold: OnceCell::new(), } } @@ -1031,10 +1029,6 @@ impl InterpreterFrame { locals, lasti: Radium::new(self.lasti.load(Relaxed)), prev_line: core::cell::Cell::new(self.prev_line.get()), - trace: PyMutex::new(None), - trace_lines: PyMutex::new(true), - trace_opcodes: PyMutex::new(false), - temporary_refs: PyMutex::new(vec![]), generator: PyAtomicBorrow::new(), // Do NOT copy previous — it may point to stack-allocated frames // that become dangling after their call returns. The f_back chain @@ -1044,13 +1038,11 @@ impl InterpreterFrame { // If we copied Thread from the source iframe, frame.clear() would // reject the frame with "cannot clear an executing frame". owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), - f_locals_hidden_overlay: PyMutex::new(None), - f_extra_locals: PyMutex::new(None), - escaped: atomic::AtomicBool::new(true), - retained_back: PyMutex::new(None), - pending_stack_pops: Default::default(), - pending_unwind_from_stack: Default::default(), materialized: Radium::new(0), + cold: OnceCell::from(Box::new(FrameColdData { + escaped: atomic::AtomicBool::new(true), + ..FrameColdData::default() + })), }; let frame_obj = FrameObject { @@ -1079,7 +1071,10 @@ impl InterpreterFrame { // is no longer executing and temporary_refs is cleared — at that // point the FrameObject is self-sustaining and GC can safely // traverse and collect it. - self.temporary_refs.lock().push(frame_ref.clone().into()); + self.cold() + .temporary_refs + .lock() + .push(frame_ref.clone().into()); // SAFETY: the pointer we stored above remains valid because // temporary_refs holds a strong reference. @@ -1119,20 +1114,14 @@ impl InterpreterFrame { locals, lasti: Radium::new(self.lasti.load(Relaxed)), prev_line: core::cell::Cell::new(self.prev_line.get()), - trace: PyMutex::new(None), - trace_lines: PyMutex::new(true), - trace_opcodes: PyMutex::new(false), - temporary_refs: PyMutex::new(vec![]), generator: PyAtomicBorrow::new(), previous: Radium::new(0), owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), - f_locals_hidden_overlay: PyMutex::new(None), - f_extra_locals: PyMutex::new(None), - escaped: atomic::AtomicBool::new(true), - retained_back: PyMutex::new(None), - pending_stack_pops: Default::default(), - pending_unwind_from_stack: Default::default(), materialized: Radium::new(0), + cold: OnceCell::from(Box::new(FrameColdData { + escaped: atomic::AtomicBool::new(true), + ..FrameColdData::default() + })), }; let frame_obj = FrameObject { @@ -1175,6 +1164,19 @@ impl InterpreterFrame { Some(unsafe { &*self.func_obj }) } } + + /// Access the lazily-allocated cold data, allocating on first use. + #[inline] + pub(crate) fn cold(&self) -> &FrameColdData { + self.cold.get_or_init(|| Box::new(FrameColdData::default())) + } + + /// Access cold data without allocating. Returns `None` if cold data + /// has not been allocated yet. + #[inline] + pub(crate) fn cold_opt(&self) -> Option<&FrameColdData> { + self.cold.get().map(|b| &**b) + } } /// Python-visible frame object. Currently always wraps an `InterpreterFrame`. @@ -1323,11 +1325,13 @@ unsafe impl Traverse for FrameObject { }; iframe.localsplus.traverse(tracer_fn); iframe.locals.traverse(tracer_fn); - iframe.trace.traverse(tracer_fn); - iframe.temporary_refs.traverse(tracer_fn); - iframe.f_locals_hidden_overlay.traverse(tracer_fn); - iframe.f_extra_locals.traverse(tracer_fn); - iframe.retained_back.traverse(tracer_fn); + if let Some(cold) = iframe.cold_opt() { + cold.trace.traverse(tracer_fn); + cold.temporary_refs.traverse(tracer_fn); + cold.f_locals_hidden_overlay.traverse(tracer_fn); + cold.f_extra_locals.traverse(tracer_fn); + cold.retained_back.traverse(tracer_fn); + } } fn clear(&mut self, _out: &mut Vec) { @@ -1505,8 +1509,8 @@ impl FrameObject { for slot in fastlocals.iter_mut() { *slot = None; } - self.iframe().f_locals_hidden_overlay.lock().take(); - self.iframe().f_extra_locals.lock().take(); + self.iframe().cold().f_locals_hidden_overlay.lock().take(); + self.iframe().cold().f_extra_locals.lock().take(); } /// Store a borrowed back-reference to the owning generator/coroutine. @@ -1578,12 +1582,17 @@ impl FrameObject { /// Record that a durable Python-level reference to this frame escaped. pub(crate) fn mark_escaped(&self) { - self.iframe().escaped.store(true, atomic::Ordering::Release); + self.iframe() + .cold() + .escaped + .store(true, atomic::Ordering::Release); } /// Whether a durable reference to this frame has escaped. pub(crate) fn has_escaped(&self) -> bool { - self.iframe().escaped.load(atomic::Ordering::Acquire) + self.iframe() + .cold_opt() + .is_some_and(|c| c.escaped.load(atomic::Ordering::Acquire)) } pub fn lasti(&self) -> u32 { @@ -1769,12 +1778,12 @@ impl FrameObject { pub fn f_locals_mapping(&self, vm: &VirtualMachine) -> PyResult { self.check_locals_access(vm)?; if !self.has_active_hidden_locals() { - self.iframe().f_locals_hidden_overlay.lock().take(); + self.iframe().cold().f_locals_hidden_overlay.lock().take(); return self.locals(vm); } let overlay_dict = { - let mut overlay = self.iframe().f_locals_hidden_overlay.lock(); + let mut overlay = self.iframe().cold().f_locals_hidden_overlay.lock(); match overlay.as_ref() { Some(dict) => dict.clone(), None => { @@ -1808,7 +1817,7 @@ impl FrameObject { /// Copy the frame's extra-locals side storage (proxy keys that are not /// fast locals) into `mapping`. No-op when nothing was ever stored. fn fold_extra_locals(&self, mapping: &ArgMapping, vm: &VirtualMachine) -> PyResult<()> { - let extra = self.iframe().f_extra_locals.lock().clone(); + let extra = self.iframe().cold().f_extra_locals.lock().clone(); if let Some(extra) = extra { for (key, value) in &extra { mapping.mapping().ass_subscript(&key, Some(value), vm)?; @@ -1934,7 +1943,7 @@ impl FrameObject { { return Ok(value); } - let extra = self.iframe().f_extra_locals.lock().clone(); + let extra = self.iframe().cold().f_extra_locals.lock().clone(); if let Some(extra) = extra && let Some(value) = extra.get_item_opt(&*key, vm)? { @@ -1953,7 +1962,7 @@ impl FrameObject { if self.framelocalsproxy_getkeyindex(&key, true, vm)?.is_some() { return Ok(true); } - let extra = self.iframe().f_extra_locals.lock().clone(); + let extra = self.iframe().cold().f_extra_locals.lock().clone(); if let Some(extra) = extra { return Ok(extra.get_item_opt(&*key, vm)?.is_some()); } @@ -1991,7 +2000,7 @@ impl FrameObject { { return Err(vm.new_value_error("cannot remove local variables from FrameLocalsProxy")); } - let extra = self.iframe().f_extra_locals.lock().clone(); + let extra = self.iframe().cold().f_extra_locals.lock().clone(); if let Some(extra) = extra && extra.get_item_opt(&*key, vm)?.is_some() { @@ -2014,7 +2023,7 @@ impl FrameObject { { return Err(vm.new_value_error("cannot remove local variables from FrameLocalsProxy")); } - let extra = self.iframe().f_extra_locals.lock().clone(); + let extra = self.iframe().cold().f_extra_locals.lock().clone(); if let Some(extra) = extra && let Some(value) = extra.pop_item(&*key, vm)? { @@ -2041,7 +2050,7 @@ impl FrameObject { } fn extra_locals_get_or_create(&self, vm: &VirtualMachine) -> PyDictRef { - let mut extra = self.iframe().f_extra_locals.lock(); + let mut extra = self.iframe().cold().f_extra_locals.lock(); extra.get_or_insert_with(|| vm.ctx.new_dict()).clone() } } @@ -2372,7 +2381,7 @@ pub(crate) fn release_datastack_frame(frame: &Py, vm: &VirtualMachi // executing here (this frame is unwinding back into it), so its payload // pointer is live. { - let mut guard = frame.iframe().retained_back.lock(); + let mut guard = frame.iframe().cold().retained_back.lock(); if guard.is_none() { let prev = frame.previous_iframe(); *guard = unsafe { owned_chain_frame(prev) }; @@ -2613,31 +2622,39 @@ impl ExecutingFrame<'_> { /// Whether this frame has a per-frame trace function set. #[inline] fn trace_is_set(&self, _vm: &VirtualMachine) -> bool { - self.iframe().trace.lock().is_some() + self.iframe() + .cold_opt() + .is_some_and(|c| c.trace.lock().is_some()) } /// Access the frame's trace_opcodes lock. #[inline] fn trace_opcodes_is_set(&self) -> bool { - *self.iframe().trace_opcodes.lock() + self.iframe() + .cold_opt() + .is_some_and(|c| *c.trace_opcodes.lock()) } /// Get pending_stack_pops from the frame. #[inline] fn pending_stack_pops(&self) -> u32 { - self.iframe().pending_stack_pops.load(Relaxed) + self.iframe() + .cold_opt() + .map_or(0, |c| c.pending_stack_pops.load(Relaxed)) } /// Get pending_unwind_from_stack from the frame. #[inline] fn pending_unwind_from_stack(&self) -> i64 { - self.iframe().pending_unwind_from_stack.load(Relaxed) + self.iframe() + .cold_opt() + .map_or(0, |c| c.pending_unwind_from_stack.load(Relaxed)) } /// Set pending_stack_pops on the frame. #[inline] fn set_pending_stack_pops(&self, val: u32) { - self.iframe().pending_stack_pops.store(val, Relaxed); + self.iframe().cold().pending_stack_pops.store(val, Relaxed); } /// Run `__init__` for the tp_new specialization. `args` holds the diff --git a/crates/vm/src/object/ext.rs b/crates/vm/src/object/ext.rs index c4732d1cb..69ee0e3c5 100644 --- a/crates/vm/src/object/ext.rs +++ b/crates/vm/src/object/ext.rs @@ -333,7 +333,7 @@ impl PyAtomicRef { pub fn swap_to_temporary_refs(&self, pyref: PyRef, vm: &VirtualMachine) { let old = unsafe { self.swap(pyref) }; if let Some(frame) = vm.current_frame() { - frame.iframe().temporary_refs.lock().push(old.into()); + frame.iframe().cold().temporary_refs.lock().push(old.into()); } } } @@ -409,7 +409,7 @@ impl PyAtomicRef> { return; }; if let Some(frame) = vm.current_frame() { - frame.iframe().temporary_refs.lock().push(old.into()); + frame.iframe().cold().temporary_refs.lock().push(old.into()); } } } @@ -452,7 +452,7 @@ impl PyAtomicRef { pub fn swap_to_temporary_refs(&self, obj: PyObjectRef, vm: &VirtualMachine) { let old = unsafe { self.swap(obj) }; if let Some(frame) = vm.current_frame() { - frame.iframe().temporary_refs.lock().push(old); + frame.iframe().cold().temporary_refs.lock().push(old); } } } @@ -499,7 +499,7 @@ impl PyAtomicRef> { return; }; if let Some(frame) = vm.current_frame() { - frame.iframe().temporary_refs.lock().push(old); + frame.iframe().cold().temporary_refs.lock().push(old); } } } diff --git a/crates/vm/src/protocol/callable.rs b/crates/vm/src/protocol/callable.rs index 9e80c5b2c..5a1605d4b 100644 --- a/crates/vm/src/protocol/callable.rs +++ b/crates/vm/src/protocol/callable.rs @@ -233,7 +233,12 @@ impl VirtualMachine { }; // Opcode events are only dispatched when f_trace_opcodes is set. - if is_opcode_event && !*frame_ref.iframe().trace_opcodes.lock() { + if is_opcode_event + && !frame_ref + .iframe() + .cold_opt() + .is_some_and(|c| *c.trace_opcodes.lock()) + { return Ok(None); } @@ -261,7 +266,7 @@ impl VirtualMachine { // trace_trampoline behavior: clear per-frame f_trace // and propagate the error. if let Some(frame_ref) = self.current_frame() { - *frame_ref.iframe().trace.lock() = None; + *frame_ref.iframe().cold().trace.lock() = None; } return Err(e); } diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 64e5c596b..0a80285db 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -1206,7 +1206,7 @@ pub(crate) mod _thread { let iframe = unsafe { &*cur }; let fo = iframe.materialize(vm).to_owned(); if let Some(child) = child_fo.take() { - let mut guard = child.iframe().retained_back.lock(); + let mut guard = child.iframe().cold().retained_back.lock(); if guard.is_none() { *guard = Some(fo.clone()); } @@ -1253,7 +1253,7 @@ pub(crate) mod _thread { let iframe = unsafe { &*cur }; let fo = iframe.materialize(vm).to_owned(); if let Some(child) = child_fo.take() { - let mut guard = child.iframe().retained_back.lock(); + let mut guard = child.iframe().cold().retained_back.lock(); if guard.is_none() { *guard = Some(fo.clone()); } diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index a9091f895..a48ce56c1 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -1768,7 +1768,7 @@ impl VirtualMachine { // materialized, f_back will resolve via the TLS chain while the // caller is still executing, or return None after it returns. if strong > 1 { - let mut guard = frame.iframe().retained_back.lock(); + let mut guard = frame.iframe().cold().retained_back.lock(); if guard.is_none() { let prev_iframe = unsafe { &*old_chain }; if let Some(fo) = prev_iframe.frame_obj() { @@ -1884,7 +1884,7 @@ impl VirtualMachine { // lightweight frame has empty localsplus; live values are // read through find_live_source_iframe when needed. let back_fo = prev_iframe.materialize_chain(self); - *fo.iframe().retained_back.lock() = Some(back_fo); + *fo.iframe().cold().retained_back.lock() = Some(back_fo); } // Set owner to FrameObject since this frame is no longer // executing on a thread. @@ -1918,7 +1918,7 @@ impl VirtualMachine { crate::gc_state::gc_state() .track_object(core::ptr::NonNull::from(fo.as_object())); let live_iframe = &*iframe_ptr; - live_iframe.temporary_refs.lock().clear(); + live_iframe.cold().temporary_refs.lock().clear(); } } } @@ -2002,7 +2002,7 @@ impl VirtualMachine { // Fire 'call' trace event. current_frame() now returns the callee. let trace_result = self.trace_event(TraceEvent::Call, None)?; if let Some(local_trace) = trace_result { - *frame.iframe().trace.lock() = Some(local_trace); + *frame.iframe().cold().trace.lock() = Some(local_trace); } let result = f(frame); @@ -2011,7 +2011,11 @@ impl VirtualMachine { // PY_UNWIND fires PyTrace_RETURN with arg=None — so we fire for // both Ok and Err, matching `call_trace_protected` behavior. if self.use_tracing.get() - && (frame.iframe().trace.lock().is_some() || !self.is_none(&self.profile_func.borrow())) + && (!self.is_none(&self.profile_func.borrow()) + || frame + .iframe() + .cold_opt() + .is_some_and(|c| c.trace.lock().is_some())) { let ret_result = self.trace_event(TraceEvent::Return, None); // call_trace_protected: if trace function raises, its error From 8e920098b2389cd2fc94df271bd7784725e3f139 Mon Sep 17 00:00:00 2001 From: Hanif Ariffin Date: Mon, 3 Aug 2026 16:52:42 +0800 Subject: [PATCH 05/94] Fix bytes.isspace (#5655) * Fix implementation of bytes.isspace to match the implementation in CPython. * Fix implementation of bytes.istitle so that test_bigmem now passes. Also added some extra_test to capture this. * Fix some clippy issues Signed-off-by: Hanif Ariffin * bytes.istitle returns false when the bytes is empty. * Fix some edges of istitle with non-alphanumeric characters * Disable a line because python 3.12 errors on bad escape sequence * Match CPython's istitle a little closer * Fix clippy warning * Rewrite bytes.istitle along _Py_bytes_istitle The lookahead-based check accepted b"Not--a Titlecase String", which test_bytes covers. Track whether the previous byte was cased instead. isspace compares bytes directly rather than converting to char. Restore the b"omkmok\Xaa" assert: 3.14 reports an invalid escape sequence as a SyntaxWarning, not an error. Assisted-by: Claude --------- Signed-off-by: Hanif Ariffin Co-authored-by: Jeong YunWon --- Lib/test/test_bigmem.py | 16 ---------- crates/vm/src/bytes_inner.rs | 46 ++++++++++++--------------- extra_tests/snippets/builtin_bytes.py | 39 +++++++++++++++++++++++ 3 files changed, 60 insertions(+), 41 deletions(-) diff --git a/Lib/test/test_bigmem.py b/Lib/test/test_bigmem.py index ea76b1282..12b221a66 100644 --- a/Lib/test/test_bigmem.py +++ b/Lib/test/test_bigmem.py @@ -789,14 +789,6 @@ class BytesTest(unittest.TestCase, BaseStrTest): def test_swapcase(self, size): self._test_swapcase(size) - @unittest.expectedFailure # TODO: RUSTPYTHON - def test_isspace(self): - return super().test_isspace() - - @unittest.expectedFailure # TODO: RUSTPYTHON - def test_istitle(self): - return super().test_istitle() - class BytearrayTest(unittest.TestCase, BaseStrTest): @@ -823,14 +815,6 @@ class BytearrayTest(unittest.TestCase, BaseStrTest): test_hash = None test_split_large = None - @unittest.expectedFailure # TODO: RUSTPYTHON - def test_isspace(self): - return super().test_isspace() - - @unittest.expectedFailure # TODO: RUSTPYTHON - def test_istitle(self): - return super().test_istitle() - class TupleTest(unittest.TestCase): # Tuples have a small, fixed-sized head and an array of pointers to diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index d144004d6..6c76808c5 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -397,44 +397,40 @@ impl PyBytesInner { self.elements.py_isupper() } + // _Py_bytes_isspace pub fn isspace(&self) -> bool { + // is_ascii_whitespace excludes vertical tab, while Py_ISSPACE accepts it !self.elements.is_empty() && self .elements .iter() - .all(|x| char::from(*x).is_ascii_whitespace()) + .all(|x| x.is_ascii_whitespace() || *x == b'\x0b') } + // _Py_bytes_istitle pub fn istitle(&self) -> bool { - if self.elements.is_empty() { - return false; - } + let mut cased = false; + let mut previous_is_cased = false; - let mut iter = self.elements.iter().peekable(); - let mut prev_cased = false; - - while let Some(c) = iter.next() { - let current = char::from(*c); - let next = if let Some(k) = iter.peek() { - char::from(**k) - } else if current.is_uppercase() { - return !prev_cased; + for byte in &self.elements { + if byte.is_ascii_uppercase() { + if previous_is_cased { + return false; + } + previous_is_cased = true; + cased = true; + } else if byte.is_ascii_lowercase() { + if !previous_is_cased { + return false; + } + previous_is_cased = true; + cased = true; } else { - return prev_cased; - }; - - let is_cased = current.to_uppercase().next().unwrap() != current - || current.to_lowercase().next().unwrap() != current; - if (is_cased && next.is_uppercase() && !prev_cased) - || (!is_cased && next.is_lowercase()) - { - return false; + previous_is_cased = false; } - - prev_cased = is_cased; } - true + cased } pub fn lower(&self) -> Vec { diff --git a/extra_tests/snippets/builtin_bytes.py b/extra_tests/snippets/builtin_bytes.py index 2cb4c317f..4f8613644 100644 --- a/extra_tests/snippets/builtin_bytes.py +++ b/extra_tests/snippets/builtin_bytes.py @@ -708,3 +708,42 @@ b = B1.fromhex("a0a1a2") assert b.foo == "bar" skip_if_unsupported(3, 11, test__bytes__) + +assert " \f\n\r\t\v".encode("utf-8").isspace() +assert " \f\n\r\t\v".encode("latin-1").isspace() + +# bytes.istitle tests +s = b"Aa6A" +assert s.istitle(), f"{s}" +s = b"Aa6aA" +assert not s.istitle(), f"{s}" +s = b"Python Is Fun" +assert s.istitle(), f"{s}" +s = b"Python is fun" +assert not s.istitle(), f"{s}" +s = b"PYTHON IS FUN" +assert not s.istitle(), f"{s}" +s = b"Python 3.9 Is Awesome!" +assert s.istitle(), f"{s}" +s = b"" +assert not s.istitle(), f"{s}" +s = b"Hello Is Amazing" +assert s.istitle(), f"{s}" +s = b"Not--a Titlecase String" +assert not s.istitle(), f"{s}" +s = b"123A" +assert s.istitle(), f"{s}" +s = b"123a" +assert not s.istitle(), f"{s}" +s = b"123A\ta" +assert not s.istitle(), f"{s}" +SUBSTR = b"123456" +s = b"".join([b"A", b"a" * 64, SUBSTR]) +assert s.istitle(), f"{s}" +s += b"A" +assert s.istitle(), f"{s}" +s += b"aA" +assert not s.istitle(), f"{s}" +assert "123A".istitle(), f"{s}" +assert not "123a".istitle(), f"{s}" +assert not "123A\ta".istitle(), f"{s}" From cbd77af58ce7ff66ee120bf1c1a84157561a83f3 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:46:27 +0900 Subject: [PATCH 06/94] socket: accept a filesystem-encoded hostname in sethostname (#8437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `socket.sethostname` took `PyUtf8StrRef`, so it rejected `bytes` outright and refused a `str` carrying a surrogate escape. `socketmodule.c socket_sethostname` accepts a bytes object directly, falls back to `PyUnicode_FSConverter` for anything else, and hands the syscall the resulting buffer and its length — the name is never required to be UTF-8. Take `FsPath`, the converter `if_nametoindex` in this same module already uses, and pass its bytes down. `host_env::socket::sethostname` correspondingly takes `&[u8]` and builds the `OsStr` from them; `nix::unistd::sethostname` accepts `AsRef` and passes pointer and length to the syscall, so nothing on the path needs a NUL terminator or valid UTF-8. `Lib/test/test_socket.py test_sethostname` covers this: it calls `socket.sethostname(b'bar')` and asserts the hostname changed. The test is skipped unless run as root, which is why the gap went unnoticed. Assisted-by: Claude --- crates/host_env/src/socket.rs | 10 ++++++++-- crates/stdlib/src/socket.rs | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/host_env/src/socket.rs b/crates/host_env/src/socket.rs index af4af2717..542031e6f 100644 --- a/crates/host_env/src/socket.rs +++ b/crates/host_env/src/socket.rs @@ -44,9 +44,15 @@ pub use libc::{AF_ALG, AF_CAN}; #[cfg(target_os = "linux")] pub use libc::{sockaddr_alg, sockaddr_can}; +/// Set the system's hostname from its filesystem-encoded bytes. +/// +/// `socketmodule.c socket_sethostname` reads the argument as a buffer and +/// passes `buf.buf`/`buf.len` straight to the syscall, so a name is not +/// required to be UTF-8; taking `&[u8]` keeps that true here as well. #[cfg(all(unix, not(target_os = "redox")))] -pub fn sethostname(hostname: &str) -> io::Result<()> { - nix::unistd::sethostname(hostname).map_err(io::Error::from) +pub fn sethostname(hostname: &[u8]) -> io::Result<()> { + use std::os::unix::ffi::OsStrExt; + nix::unistd::sethostname(std::ffi::OsStr::from_bytes(hostname)).map_err(io::Error::from) } #[cfg(unix)] diff --git a/crates/stdlib/src/socket.rs b/crates/stdlib/src/socket.rs index 4f85374b1..f78bec69d 100644 --- a/crates/stdlib/src/socket.rs +++ b/crates/stdlib/src/socket.rs @@ -2313,8 +2313,8 @@ mod _socket { #[cfg(all(unix, not(any(target_os = "redox", target_os = "android"))))] #[pyfunction] - fn sethostname(hostname: PyUtf8StrRef) -> std::io::Result<()> { - host_socket::sethostname(hostname.as_str()) + fn sethostname(hostname: FsPath) -> std::io::Result<()> { + host_socket::sethostname(hostname.as_bytes()) } #[pyfunction] From 33cb5d86cde9e8e70d289a49b6e56dd2c46e2d56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:00:15 +0900 Subject: [PATCH 07/94] build(deps): bump https://github.com/astral-sh/ruff-pre-commit (#8439) Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.15.22 to 0.16.0. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.22...v0.16.0) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.16.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 80177744b..87bee5bd7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.22 + rev: v0.16.0 hooks: - id: ruff-format priority: 0 From 93ef1941740dafc79ce6a51695496d06b2896661 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:00:26 +0900 Subject: [PATCH 08/94] build(deps): bump github/gh-aw/actions/setup from 0.82.14 to 0.83.4 (#8441) Bumps [github/gh-aw/actions/setup](https://github.com/github/gh-aw) from 0.82.14 to 0.83.4. - [Release notes](https://github.com/github/gh-aw/releases) - [Changelog](https://github.com/github/gh-aw/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/gh-aw/compare/8b820ae1073f301991aaf2f307f7e271f618bb9f...bbb8042878459948333b15b66f27113f4b5c1b9a) --- updated-dependencies: - dependency-name: github/gh-aw/actions/setup dependency-version: 0.83.4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/upgrade-pylib.lock.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index 31a7ac26f..4f2944408 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -58,7 +58,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 + uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -99,7 +99,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 + uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -806,7 +806,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 + uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -927,7 +927,7 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 + uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 with: destination: /opt/gh-aw/actions - name: Download agent artifacts @@ -1039,7 +1039,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 + uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 with: destination: /opt/gh-aw/actions - name: Download agent output artifact From 70e5f2de8dc45138981252580f4c9a2fa9607c00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:00:34 +0900 Subject: [PATCH 09/94] build(deps): bump rustls from 0.23.42 to 0.23.43 (#8445) Bumps [rustls](https://github.com/rustls/rustls) from 0.23.42 to 0.23.43. - [Release notes](https://github.com/rustls/rustls/releases) - [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md) - [Commits](https://github.com/rustls/rustls/compare/v/0.23.42...v/0.23.43) --- updated-dependencies: - dependency-name: rustls dependency-version: 0.23.43 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b9870a0b..70d975cd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3174,9 +3174,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "once_cell", @@ -4196,7 +4196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", From 118128accbf4cd28fcf37b64a090c7d9e1ff73e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:01:00 +0900 Subject: [PATCH 10/94] build(deps): bump taiki-e/install-action from 2.84.0 to 2.85.2 (#8438) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.84.0 to 2.85.2. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9...41049aa56687c35e0afa74eed4f09cec4f9afabf) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index c6eebbdf8..b93a90e8d 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -33,7 +33,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2.84.0 + - uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2 with: tool: cargo-llvm-cov From e03b4f1e81a7a5bb1e6ceb9c307326f80b0c735a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:01:34 +0900 Subject: [PATCH 11/94] build(deps): bump proc-macro2 from 1.0.106 to 1.0.107 (#8444) Bumps [proc-macro2](https://github.com/dtolnay/proc-macro2) from 1.0.106 to 1.0.107. - [Release notes](https://github.com/dtolnay/proc-macro2/releases) - [Commits](https://github.com/dtolnay/proc-macro2/compare/1.0.106...1.0.107) --- updated-dependencies: - dependency-name: proc-macro2 dependency-version: 1.0.107 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70d975cd7..e989c6841 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2773,9 +2773,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] From a533e7f63dbd688a9e4a39e390a4e6b6ee3feb53 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:01:52 +0900 Subject: [PATCH 12/94] build(deps): bump bstr from 1.12.3 to 1.13.0 (#8442) Bumps [bstr](https://github.com/BurntSushi/bstr) from 1.12.3 to 1.13.0. - [Commits](https://github.com/BurntSushi/bstr/compare/1.12.3...1.13.0) --- updated-dependencies: - dependency-name: bstr dependency-version: 1.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e989c6841..58520470c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,9 +402,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", From 7d151c4ec3272def2aac03c29900d78a04f9a07d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:02:44 +0900 Subject: [PATCH 13/94] build(deps): bump zizmorcore/zizmor-action from 0.5.7 to 0.6.1 (#8443) Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.5.7 to 0.6.1. - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/192e21d79ab29983730a13d1382995c2307fbcaa...6fc4b006235f201fdab3722e17240ab420d580e5) --- updated-dependencies: - dependency-name: zizmorcore/zizmor-action dependency-version: 0.6.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dc7a1a3e7..777dad42e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -557,7 +557,7 @@ jobs: uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0 - name: zizmor - uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 + uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 - name: restore prek cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 From 5aef38825378fda2a1b9abe279edd33cae0b27c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:03:09 +0900 Subject: [PATCH 14/94] build(deps): bump reviewdog/action-actionlint from 1.72.0 to 1.73.0 (#8447) Bumps [reviewdog/action-actionlint](https://github.com/reviewdog/action-actionlint) from 1.72.0 to 1.73.0. - [Release notes](https://github.com/reviewdog/action-actionlint/releases) - [Commits](https://github.com/reviewdog/action-actionlint/compare/6fb7acc99f4a1008869fa8a0f09cfca740837d9d...50842263c20a7c46bd0065b9e624d3c569db061e) --- updated-dependencies: - dependency-name: reviewdog/action-actionlint dependency-version: 1.73.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 777dad42e..bcd60c6a0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -554,7 +554,7 @@ jobs: components: rustfmt - name: actionlint - uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0 + uses: reviewdog/action-actionlint@50842263c20a7c46bd0065b9e624d3c569db061e # v1.73.0 - name: zizmor uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 From 3d9a191190c5866b2260ac3f1a2a12daea02c5fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:13:46 +0900 Subject: [PATCH 15/94] build(deps): bump https://github.com/rbubley/mirrors-prettier (#8440) Bumps [https://github.com/rbubley/mirrors-prettier](https://github.com/rbubley/mirrors-prettier) from v3.9.5 to 3.9.6. - [Commits](https://github.com/rbubley/mirrors-prettier/compare/v3.9.5...v3.9.6) --- updated-dependencies: - dependency-name: https://github.com/rbubley/mirrors-prettier dependency-version: 3.9.6 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 87bee5bd7..ee90488c7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,7 +77,7 @@ repos: priority: 0 - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.9.5 + rev: v3.9.6 hooks: - id: prettier files: '^wasm/.*$' From 8023f32f6d8464c0770f772dc276af19603cba79 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:14:12 +0900 Subject: [PATCH 16/94] build(deps): bump j178/prek-action from 2.0.5 to 2.0.6 (#8446) Bumps [j178/prek-action](https://github.com/j178/prek-action) from 2.0.5 to 2.0.6. - [Release notes](https://github.com/j178/prek-action/releases) - [Commits](https://github.com/j178/prek-action/compare/e98a699c41eb69ab013a45817a0406469a748f8d...5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16) --- updated-dependencies: - dependency-name: j178/prek-action dependency-version: 2.0.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bcd60c6a0..ae82353c2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -567,7 +567,7 @@ jobs: - name: install prek id: prek - uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5 + uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6 with: cache: false show-verbose-logs: false From 345ed00a504368e50b66c2fd87a44219d2ca8e38 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:31:06 +0900 Subject: [PATCH 17/94] Use datastack path for __init__ dispatch in CallAllocAndEnterInit (#8448) specialization_run_init used prepare_exact_args_frame which heap-allocates a FrameObject with 4-5 atomic refcount bumps per call. Switch to invoke_prepared_exact_args which uses InterpreterFrame::new_on_datastack with zero refcount bumps. CallAllocAndEnterInit already guards against tracing (eval_frame_active) and recursion limits before reaching this path. Assisted-by: Claude --- crates/vm/src/builtins/function.rs | 2 +- crates/vm/src/frame.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index 39195202a..89fab4db1 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -791,7 +791,7 @@ impl Py { frame } - fn invoke_prepared_exact_args( + pub(crate) fn invoke_prepared_exact_args( &self, args: impl ExactSizeIterator, vm: &VirtualMachine, diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index bf276ec45..bd5b66f8a 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -2673,10 +2673,7 @@ impl ExecutingFrame<'_> { .iter_mut() .map(|slot| slot.take().expect("arg slot must be filled")); - let init_frame = init_func.prepare_exact_args_frame(taken, vm); - let init_result = vm.run_frame(init_frame.clone()); - release_datastack_frame(&init_frame, vm); - let init_result = init_result?; + let init_result = init_func.invoke_prepared_exact_args(taken, vm)?; if !vm.is_none(&init_result) { return Err(vm.new_type_error(format!( From 3c70a1ba58639a1dcb58419e34269fc0727484c0 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:50:22 -0400 Subject: [PATCH 18/94] Replace deprecated `chrono` with `jiff` (#8436) See: chronotope/chrono#1768 --- Cargo.lock | 122 ++++++---------------------------- Cargo.toml | 2 +- crates/stdlib/Cargo.toml | 2 +- crates/stdlib/src/ssl/cert.rs | 14 ++-- crates/vm/Cargo.toml | 6 +- crates/vm/build.rs | 22 +++--- crates/vm/src/stdlib/time.rs | 104 +++++++++++++---------------- 7 files changed, 91 insertions(+), 181 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58520470c..3acba3351 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,15 +73,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anes" version = "0.1.6" @@ -509,19 +500,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "ciborium" version = "0.2.2" @@ -1567,30 +1545,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_casemap" version = "2.2.0" @@ -1820,10 +1774,14 @@ checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" dependencies = [ "defmt", "jiff-static", + "jiff-tzdb-platform", + "js-sys", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -1837,6 +1795,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -3575,7 +3548,6 @@ dependencies = [ "base64", "blake2", "bzip2", - "chrono", "constant_time_eq", "crc32fast", "crossbeam-utils", @@ -3591,6 +3563,7 @@ dependencies = [ "indexmap", "insta", "itertools 0.15.0", + "jiff", "libc", "libsqlite3-sys", "libz-rs-sys", @@ -3669,7 +3642,6 @@ dependencies = [ "ascii", "bitflags 2.13.1", "bstr", - "chrono", "constant_time_eq", "crossbeam-utils", "exitcode", @@ -3682,6 +3654,7 @@ dependencies = [ "is-macro", "itertools 0.15.0", "itoa", + "jiff", "libc", "log", "malachite-bigint", @@ -4713,65 +4686,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 6bb22e36f..3b489a886 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -200,7 +200,6 @@ bitflags = "2.11.0" bitflagset = "0.0.3" bstr = { version = "1", default-features = false, features = ["unicode"] } bzip2 = "0.6" -chrono = { version = "0.4.44", default-features = false, features = ["clock", "std"] } console_error_panic_hook = "0.1" constant_time_eq = "0.5" cranelift = "0.132.0" @@ -231,6 +230,7 @@ insta = "1.47" itertools = { version = "0.15.0", default-features = false, features = ["use_alloc"] } itoa = "1" is-macro = "0.3.7" +jiff = "0.2" js-sys = "0.3" junction = "2.0.0" lexical-parse-float = "1.0.6" diff --git a/crates/stdlib/Cargo.toml b/crates/stdlib/Cargo.toml index d32e7ce8b..98a6677de 100644 --- a/crates/stdlib/Cargo.toml +++ b/crates/stdlib/Cargo.toml @@ -86,10 +86,10 @@ libz-rs-sys = { workspace = true } bzip2 = { workspace = true } # tkinter +jiff = { workspace = true } tk-sys = { workspace = true, optional = true } tcl-sys = { workspace = true, optional = true } widestring = { workspace = true, optional = true } -chrono.workspace = true # uuid [target.'cfg(not(any(target_os = "ios", target_os = "android", target_os = "windows", target_arch = "wasm32", target_os = "redox")))'.dependencies] diff --git a/crates/stdlib/src/ssl/cert.rs b/crates/stdlib/src/ssl/cert.rs index e304781b6..f12f43072 100644 --- a/crates/stdlib/src/ssl/cert.rs +++ b/crates/stdlib/src/ssl/cert.rs @@ -10,7 +10,7 @@ //! - Loading certificates from files, directories, and bytes use alloc::sync::Arc; -use chrono::{DateTime, Utc}; +use jiff::{Timestamp, Zoned, tz::TimeZone}; use parking_lot::RwLock as ParkingRwLock; use rustls::{ DigitallySignedStruct, RootCertStore, SignatureScheme, @@ -201,10 +201,10 @@ fn format_ip_address(ip: &[u8]) -> String { /// Formats certificate validity dates in the format: /// "Mon DD HH:MM:SS YYYY GMT" fn format_asn1_time(time: &x509_parser::time::ASN1Time) -> String { - let timestamp = time.timestamp(); - DateTime::::from_timestamp(timestamp, 0) - .expect("ASN1Time must be valid timestamp") - .format("%b %e %H:%M:%S %Y GMT") + let timestamp = + Timestamp::from_second(time.timestamp()).expect("ASN1Time must be valid timestamp"); + Zoned::new(timestamp, TimeZone::UTC) + .strftime("%b %e %H:%M:%S %Y GMT") .to_string() } @@ -341,7 +341,7 @@ pub(super) fn cert_to_dict( let serial = format_serial_number(&cert.serial); dict.set_item("serialNumber", vm.ctx.new_str(serial).into(), vm)?; - // Validity dates - format with GMT using chrono + // Validity dates - format with GMT using jiff dict.set_item( "notBefore", vm.ctx @@ -414,7 +414,7 @@ pub(super) fn cert_der_to_dict_helper( // CPython ordering: issuer, notAfter, notBefore, serialNumber, subject, version dict.set_item("issuer", name_to_tuple(cert.issuer())?, vm)?; - // Validity - format with GMT using chrono + // Validity - format with GMT using jiff dict.set_item( "notAfter", vm.ctx diff --git a/crates/vm/Cargo.toml b/crates/vm/Cargo.toml index 6601fb03d..cc7c8dec2 100644 --- a/crates/vm/Cargo.toml +++ b/crates/vm/Cargo.toml @@ -26,7 +26,7 @@ ast = ["ruff_python_ast", "ruff_text_size"] codegen = ["rustpython-codegen", "ast"] parser = ["ast"] serde = ["dep:serde"] -wasmbind = ["rustpython-common/wasm_js", "chrono/wasmbind", "wasm-bindgen"] +wasmbind = ["rustpython-common/wasm_js", "jiff/js", "wasm-bindgen"] [dependencies] rustpython-compiler = { workspace = true, optional = true } @@ -48,7 +48,6 @@ ascii = { workspace = true } bitflags = { workspace = true } bstr = { workspace = true } crossbeam-utils = { workspace = true } -chrono = { workspace = true } constant_time_eq = { workspace = true } flame = { workspace = true, optional = true } hex = { workspace = true } @@ -56,6 +55,7 @@ indexmap = { workspace = true } itertools = { workspace = true } itoa = { workspace = true } is-macro = { workspace = true } +jiff = { workspace = true } libc = { workspace = true } log = { workspace = true } malachite-bigint = { workspace = true } @@ -91,9 +91,9 @@ widestring = { workspace = true } wasm-bindgen = { workspace = true, optional = true } [build-dependencies] -chrono = { workspace = true } glob = { workspace = true } itertools = { workspace = true } +jiff = { workspace = true } [lints] workspace = true diff --git a/crates/vm/build.rs b/crates/vm/build.rs index 36e7a5d9d..2846adfe5 100644 --- a/crates/vm/build.rs +++ b/crates/vm/build.rs @@ -3,9 +3,8 @@ reason = "build scripts cannot use rustpython-host_env" )] -use chrono::{Local, prelude::DateTime}; -use core::time::Duration; use itertools::Itertools; +use jiff::{Timestamp, Zoned, tz::TimeZone}; use std::{ env, io::{self, prelude::*}, @@ -123,24 +122,27 @@ fn git_identifier() -> String { } } -fn get_git_timestamp_datetime() -> DateTime { - let timestamp = git_timestamp().parse::().unwrap_or_default(); - let datetime = UNIX_EPOCH + Duration::from_secs(timestamp); - datetime.into() +fn get_git_timestamp_raw() -> Option { + let git_timestamp = git_timestamp().parse::().ok()?; + Timestamp::from_second(git_timestamp).ok() +} + +fn get_git_timestamp_datetime() -> Zoned { + get_git_timestamp_raw().map_or_else(Zoned::now, |timestamp| { + Zoned::new(timestamp, TimeZone::system()) + }) } #[must_use] fn get_git_date() -> String { let datetime = get_git_timestamp_datetime(); - - datetime.format("%b %e %Y").to_string() + datetime.strftime("%b %e %Y").to_string() } #[must_use] fn get_git_time() -> String { let datetime = get_git_timestamp_datetime(); - - datetime.format("%H:%M:%S").to_string() + datetime.strftime("%H:%M:%S").to_string() } fn rustc_version() -> String { diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index 226432654..54ccd46d7 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -25,12 +25,9 @@ mod decl { common::wtf8::Wtf8Buf, convert::{ToPyException, ToPyObject}, }; - #[cfg(not(any(unix, windows)))] - use chrono::{ - DateTime, Datelike, TimeZone, Timelike, - naive::{NaiveDate, NaiveDateTime, NaiveTime}, - }; use core::time::Duration; + #[cfg(not(any(unix, windows)))] + use jiff::{Timestamp, Zoned, civil::DateTime, tz::TimeZone}; #[cfg(target_os = "wasi")] use rustpython_host_env::time::ClockId; #[cfg(any(unix, windows))] @@ -289,10 +286,7 @@ mod decl { } #[cfg(not(any(unix, windows)))] - fn pyobj_to_date_time( - value: Either, - vm: &VirtualMachine, - ) -> PyResult> { + fn pyobj_to_timestamp(value: Either, vm: &VirtualMachine) -> PyResult { let secs = match value { Either::A(float) => { if !float.is_finite() { @@ -302,19 +296,19 @@ mod decl { } Either::B(int) => int, }; - DateTime::::from_timestamp(secs, 0) - .ok_or_else(|| vm.new_overflow_error("timestamp out of range for platform time_t")) + Timestamp::from_second(secs) + .map_err(|_| vm.new_overflow_error("timestamp out of range for platform time_t")) } #[cfg(not(any(unix, windows)))] impl OptionalArg>> { /// Construct a localtime from the optional seconds, or get the current local time. - fn naive_or_local(self, vm: &VirtualMachine) -> PyResult { + fn naive_or_local(self, vm: &VirtualMachine) -> PyResult { Ok(match self { - Self::Present(Some(secs)) => pyobj_to_date_time(secs, vm)? - .with_timezone(&chrono::Local) - .naive_local(), - Self::Present(None) | Self::Missing => chrono::offset::Local::now().naive_local(), + Self::Present(Some(secs)) => { + pyobj_to_timestamp(secs, vm)?.to_zoned(TimeZone::system()) + } + Self::Present(None) | Self::Missing => Zoned::now(), }) } } @@ -432,10 +426,10 @@ mod decl { #[cfg(not(any(unix, windows)))] impl OptionalArg { - fn naive_or_local(self, vm: &VirtualMachine) -> PyResult { + fn naive_or_local(self, vm: &VirtualMachine) -> PyResult { Ok(match self { Self::Present(t) => t.to_date_time(vm)?, - Self::Missing => chrono::offset::Local::now().naive_local(), + Self::Missing => Zoned::now().datetime(), }) } } @@ -456,9 +450,9 @@ mod decl { } _ => { let instant = match secs { - OptionalArg::Present(Some(secs)) => pyobj_to_date_time(secs, vm)?.naive_utc(), + OptionalArg::Present(Some(secs)) => pyobj_to_timestamp(secs, vm)?.to_zoned(TimeZone::UTC), OptionalArg::Present(None) | OptionalArg::Missing => { - chrono::offset::Utc::now().naive_utc() + Zoned::now().with_time_zone(TimeZone::UTC) } }; Ok(StructTimeData::new_utc(vm, instant)) @@ -481,7 +475,7 @@ mod decl { } _ => { let instant = secs.naive_or_local(vm)?; - Ok(StructTimeData::new_local(vm, instant, 0)) + StructTimeData::new_local(vm, instant.into(), 0) } } } @@ -502,11 +496,10 @@ mod decl { { let datetime = t.to_date_time(vm)?; // mktime interprets struct_time as local time - let local_dt = chrono::Local - .from_local_datetime(&datetime) - .single() - .ok_or_else(|| vm.new_overflow_error("mktime argument out of range"))?; - let seconds_since_epoch = local_dt.timestamp() as f64; + let local_dt = datetime + .to_zoned(TimeZone::system()) + .map_err(|_| vm.new_overflow_error("mktime argument out of range"))?; + let seconds_since_epoch = local_dt.timestamp().as_second() as f64; Ok(seconds_since_epoch) } } @@ -534,7 +527,7 @@ mod decl { #[cfg(not(any(unix, windows)))] { let instant = t.naive_or_local(vm)?; - let formatted_time = instant.format(CFMT).to_string(); + let formatted_time = instant.strftime(CFMT).to_string(); Ok(vm.ctx.new_str(formatted_time).into()) } } @@ -555,7 +548,7 @@ mod decl { #[cfg(not(any(unix, windows)))] { let instant = secs.naive_or_local(vm)?; - Ok(instant.format(CFMT).to_string()) + Ok(instant.strftime(CFMT).to_string()) } } @@ -645,7 +638,7 @@ mod decl { }; let mut formatted_time = String::new(); - write!(&mut formatted_time, "{}", instant.format(&fmt_lossy)) + write!(&mut formatted_time, "{}", instant.strftime(&*fmt_lossy)) .unwrap_or_else(|_| formatted_time = format.to_string()); Ok(vm.ctx.new_str(formatted_time).into()) } @@ -757,13 +750,7 @@ mod decl { impl StructTimeData { #[cfg(not(any(unix, windows)))] - fn new_inner( - vm: &VirtualMachine, - tm: NaiveDateTime, - isdst: i32, - gmtoff: i32, - zone: &str, - ) -> Self { + fn new_inner(vm: &VirtualMachine, tm: Zoned, isdst: i32) -> Self { Self { tm_year: vm.ctx.new_int(tm.year()).into(), tm_mon: vm.ctx.new_int(tm.month()).into(), @@ -771,46 +758,47 @@ mod decl { tm_hour: vm.ctx.new_int(tm.hour()).into(), tm_min: vm.ctx.new_int(tm.minute()).into(), tm_sec: vm.ctx.new_int(tm.second()).into(), - tm_wday: vm.ctx.new_int(tm.weekday().num_days_from_monday()).into(), - tm_yday: vm.ctx.new_int(tm.ordinal()).into(), + tm_wday: vm.ctx.new_int(tm.weekday().to_sunday_zero_offset()).into(), + tm_yday: vm.ctx.new_int(tm.day_of_year()).into(), tm_isdst: vm.ctx.new_int(isdst).into(), - tm_zone: vm.ctx.new_str(zone).into(), - tm_gmtoff: vm.ctx.new_int(gmtoff).into(), + tm_zone: vm.ctx.new_str(tm.strftime("%Z").to_string()).into(), + tm_gmtoff: vm.ctx.new_int(tm.offset().seconds()).into(), } } /// Create struct_time for UTC (gmtime) #[cfg(not(any(unix, windows)))] - fn new_utc(vm: &VirtualMachine, tm: NaiveDateTime) -> Self { - Self::new_inner(vm, tm, 0, 0, "UTC") + fn new_utc(vm: &VirtualMachine, tm: Zoned) -> Self { + Self::new_inner(vm, tm, 0) } /// Create struct_time for local timezone (localtime) #[cfg(not(any(unix, windows)))] - fn new_local(vm: &VirtualMachine, tm: NaiveDateTime, isdst: i32) -> Self { - let local_time = chrono::Local.from_local_datetime(&tm).unwrap(); - let offset_seconds = local_time.offset().local_minus_utc(); - let tz_abbr = local_time.format("%Z").to_string(); - Self::new_inner(vm, tm, isdst, offset_seconds, &tz_abbr) + fn new_local(vm: &VirtualMachine, tm: DateTime, isdst: i32) -> PyResult { + tm.to_zoned(TimeZone::system()) + .map(|tm| Self::new_inner(vm, tm, isdst)) + .map_err(|_| { + vm.new_overflow_error("timestamp is ambiguous for the system timezone") + }) } #[cfg(not(any(unix, windows)))] - fn to_date_time(&self, vm: &VirtualMachine) -> PyResult { - let invalid_overflow = || vm.new_overflow_error("mktime argument out of range"); - let invalid_value = || vm.new_value_error("invalid struct_time parameter"); - + fn to_date_time(&self, vm: &VirtualMachine) -> PyResult { macro_rules! field { ($field:ident) => { self.$field.clone().try_into_value(vm)? }; } - let dt = NaiveDateTime::new( - NaiveDate::from_ymd_opt(field!(tm_year), field!(tm_mon), field!(tm_mday)) - .ok_or_else(invalid_value)?, - NaiveTime::from_hms_opt(field!(tm_hour), field!(tm_min), field!(tm_sec)) - .ok_or_else(invalid_overflow)?, - ); - Ok(dt) + DateTime::new( + field!(tm_year), + field!(tm_mon), + field!(tm_mday), + field!(tm_hour), + field!(tm_min), + field!(tm_sec), + 0, + ) + .map_err(|_| vm.new_overflow_error("mktime argument out of range")) } } From 5c07fa4bd00760a31f3d2b4705e35f09102f9116 Mon Sep 17 00:00:00 2001 From: Kevin Turcios <106575910+KRRT7@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:12:22 -0500 Subject: [PATCH 19/94] Optimize list removal lock usage (#8451) Assisted-by: WarpForgeAgent:DeepSeek-v4-flash --- .pre-commit-config.yaml | 4 ++-- crates/vm/src/builtins/list.rs | 9 ++++++--- scripts/check_redundant_patches.py | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee90488c7..db9869bb3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: - id: generate-rs-opcode-metadata name: generate rust opcode metadata - entry: python tools/opcode_metadata/generate_rs_opcode_metadata.py + entry: python3 tools/opcode_metadata/generate_rs_opcode_metadata.py files: '^(crates/compiler-core/src/bytecode/instruction\.rs|tools/opcode_metadata/*)$' pass_filenames: false language: system @@ -53,7 +53,7 @@ repos: - id: generate-py-opcode-metadata name: generate python opcode metadata - entry: python tools/opcode_metadata/generate_py_opcode_metadata.py + entry: python3 tools/opcode_metadata/generate_py_opcode_metadata.py files: '^(crates/compiler-core/src/bytecode/instruction\.rs|tools/opcode_metadata/*)$' pass_filenames: false language: system diff --git a/crates/vm/src/builtins/list.rs b/crates/vm/src/builtins/list.rs index 8a426685a..2b83a5c10 100644 --- a/crates/vm/src/builtins/list.rs +++ b/crates/vm/src/builtins/list.rs @@ -372,12 +372,15 @@ impl PyList { if let Some(index) = index.into() { // defer delete out of borrow - let is_inside_range = index < self.borrow_vec().len(); - Ok(is_inside_range.then(|| self.borrow_vec_mut().remove(index))) + let removed = { + let mut elements = self.borrow_vec_mut(); + (index < elements.len()).then(|| elements.remove(index)) + }; + drop(removed); + Ok(()) } else { Err(vm.new_value_error(format!("'{}' is not in list", needle.str(vm)?))) } - .map(drop) } fn _delitem(&self, needle: &PyObject, vm: &VirtualMachine) -> PyResult<()> { diff --git a/scripts/check_redundant_patches.py b/scripts/check_redundant_patches.py index 4bc89a573..e9981e38d 100644 --- a/scripts/check_redundant_patches.py +++ b/scripts/check_redundant_patches.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import argparse import ast import glob From c15eb6796f02396747e5a271d938e832d1f381cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:12:39 +0900 Subject: [PATCH 20/94] build(deps-dev): bump fast-uri from 3.1.4 to 3.1.5 in /wasm/demo (#8452) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- wasm/demo/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wasm/demo/package-lock.json b/wasm/demo/package-lock.json index 9cfd625aa..b114d92eb 100644 --- a/wasm/demo/package-lock.json +++ b/wasm/demo/package-lock.json @@ -2559,9 +2559,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { From c258bad623b21b69a6068efd2e4c8805cd93622d Mon Sep 17 00:00:00 2001 From: Seonghun An <53287605+shAn-kor@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:13:43 +0900 Subject: [PATCH 21/94] Fix complex zero-padding error priority (#8453) Assisted-by: Codex:gpt-5 --- crates/common/src/format.rs | 65 ++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index b4b25b273..b5f062f97 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -505,6 +505,14 @@ impl FormatSpec { Ok(()) } + fn validate_complex_padding_and_alignment(&self) -> Result<(), FormatSpecError> { + match &self.fill.unwrap_or_else(|| ' '.into()).to_char() { + Some('0') => Err(FormatSpecError::ZeroPadding), + _ if self.align == Some(FormatAlign::AfterSign) => Err(FormatSpecError::AlignmentFlag), + _ => Ok(()), + } + } + const fn get_separator_interval(&self) -> usize { match self.format_type { Some(FormatType::Binary | FormatType::Octal | FormatType::Hex(_)) => 4, @@ -784,6 +792,7 @@ impl FormatSpec { // No parentheses for 'n' format (CPython: add_parens=0) let magnitude_str = format!("{grouped_re}{grouped_im}"); + self.validate_complex_padding_and_alignment()?; Ok(self.format_sign_and_align(&AsciiStr::new(&magnitude_str), "", FormatAlign::Right)) } @@ -1057,17 +1066,8 @@ impl FormatSpec { } else { format!("{formatted_re}{formatted_im}") }; - if let Some(FormatAlign::AfterSign) = &self.align { - return Err(FormatSpecError::AlignmentFlag); - } - match &self.fill.unwrap_or_else(|| ' '.into()).to_char() { - Some('0') => Err(FormatSpecError::ZeroPadding), - _ => Ok(self.format_sign_and_align( - &AsciiStr::new(&magnitude_str), - "", - FormatAlign::Right, - )), - } + self.validate_complex_padding_and_alignment()?; + Ok(self.format_sign_and_align(&AsciiStr::new(&magnitude_str), "", FormatAlign::Right)) } fn format_complex_re_im(&self, num: &Complex64) -> Result<(String, String), FormatSpecError> { @@ -1726,6 +1726,26 @@ mod tests { ); } + #[test] + fn format_complex_rejects_zero_padding_before_after_sign_alignment() { + for text in [ + "08.1f", "=08.1f", "0=8.1f", "#08.1f", "0>8.1f", "0<8.1f", "0^8.1f", + ] { + let spec = FormatSpec::parse(text).unwrap(); + assert_eq!( + spec.format_complex(&Complex64::new(1.0, 2.0)), + Err(FormatSpecError::ZeroPadding), + "{text}" + ); + } + + let spec = FormatSpec::parse("=8.1f").unwrap(); + assert_eq!( + spec.format_complex(&Complex64::new(1.0, 2.0)), + Err(FormatSpecError::AlignmentFlag) + ); + } + #[test] fn format_int_zero_padding_stays_after_sign() { let spec = FormatSpec::parse("08").unwrap(); @@ -1736,6 +1756,29 @@ mod tests { ); } + #[test] + fn format_complex_locale_rejects_zero_padding_before_after_sign_alignment() { + let locale = LocaleInfo { + thousands_sep: String::new(), + decimal_point: ".".to_owned(), + grouping: vec![], + }; + for text in ["08n", "=08n", "0=8n", "#08n", "0>8n", "0<8n", "0^8n"] { + let spec = FormatSpec::parse(text).unwrap(); + assert_eq!( + spec.format_complex_locale(&Complex64::new(1.0, 2.0), &locale), + Err(FormatSpecError::ZeroPadding), + "{text}" + ); + } + + let spec = FormatSpec::parse("=8n").unwrap(); + assert_eq!( + spec.format_complex_locale(&Complex64::new(1.0, 2.0), &locale), + Err(FormatSpecError::AlignmentFlag) + ); + } + #[test] fn format_int() { assert_eq!( From 3b9c64b151d3a6fca4c41a2cfd7abefc56e8a363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=91=ED=9A=A8?= <87641474+widehyo1@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:15:27 +0900 Subject: [PATCH 22/94] ast: enforce integer string limits in AST-only parsing (#8454) Validate decimal integer literals from the parser token stream before lowering the parsed AST. Reuse the validator for normal compilation and the `_ast` parsing path so `PyCF_ONLY_AST`, `ast.parse()`, and `ast.literal_eval()` honor `int_max_str_digits` consistently. Remove the now-passing `test_literal_eval_str_int_limit` expected-failure marker. The token-based validator excludes non-decimal integers, floats, complex literals, strings, and comments without duplicating lexer logic. Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_ast/test_ast.py | 1 - crates/compiler/src/lib.rs | 159 ++++++++++++++-------------------- crates/vm/src/stdlib/_ast.rs | 8 ++ 3 files changed, 71 insertions(+), 97 deletions(-) diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index a3ce57034..699a5ec0b 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -1888,7 +1888,6 @@ Module( self.assertRaises(ValueError, ast.literal_eval, '+True') self.assertRaises(ValueError, ast.literal_eval, '2+3') - @unittest.expectedFailure # TODO: RUSTPYTHON; SyntaxError not raised def test_literal_eval_str_int_limit(self): with support.adjust_int_max_str_digits(4000): ast.literal_eval('3'*4000) # no error diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index 2cebfcb8b..43f7174d8 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -1,4 +1,4 @@ -pub use ruff_python_ast::token::TokenKind; +pub use ruff_python_ast::token::{TokenKind, Tokens}; use ruff_python_parser::ParseErrorType; use ruff_source_file::{PositionEncoding, SourceFile, SourceFileBuilder, SourceLocation}; use ruff_text_size::{Ranged, TextSize, TextSlice}; @@ -4752,97 +4752,45 @@ fn invalid_legacy_statement_error(source: &str) -> Option<(String, usize, usize) None } -fn long_decimal_integer_literal_error( - source: &str, +/// Return the syntax error for a decimal integer literal exceeding the configured limit. +/// +/// The parser has already distinguished integer literals from strings, comments, floats, and +/// complex numbers. Inspecting its tokens keeps the limit consistent for every source parsing +/// entry point without reimplementing Python's lexer here. +#[must_use] +pub fn long_decimal_integer_literal_error( + source_file: &SourceFile, + tokens: &Tokens, max_str_digits: usize, -) -> Option<(String, usize, usize)> { +) -> Option { if max_str_digits == 0 { return None; } - let bytes = source.as_bytes(); - let mut index = 0; - while index < bytes.len() { - match bytes[index] { - b'#' => { - while index < bytes.len() && bytes[index] != b'\n' { - index += 1; - } - } - b'\'' | b'"' => { - index = skip_quoted_string(bytes, index); - } - byte if byte >= 0x80 || byte == b'_' || byte.is_ascii_alphabetic() => { - index += 1; - while index < bytes.len() - && (bytes[index] >= 0x80 || is_ascii_identifier_char(bytes[index])) - { - index += 1; - } - } - b'.' => { - if bytes - .get(index + 1) - .is_some_and(|byte| byte.is_ascii_digit()) - { - let (_, end) = number_literal_end(bytes, index)?; - index = end.max(index + 1); - } else { - index += 1; - } - } - b'0'..=b'9' => { - if bytes.get(index) == Some(&b'0') - && matches!( - bytes.get(index + 1), - Some(b'x' | b'X' | b'o' | b'O' | b'b' | b'B') - ) - { - let Some((_, end)) = number_literal_end(bytes, index) else { - index += 1; - continue; - }; - index = end.max(index + 1); - continue; - } - - let start = index; - let mut digits = 0usize; - while index < bytes.len() { - match bytes[index] { - b'0'..=b'9' => { - digits += 1; - index += 1; - } - b'_' if bytes - .get(index + 1) - .is_some_and(|byte| byte.is_ascii_digit()) => - { - index += 1; - } - _ => break, - } - } - if matches!(bytes.get(index), Some(b'.' | b'e' | b'E' | b'j' | b'J')) { - let Some((_, end)) = number_literal_end(bytes, start) else { - continue; - }; - index = end.max(index + 1); - continue; - } - if digits > max_str_digits { - return Some(( - format!( - "Exceeds the limit ({max_str_digits} digits) for integer string conversion: value has {digits} digits; use sys.set_int_max_str_digits() to increase the limit - Consider hexadecimal for huge integer literals to avoid decimal conversion limits." - ), - start, - start, - )); - } - } - _ => index += 1, + tokens.iter().find_map(|token| { + if token.kind() != TokenKind::Int { + return None; } - } - None + let literal = source_file.source_text().slice(token.range()); + if literal + .as_bytes() + .get(..2) + .is_some_and(|prefix| matches!(prefix, b"0x" | b"0X" | b"0o" | b"0O" | b"0b" | b"0B")) + { + return None; + } + let digits = literal.bytes().filter(u8::is_ascii_digit).count(); + (digits > max_str_digits).then(|| { + let start = token.range().start().to_usize(); + CompileError::from_source_error( + source_file, + format!( + "Exceeds the limit ({max_str_digits} digits) for integer string conversion: value has {digits} digits; use sys.set_int_max_str_digits() to increase the limit - Consider hexadecimal for huge integer literals to avoid decimal conversion limits." + ), + start, + start, + ) + }) + }) } fn invalid_parenthesized_import_star_error(source: &str) -> Option<(String, usize, usize)> { @@ -4951,12 +4899,27 @@ fn invalid_unparenthesized_yield_after_comma_error(source: &str) -> Option<(Stri None } -fn post_parse_source_error(source_file: &SourceFile, opts: &CompileOpts) -> Option { - too_many_nested_parentheses_error(source_file.source_text()) - .or_else(|| { - long_decimal_integer_literal_error(source_file.source_text(), opts.int_max_str_digits) - }) - .or_else(|| invalid_call_argument_error(source_file.source_text())) +fn post_parse_source_error( + source_file: &SourceFile, + tokens: &Tokens, + opts: &CompileOpts, +) -> Option { + if let Some((message, start, end)) = + too_many_nested_parentheses_error(source_file.source_text()) + { + return Some(CompileError::from_source_error( + source_file, + message, + start, + end, + )); + } + if let Some(error) = + long_decimal_integer_literal_error(source_file, tokens, opts.int_max_str_digits) + { + return Some(error); + } + invalid_call_argument_error(source_file.source_text()) .or_else(|| invalid_match_mapping_rest_wildcard_error(source_file.source_text())) .or_else(|| invalid_match_as_target_error(source_file.source_text())) .or_else(|| invalid_unparenthesized_yield_after_comma_error(source_file.source_text())) @@ -5220,7 +5183,7 @@ fn _compile_with_syntax_warning_handler<'a>( { return Err(error); } - if let Some(error) = post_parse_source_error(&source_file, &opts) { + if let Some(error) = post_parse_source_error(&source_file, parsed.tokens(), &opts) { return Err(error); } let ast = parsed.into_syntax(); @@ -5273,7 +5236,9 @@ pub fn _compile_symtable( Mode::Exec | Mode::Single | Mode::BlockExpr => { let ast = ruff_python_parser::parse_module(source_file.source_text()) .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file))?; - if let Some(error) = post_parse_source_error(&source_file, &CompileOpts::default()) { + if let Some(error) = + post_parse_source_error(&source_file, ast.tokens(), &CompileOpts::default()) + { return Err(error); } let ast = ast.into_syntax(); @@ -5290,7 +5255,9 @@ pub fn _compile_symtable( parser::Mode::Expression.into(), ) .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file))?; - if let Some(error) = post_parse_source_error(&source_file, &CompileOpts::default()) { + if let Some(error) = + post_parse_source_error(&source_file, ast.tokens(), &CompileOpts::default()) + { return Err(error); } symboltable::SymbolTable::scan_expr( diff --git a/crates/vm/src/stdlib/_ast.rs b/crates/vm/src/stdlib/_ast.rs index a04def225..b36277f54 100644 --- a/crates/vm/src/stdlib/_ast.rs +++ b/crates/vm/src/stdlib/_ast.rs @@ -1861,6 +1861,14 @@ pub(crate) fn parse( .into()); } + if let Some(error) = rustpython_compiler::long_decimal_integer_literal_error( + &source_file, + parsed.tokens(), + vm.state.int_max_str_digits.load(), + ) { + return Err(error); + } + let mut top = parsed.into_syntax(); if let Some(error) = ipython_escape_command_syntax_error(&top, &source_file) { return Err(error); From a28ff423457c2fad68920902c2b62d9105a331cc Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:10:14 +0900 Subject: [PATCH 23/94] Flatten Python eval loop with trampoline for reduced call overhead (#8431) * Factor with_iframe into enter_iframe/exit_iframe helpers Extract the frame entry (recursion check, TLS link, exception save) and exit (materialization sync, TLS restore, GC tracking) logic from with_iframe into standalone enter_iframe/exit_iframe methods. with_iframe now calls them, with no behavioral change. This prepares for the trampoline loop where enter/exit are called individually rather than wrapped around a closure. Assisted-by: Claude * Allocate InterpreterFrame and LocalsPlus together on the datastack Add InterpreterFrame::new_on_datastack() that bump-allocates both the InterpreterFrame struct and its LocalsPlus data array in a single datastack push, eliminating one allocation per function call. Update datastack_frame_size_bytes_for_code() to include InterpreterFrame size. Convert invoke_prepared_exact_args() and the invoke() fast path to use the combined allocation. Add release_datastack_frame() method on InterpreterFrame that drops all localsplus values, runs field destructors (trace, temporary_refs, retained_back, etc.), and returns the datastack base pointer for pop. Assisted-by: Claude * Implement trampoline loop for Python-to-Python calls Add ExecutionResult::TailCall variant and a trampoline in run_frame_fast that flattens Python-to-Python calls into a single Rust stack frame instead of recursing through the eval loop. CallPyExactArgs now prepares the callee frame on the datastack and returns TailCall when tailcall_enabled is set (run_iframe path only). The trampoline dispatches via a state machine (EnterCallee / ReturnValue / Unwind) in a single loop, avoiding mutual recursion between helper functions that would exhaust the C stack. Exception propagation through suspended frames uses trampoline_handle_exception which adds traceback entries and calls unwind_blocks on each caller. Assisted-by: Claude * Optimize trampoline: skip recursion check, avoid temporary_refs mutex, add bound method TailCall - Add enter_iframe_unchecked for trampoline callee entry (recursion already checked by specialization_call_recursion_guard) - Move callable ownership from per-frame temporary_refs mutex to trampoline-local SuspendedFrame.owned_refs via VM side channel - Add TailCall support for CallBoundMethodExactArgs - Move args directly from caller stack to callee fastlocals - Read materialized pointer once in exit_iframe Incremental call overhead: ~55 ns -> ~35 ns Assisted-by: Claude * Use UnsafeCell for pending_tailcall_refs The VM is per-thread so RefCell's runtime borrow checking is unnecessary overhead. Replace with UnsafeCell for direct access. Assisted-by: Claude * Remove .claude/settings.json from tracking Assisted-by: Claude * Replace SendPtr with Option for type-safe pending tailcall Use NonNull + Option instead of raw *mut T with manual null checks. The compiler enforces non-null via the type system, and Option has the same size as a raw pointer thanks to niche optimization. Also extract take_pending_tailcall helper to deduplicate the pattern. Assisted-by: Claude * Restrict PendingFrame to private, expose only set/take methods Rename SendNonNull to PendingFrame and make it fully private: the struct, its field, and the pending_tailcall_frame Cell are all non-pub. External code accesses the side channel only through set_pending_tailcall (pub(crate)) and take_pending_tailcall (private). This ensures the unsafe Send+Sync impl cannot be reused elsewhere without justifying a new safety argument. Assisted-by: Claude * Restore C-stack overflow check in trampoline enter_iframe_unchecked enter_iframe_unchecked was skipping C-stack checks under the assumption that the trampoline stays in one Rust stack frame. But each run_iframe call still consumes Rust stack, so deep Python recursion through the trampoline can exhaust the C stack (observed as STATUS_STACK_OVERFLOW on Windows CI). Keep the C-stack check (every 8th call) while still skipping the Python recursion depth check (already done by specialization_call_recursion_guard). Assisted-by: Claude * Address code review: entry frame double-free, panic safety, cleanup - Fix double-free: mark entry frame with is_entry flag in SuspendedFrame so the trampoline skips its datastack release (the caller owns that cleanup) - Clear iframe.previous in exit_iframe before unlinking the chain, matching with_frame and resume_gen_frame behavior - Add scopeguard in with_iframe for panic safety - Use saturating_sub(1) for lasti in trampoline_handle_exception - Extract datastack_iframe_localsplus_offset helper to avoid duplicated alignment computation Assisted-by: Claude --- .claude/settings.json | 15 - crates/vm/src/builtins/function.rs | 41 +-- crates/vm/src/coroutine.rs | 2 + crates/vm/src/frame.rs | 347 +++++++++++++++++++++- crates/vm/src/vm/mod.rs | 455 ++++++++++++++++++++++++++--- crates/vm/src/vm/thread.rs | 2 + 6 files changed, 783 insertions(+), 79 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 22f0a9a8a..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "bash .claude/scripts/setup-env.sh" - } - ] - } - ] - } -} diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index 89fab4db1..d1fa52223 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -64,9 +64,9 @@ fn format_missing_args( #[pyclass(module = false, name = "function", traverse = "manual")] #[derive(Debug)] pub struct PyFunction { - code: PyAtomicRef, - globals: PyDictRef, - builtins: PyObjectRef, + pub(crate) code: PyAtomicRef, + pub(crate) globals: PyDictRef, + pub(crate) builtins: PyObjectRef, pub(crate) closure: Option>>, defaults_and_kwdefaults: PyMutex<(Option, Option)>, name: PyMutex, @@ -617,11 +617,6 @@ impl Py { // Fast path: stack-allocated InterpreterFrame, no FrameObject. // No refcount inc for code — it's alive via self.code for the call duration. - let nlocalsplus = code.localspluskinds.len(); - let max_stackdepth = code.max_stackdepth as usize; - let localsplus = - crate::frame::LocalsPlus::new_on_datastack(nlocalsplus, max_stackdepth, vm); - let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { crate::frame::FrameLocals::lazy() } else if let Some(locals) = locals { @@ -634,22 +629,21 @@ impl Py { // Use self.as_object() as raw pointer — no refcount inc/dec. // The function is alive on the caller's stack for the call duration. - let mut iframe = crate::frame::InterpreterFrame::new( + let iframe = crate::frame::InterpreterFrame::new_on_datastack( &self.code, &self.globals, &self.builtins, Some(self.as_object()), - localsplus, locals, self.closure.as_ref().map_or(&[], |c| c.as_slice()), - crate::frame::FrameOwner::Thread, + vm, ); let result = self - .fill_locals_from_args_iframe(&mut iframe, func_args, vm) - .and_then(|()| vm.run_frame_fast(&mut iframe)); + .fill_locals_from_args_iframe(iframe, func_args, vm) + .and_then(|()| vm.run_frame_fast(iframe)); // Release data stack memory — must happen on both success and error. unsafe { - if let Some(base) = iframe.localsplus.release_datastack() { + if let Some(base) = iframe.release_datastack_frame() { vm.datastack_pop(base); } } @@ -797,10 +791,6 @@ impl Py { vm: &VirtualMachine, ) -> PyResult { let code = &*self.code; - let nlocalsplus = code.localspluskinds.len(); - let max_stackdepth = code.max_stackdepth as usize; - let localsplus = - crate::frame::LocalsPlus::new_on_datastack(nlocalsplus, max_stackdepth, vm); let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { crate::frame::FrameLocals::lazy() @@ -810,15 +800,14 @@ impl Py { )) }; - let mut iframe = crate::frame::InterpreterFrame::new( + let iframe = crate::frame::InterpreterFrame::new_on_datastack( code, &self.globals, &self.builtins, Some(self.as_object()), - localsplus, locals, self.closure.as_ref().map_or(&[], |c| c.as_slice()), - crate::frame::FrameOwner::Thread, + vm, ); // Fill arguments directly into fastlocals @@ -829,9 +818,9 @@ impl Py { } } - let result = vm.run_frame_fast(&mut iframe); + let result = vm.run_frame_fast(iframe); unsafe { - if let Some(base) = iframe.localsplus.release_datastack() { + if let Some(base) = iframe.release_datastack_frame() { vm.datastack_pop(base); } } @@ -887,8 +876,10 @@ pub(crate) fn datastack_frame_size_bytes_for_code(code: &Py) -> Option()) + Some(crate::frame::datastack_iframe_total_bytes( + nlocalsplus, + code.max_stackdepth as usize, + )) } impl PyPayload for PyFunction { diff --git a/crates/vm/src/coroutine.rs b/crates/vm/src/coroutine.rs index c7252c66d..43a28320e 100644 --- a/crates/vm/src/coroutine.rs +++ b/crates/vm/src/coroutine.rs @@ -23,6 +23,7 @@ impl ExecutionResult { }; PyIterReturn::StopIteration(arg) } + Self::TailCall => unreachable!("TailCall in generator/coroutine"), } } } @@ -104,6 +105,7 @@ impl Coro { self.clear_frame_locals_on_close(); } Ok(ExecutionResult::Yield(_)) => {} + Ok(ExecutionResult::TailCall) => unreachable!("TailCall in generator/coroutine"), } } diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index bd5b66f8a..e1836ad02 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -544,6 +544,13 @@ impl LocalsPlus { Ok(()) } + /// Push a PyObjectRef onto the evaluation stack. + /// Panics on overflow. + pub(crate) fn push_stack(&mut self, value: PyObjectRef) { + self.stack_try_push(Some(PyStackRef::new_owned(value))) + .expect("stack overflow in push_stack"); + } + /// Pop a value from the evaluation stack. #[inline(always)] fn stack_pop(&mut self) -> Option { @@ -861,6 +868,9 @@ pub struct InterpreterFrame { /// Used by `frame.clear()` to reject clearing an executing frame, /// even when called from a different thread. pub(crate) owner: atomic::AtomicI8, + /// Base pointer of the datastack allocation when this frame and its + /// localsplus are bump-allocated together. Null for heap-backed frames. + pub(crate) datastack_base: *mut u8, /// Pointer to the owning `Py`, or null for stack-allocated /// frames that have not been materialized yet. /// Stored as `usize` for `PyAtomic` compatibility. @@ -944,11 +954,110 @@ impl InterpreterFrame { generator: PyAtomicBorrow::new(), previous: Radium::new(0), owner: atomic::AtomicI8::new(owner as i8), + datastack_base: core::ptr::null_mut(), materialized: Radium::new(0), cold: OnceCell::new(), } } + /// Allocate an InterpreterFrame and its LocalsPlus data together on the + /// thread data stack in a single bump allocation. + /// + /// Layout: `[InterpreterFrame | localsplus usize×capacity]` + /// + /// Returns a mutable reference whose lifetime is bounded by the data + /// stack's LIFO discipline. The caller must call + /// `release_datastack_frame()` (unsafe) when done, then + /// `vm.datastack_pop(base)`. The reference must not be used after + /// `release_datastack_frame` returns. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_on_datastack<'a>( + code: &Py, + globals: &Py, + builtins: &PyObject, + func_obj: Option<&PyObject>, + locals: FrameLocals, + closure: &[PyCellRef], + vm: &VirtualMachine, + ) -> &'a mut Self { + let nlocalsplus = code.localspluskinds.len(); + let stacksize = code.max_stackdepth as usize; + let capacity = nlocalsplus + .checked_add(stacksize) + .expect("LocalsPlus capacity overflow"); + + let total_bytes = datastack_iframe_total_bytes(nlocalsplus, stacksize); + let base = vm.datastack_push(total_bytes); + + // InterpreterFrame lives at the start of the allocation. + let iframe_ptr = base as *mut Self; + // LocalsPlus data follows the InterpreterFrame, aligned to usize. + let localsplus_data_ptr = + unsafe { base.add(datastack_iframe_localsplus_offset()) } as *mut usize; + + // Zero-initialize localsplus data. + unsafe { core::ptr::write_bytes(localsplus_data_ptr, 0, capacity) }; + + let nlocalsplus_u32 = u32::try_from(nlocalsplus).expect("nlocalsplus exceeds u32"); + let localsplus = LocalsPlus { + data: LocalsPlusData::DataStack { + ptr: localsplus_data_ptr, + capacity, + }, + nlocalsplus: nlocalsplus_u32, + stack_top: 0, + }; + + let mut iframe = Self::new( + code, + globals, + builtins, + func_obj, + localsplus, + locals, + closure, + FrameOwner::Thread, + ); + iframe.datastack_base = base; + + // Write the fully initialized InterpreterFrame into the datastack. + unsafe { + core::ptr::write(iframe_ptr, iframe); + &mut *iframe_ptr + } + } + + /// Release this datastack-allocated frame's resources and return the + /// base pointer for `vm.datastack_pop()`. + /// + /// Drops all localsplus values, runs destructors for all frame fields + /// (trace, temporary_refs, retained_back, etc.), and detaches the + /// backing store. + /// Returns `None` if this frame is not datastack-allocated. + /// + /// After this call, the InterpreterFrame at `self` is logically dead — + /// the caller must not use `self` again except to pass the returned + /// base to `vm.datastack_pop()`. + pub(crate) unsafe fn release_datastack_frame(&mut self) -> Option<*mut u8> { + let base = self.datastack_base; + if base.is_null() { + return None; + } + self.datastack_base = core::ptr::null_mut(); + // Drop all localsplus values while the backing store is still valid. + self.localsplus.drop_values(); + // Detach from the data stack so further accesses see an empty frame. + self.localsplus.data = LocalsPlusData::Heap(Box::default()); + self.localsplus.nlocalsplus = 0; + // Drop remaining frame fields (trace, temporary_refs, retained_back, + // etc.) by running destructors in place. The localsplus is already + // empty/heap-backed, so this only drops non-localsplus fields. + // SAFETY: `self` points to valid, initialized memory on the data + // stack. After this call the memory is logically dead. + unsafe { core::ptr::drop_in_place(self) }; + Some(base) + } + /// Get the last instruction index. #[inline(always)] pub fn get_lasti(&self) -> u32 { @@ -1038,6 +1147,7 @@ impl InterpreterFrame { // If we copied Thread from the source iframe, frame.clear() would // reject the frame with "cannot clear an executing frame". owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), + datastack_base: core::ptr::null_mut(), materialized: Radium::new(0), cold: OnceCell::from(Box::new(FrameColdData { escaped: atomic::AtomicBool::new(true), @@ -1117,6 +1227,7 @@ impl InterpreterFrame { generator: PyAtomicBorrow::new(), previous: Radium::new(0), owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), + datastack_base: core::ptr::null_mut(), materialized: Radium::new(0), cold: OnceCell::from(Box::new(FrameColdData { escaped: atomic::AtomicBool::new(true), @@ -1350,6 +1461,10 @@ unsafe impl Traverse for FrameObject { pub enum ExecutionResult { Return(PyObjectRef), Yield(PyObjectRef), + /// The bytecode loop wants to tail-call into a new frame that has + /// already been prepared on the datastack. The trampoline reads the + /// pending frame pointer from `vm.pending_tailcall_frame`. + TailCall, } /// A valid execution result, or an exception @@ -2094,6 +2209,7 @@ impl Py { func_obj, prev_line: &iframe.prev_line, monitoring_mask: 0, + tailcall_enabled: false, }; f(exec) } @@ -2156,6 +2272,7 @@ impl Py { func_obj, prev_line: &iframe.prev_line, monitoring_mask: 0, + tailcall_enabled: false, }; exec.yield_from_target().map(PyObject::to_owned) } @@ -2179,6 +2296,92 @@ impl Py { } } +/// Byte offset from the start of a datastack allocation to the LocalsPlus data, +/// accounting for alignment padding after the InterpreterFrame header. +#[inline] +fn datastack_iframe_localsplus_offset() -> usize { + let iframe_size = core::mem::size_of::(); + (iframe_size + core::mem::align_of::() - 1) & !(core::mem::align_of::() - 1) +} + +/// Total bytes needed to co-allocate an InterpreterFrame and its LocalsPlus +/// data on the thread data stack. +pub(crate) fn datastack_iframe_total_bytes(nlocalsplus: usize, stacksize: usize) -> usize { + let iframe_padded = datastack_iframe_localsplus_offset(); + let capacity = nlocalsplus + .checked_add(stacksize) + .expect("LocalsPlus capacity overflow"); + let data_bytes = capacity + .checked_mul(core::mem::size_of::()) + .expect("LocalsPlus byte size overflow"); + iframe_padded + .checked_add(data_bytes) + .expect("datastack iframe total size overflow") +} + +/// Handle an exception propagating into a suspended caller frame in the +/// trampoline. Adds a traceback entry at the caller's call site, then +/// tries the caller's exception table via `unwind_blocks`. +/// +/// Returns: +/// - `Ok(None)` — handler found, the caller's `run_iframe` can be re-entered +/// - `Ok(Some(result))` — handler returned a result (break from the run loop) +/// - `Err(exc)` — no handler, exception propagates to the next caller +pub(crate) fn trampoline_handle_exception( + iframe: &mut InterpreterFrame, + exception: &PyBaseExceptionRef, + vm: &VirtualMachine, +) -> FrameResult { + let code: &Py = unsafe { &*iframe.code }; + let globals: &Py = unsafe { &*iframe.globals }; + let builtins: &PyObject = unsafe { &*iframe.builtins }; + let func_obj: Option<&PyObject> = if iframe.func_obj.is_null() { + None + } else { + Some(unsafe { &*iframe.func_obj }) + }; + let builtins_dict = if globals.class().is(vm.ctx.types.dict_type) { + builtins + .downcast_ref_if_exact::(vm) + .map(|d| unsafe { PyExact::ref_unchecked(d) }) + } else { + None + }; + let iframe_ptr = iframe as *const InterpreterFrame; + let mut exec = ExecutingFrame { + code, + localsplus: &mut iframe.localsplus, + locals: &iframe.locals, + globals, + builtins, + builtins_dict, + lasti: &iframe.lasti, + iframe: iframe_ptr, + func_obj, + prev_line: &mut iframe.prev_line, + monitoring_mask: 0, + tailcall_enabled: false, + }; + + // lasti points past the CallPyExactArgs instruction (+ cache entries). + // The exception occurred at the previous instruction (the call site). + let idx = (exec.lasti() as usize).saturating_sub(1); + + // Add traceback entry at the call site. + if let Some((loc, _end_loc)) = exec.code.locations.get(idx) { + let next = exception.__traceback__(); + let new_traceback = PyTraceback::new(next, exec.frame_object(vm), idx as u32 * 2, loc.line); + exception.set_traceback_typed(Some(new_traceback.into_ref(&vm.ctx))); + } + + exec.unwind_blocks( + vm, + UnwindReason::Raising { + exception: exception.clone(), + }, + ) +} + /// Execute an InterpreterFrame's bytecode directly, without a FrameObject. /// /// # Safety @@ -2217,6 +2420,7 @@ pub(crate) fn run_iframe( func_obj, prev_line: &mut iframe.prev_line, monitoring_mask: 0, + tailcall_enabled: true, }; exec.run(vm) } @@ -2246,6 +2450,9 @@ pub(crate) struct ExecutingFrame<'a> { prev_line: &'a core::cell::Cell, /// Cached monitoring events mask. Reloaded at Resume instruction only, monitoring_mask: u32, + /// Whether TailCall is allowed. True when running under the trampoline + /// (`run_frame_fast`), false for FrameObject-based execution. + tailcall_enabled: bool, } #[inline] @@ -5644,7 +5851,11 @@ impl ExecutingFrame<'_> { if self.specialization_call_recursion_guard(vm) { return self.execute_call_vectorcall(nargs, vm); } - // Stage args without a per-call Vec: [self?, arg1, ..., argN] + if self.tailcall_enabled && !func.is_generator_like() { + self.tailcall_prepare_frame(nargs, self_or_null_is_some, vm); + return Ok(Some(ExecutionResult::TailCall)); + } + // Recursive path: pop args and call. let base = usize::from(self_or_null_is_some); let mut arg_buf = CallArgBuffer::new(nargs as usize + base); let args = arg_buf.slots(); @@ -5701,7 +5912,16 @@ impl ExecutingFrame<'_> { if self.specialization_call_recursion_guard(vm) { return self.execute_call_vectorcall(nargs, vm); } - // Stage args without a per-call Vec: + if self.tailcall_enabled && !func.is_generator_like() { + self.tailcall_prepare_bound_method_frame( + nargs, + bound_function, + bound_self, + vm, + ); + return Ok(Some(ExecutionResult::TailCall)); + } + // Recursive path: stage args without a per-call Vec. // [bound_self, arg1, ..., argN] let mut arg_buf = CallArgBuffer::new(nargs as usize + 1); let args = arg_buf.slots(); @@ -10400,6 +10620,129 @@ impl ExecutingFrame<'_> { >= vm.recursion_limit.get() } + /// Prepare a callee frame on the datastack for a TailCall. + /// Pops args, self_or_null, and callable from the caller's stack, + /// builds the callee InterpreterFrame, and stores its pointer in + /// `vm.pending_tailcall_frame`. + /// + /// The callable must be at stack position `nargs + 1` (already validated). + fn tailcall_prepare_frame( + &mut self, + nargs: u32, + self_or_null_is_some: bool, + vm: &VirtualMachine, + ) { + let base = usize::from(self_or_null_is_some); + let effective_nargs = nargs as usize + base; + + // Peek at the callable (still on the stack) to build the callee + // frame. The callable stays on the caller's stack until we're done + // constructing the callee. + let callable = self.nth_value(nargs + 1); + let func = callable.downcast_ref_if_exact::(vm).unwrap(); + + let code: &Py = &func.code; + + let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { + FrameLocals::lazy() + } else { + FrameLocals::with_locals(crate::function::ArgMapping::from_dict_exact( + func.globals.clone(), + )) + }; + + let callee_iframe = InterpreterFrame::new_on_datastack( + code, + &func.globals, + &func.builtins, + Some(func.as_object()), + locals, + func.closure.as_ref().map_or(&[], |c| c.as_slice()), + vm, + ); + + // Move args directly from the caller's stack into callee fastlocals, + // avoiding an intermediate buffer. + { + let fastlocals = callee_iframe.localsplus.fastlocals_mut(); + for (dst, arg) in fastlocals[base..effective_nargs] + .iter_mut() + .zip(self.pop_multiple(nargs as usize)) + { + *dst = Some(arg); + } + let self_or_null = self.pop_value_opt(); + debug_assert_eq!(self_or_null.is_some(), self_or_null_is_some); + if self_or_null.is_some() { + fastlocals[0] = self_or_null; + } + } + + // Pop the callable and transfer ownership to the trampoline via + // the VM side channel, avoiding a per-frame mutex lock on + // temporary_refs. + let callable = self.pop_value(); + unsafe { &mut *vm.pending_tailcall_refs.get() }.push(callable); + + vm.set_pending_tailcall(callee_iframe); + } + + /// Prepare a callee frame for a bound method TailCall. + /// Pops args, self_or_null (null), and callable from the caller's stack, + /// builds the callee InterpreterFrame with bound_self prepended, and + /// stores its pointer in `vm.pending_tailcall_frame`. + fn tailcall_prepare_bound_method_frame( + &mut self, + nargs: u32, + bound_function: PyObjectRef, + bound_self: PyObjectRef, + vm: &VirtualMachine, + ) { + let effective_nargs = nargs as usize + 1; // +1 for bound_self + + let func = bound_function + .downcast_ref_if_exact::(vm) + .unwrap(); + let code: &Py = &func.code; + + let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { + FrameLocals::lazy() + } else { + FrameLocals::with_locals(crate::function::ArgMapping::from_dict_exact( + func.globals.clone(), + )) + }; + + let callee_iframe = InterpreterFrame::new_on_datastack( + code, + &func.globals, + &func.builtins, + Some(func.as_object()), + locals, + func.closure.as_ref().map_or(&[], |c| c.as_slice()), + vm, + ); + + // Move args directly from the caller's stack into callee fastlocals. + let fastlocals = callee_iframe.localsplus.fastlocals_mut(); + for (dst, arg) in fastlocals[1..effective_nargs] + .iter_mut() + .zip(self.pop_multiple(nargs as usize)) + { + *dst = Some(arg); + } + self.pop_value_opt(); // null (self_or_null) + let callable = self.pop_value(); // callable (bound method) + fastlocals[0] = Some(bound_self); + + // Transfer ownership to the trampoline via the VM side channel. + let refs = unsafe { &mut *vm.pending_tailcall_refs.get() }; + refs.push(bound_function); + refs.push(callable); + + vm.set_pending_tailcall(callee_iframe); + } + #[inline] fn for_iter_has_end_for_shape(&self, instr_idx: usize, jump_delta: u32) -> bool { let target_idx = instr_idx diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index a48ce56c1..e0a086c10 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -106,6 +106,15 @@ pub struct VirtualMachine { pub asyncio_running_task: RefCell>, pub(crate) callable_cache: CallableCache, pub(crate) audit_hooks: RefCell>, + /// Side channel for TailCall: the bytecode loop stores the new frame + /// pointer here before returning `ExecutionResult::TailCall`. + /// Access only via `set_pending_tailcall` / `take_pending_tailcall`. + pending_tailcall_frame: Cell>, + /// Owned references that keep callee raw pointers valid during TailCall. + /// Set by `tailcall_prepare_frame`, drained by the trampoline into + /// its local `owned_refs` Vec. Uses UnsafeCell because the VM is + /// per-thread and this field is only accessed on the owning thread. + pub(crate) pending_tailcall_refs: core::cell::UnsafeCell>, } /// Non-owning frame pointer for the non-unix threading frames stack. @@ -777,6 +786,60 @@ pub fn process_hash_secret_seed() -> u32 { *SEED.get_or_init(|| u32::from_ne_bytes(rustpython_common::rand::os_random())) } +/// A `NonNull` wrapper that implements `Send + Sync`. +/// +/// # Safety contract +/// +/// This type bypasses Rust's `Send`/`Sync` bounds on `NonNull`. It is +/// sound **only** when the pointer is exclusively accessed by one thread +/// at a time. In this codebase, that invariant is upheld because +/// `VirtualMachine` is per-thread. +/// +/// **Do not use this type outside `pending_tailcall_frame`.** It exists +/// solely to let a `Cell>` field on the per-thread +/// VM satisfy `Send + Sync`. If you need a `Send`-able pointer +/// elsewhere, justify and document the safety invariant at that site. +#[repr(transparent)] +struct PendingFrame(core::ptr::NonNull); + +impl Copy for PendingFrame {} +impl Clone for PendingFrame { + fn clone(&self) -> Self { + *self + } +} + +// SAFETY: VirtualMachine is per-thread; the pointer is only ever +// accessed on the thread that wrote it. The pointed-to InterpreterFrame +// lives on that thread's datastack and is valid from set to take. +unsafe impl Send for PendingFrame {} +unsafe impl Sync for PendingFrame {} + +/// Saved state from `enter_iframe`, needed by `exit_iframe` to restore +/// the previous frame chain and exception state. +pub(crate) struct IframeEntryState { + pub(crate) iframe_ptr: *const crate::frame::InterpreterFrame, + pub(crate) old_chain: *const crate::frame::InterpreterFrame, + pub(crate) saved_exc: Option, + pub(crate) save_exc: bool, +} + +/// Caller frame suspended by a TailCall in the trampoline. +struct SuspendedFrame { + iframe: *mut crate::frame::InterpreterFrame, + entry_state: IframeEntryState, + /// Owned references that keep callee's raw pointers (code, globals, + /// builtins borrowed from PyFunction) valid. Drained from + /// `vm.pending_tailcall_refs` when the callee's TailCall is consumed. + /// Dropped when this SuspendedFrame is popped (after callee returns/errors). + owned_refs: Vec, + /// True for the initial frame passed into the trampoline by the caller. + /// The caller owns the datastack allocation for the entry frame, so the + /// trampoline must NOT release it — only callee-allocated frames are + /// released here. + is_entry: bool, +} + impl VirtualMachine { fn init_callable_cache(&mut self) -> PyResult<()> { self.callable_cache.len = Some(self.builtins.get_attr("len", self)?); @@ -891,6 +954,8 @@ impl VirtualMachine { asyncio_running_task: RefCell::new(None), callable_cache: CallableCache::default(), audit_hooks: RefCell::new(vec![]), + pending_tailcall_frame: Cell::new(None), + pending_tailcall_refs: core::cell::UnsafeCell::new(Vec::with_capacity(2)), }; if vm.state.hash_secret.hash_str("") @@ -1331,13 +1396,294 @@ impl VirtualMachine { } } + /// Store a callee frame pointer for the trampoline to pick up after + /// `TailCall` is returned. The pointed-to InterpreterFrame must live + /// on the current thread's datastack and remain valid until the + /// trampoline calls `take_pending_tailcall`. + #[inline(always)] + pub(crate) fn set_pending_tailcall(&self, iframe: &mut crate::frame::InterpreterFrame) { + self.pending_tailcall_frame + .set(Some(PendingFrame(core::ptr::NonNull::from(iframe)))); + } + + /// Take the pending tailcall frame pointer, resetting the side channel. + #[inline(always)] + fn take_pending_tailcall(&self) -> *mut crate::frame::InterpreterFrame { + self.pending_tailcall_frame + .take() + .expect("TailCall without pending frame") + .0 + .as_ptr() + } + #[inline(always)] /// Run a stack-allocated InterpreterFrame without heap allocation. - /// This is the fast path for regular (non-generator) function calls. + /// Uses a trampoline loop to flatten Python-to-Python calls: when the + /// bytecode loop returns `TailCall`, the trampoline swaps to the new + /// frame without adding a Rust stack frame. pub fn run_frame_fast(&self, iframe: &mut crate::frame::InterpreterFrame) -> PyResult { - match self.with_iframe(iframe, |iframe| crate::frame::run_iframe(iframe, self))? { - ExecutionResult::Return(value) => Ok(value), - _ => panic!("Got unexpected result from function"), + use crate::frame::ExecutionResult; + + let entry_state = self.enter_iframe(iframe)?; + let result = crate::frame::run_iframe(iframe, self); + + match result { + Ok(ExecutionResult::Return(value)) => { + self.exit_iframe(entry_state); + Ok(value) + } + Ok(ExecutionResult::TailCall) => self.run_frame_fast_trampoline(iframe, entry_state), + Ok(ExecutionResult::Yield(_)) => panic!("Yield in non-generator frame"), + Err(exc) => { + self.exit_iframe(entry_state); + Err(exc) + } + } + } + + /// Cold path: at least one TailCall was issued. Run the trampoline. + /// All frame dispatch happens in this single loop — no mutual recursion + /// between helper functions, so C stack depth is bounded. + #[cold] + #[inline(never)] + fn run_frame_fast_trampoline( + &self, + iframe: &mut crate::frame::InterpreterFrame, + entry_state: IframeEntryState, + ) -> PyResult { + use crate::frame::ExecutionResult; + + let mut frame_stack: Vec = Vec::with_capacity(8); + + // What we need to do next. + enum Action { + /// Enter and run a new callee frame (pointer from pending_tailcall_frame). + EnterCallee(*mut crate::frame::InterpreterFrame), + /// Push a return value onto the next caller and re-enter it. + ReturnValue(PyObjectRef), + /// Propagate an exception through suspended callers. + Unwind(PyBaseExceptionRef), + } + + let initial_ptr = self.take_pending_tailcall(); + // Drain the refs that keep the initial callee's raw pointers alive. + let initial_refs = unsafe { &mut *self.pending_tailcall_refs.get() } + .drain(..) + .collect(); + frame_stack.push(SuspendedFrame { + iframe: iframe as *mut crate::frame::InterpreterFrame, + entry_state, + owned_refs: initial_refs, + is_entry: true, + }); + let mut action = Action::EnterCallee(initial_ptr); + + loop { + match action { + Action::EnterCallee(callee_ptr) => { + let callee = unsafe { &mut *callee_ptr }; + let callee_entry = match self.enter_iframe_unchecked(callee) { + Ok(state) => state, + Err(exc) => { + unsafe { + if let Some(base) = callee.release_datastack_frame() { + self.datastack_pop(base); + } + } + action = Action::Unwind(exc); + continue; + } + }; + + let result = crate::frame::run_iframe(callee, self); + match result { + Ok(ExecutionResult::TailCall) => { + let refs = unsafe { &mut *self.pending_tailcall_refs.get() } + .drain(..) + .collect(); + frame_stack.push(SuspendedFrame { + iframe: callee_ptr, + entry_state: callee_entry, + owned_refs: refs, + is_entry: false, + }); + action = Action::EnterCallee(self.take_pending_tailcall()); + } + Ok(ExecutionResult::Return(value)) => { + self.exit_iframe(callee_entry); + unsafe { + if let Some(base) = callee.release_datastack_frame() { + self.datastack_pop(base); + } + } + action = Action::ReturnValue(value); + } + Ok(ExecutionResult::Yield(_)) => panic!("Yield in non-generator frame"), + Err(exc) => { + self.exit_iframe(callee_entry); + unsafe { + if let Some(base) = callee.release_datastack_frame() { + self.datastack_pop(base); + } + } + action = Action::Unwind(exc); + } + } + } + + Action::ReturnValue(value) => { + let Some(caller) = frame_stack.pop() else { + // All frames consumed — this is the final return. + return Ok(value); + }; + let SuspendedFrame { + iframe: caller_iframe_ptr, + entry_state: caller_entry, + owned_refs: _caller_refs, + is_entry: caller_is_entry, + } = caller; + let caller_iframe = unsafe { &mut *caller_iframe_ptr }; + caller_iframe.localsplus.push_stack(value); + + let result = crate::frame::run_iframe(caller_iframe, self); + match result { + Ok(ExecutionResult::TailCall) => { + let refs = unsafe { &mut *self.pending_tailcall_refs.get() } + .drain(..) + .collect(); + drop(_caller_refs); + frame_stack.push(SuspendedFrame { + iframe: caller_iframe_ptr, + entry_state: caller_entry, + owned_refs: refs, + is_entry: caller_is_entry, + }); + action = Action::EnterCallee(self.take_pending_tailcall()); + } + Ok(ExecutionResult::Return(value)) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = caller_iframe.release_datastack_frame() { + self.datastack_pop(base); + } + } + } + action = Action::ReturnValue(value); + } + Ok(ExecutionResult::Yield(_)) => panic!("Yield in non-generator frame"), + Err(exc) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = caller_iframe.release_datastack_frame() { + self.datastack_pop(base); + } + } + } + action = Action::Unwind(exc); + } + } + } + + Action::Unwind(exc) => { + let Some(caller) = frame_stack.pop() else { + return Err(exc); + }; + let SuspendedFrame { + iframe: caller_iframe_ptr, + entry_state: caller_entry, + owned_refs: _caller_refs, + is_entry: caller_is_entry, + } = caller; + let caller_iframe = unsafe { &mut *caller_iframe_ptr }; + + let handled = + crate::frame::trampoline_handle_exception(caller_iframe, &exc, self); + + match handled { + Ok(None) => { + // Handler found — resume the caller's dispatch loop. + let result = crate::frame::run_iframe(caller_iframe, self); + match result { + Ok(ExecutionResult::TailCall) => { + let refs = unsafe { &mut *self.pending_tailcall_refs.get() } + .drain(..) + .collect(); + drop(_caller_refs); + frame_stack.push(SuspendedFrame { + iframe: caller_iframe_ptr, + entry_state: caller_entry, + owned_refs: refs, + is_entry: caller_is_entry, + }); + action = Action::EnterCallee(self.take_pending_tailcall()); + } + Ok(ExecutionResult::Return(value)) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop(base); + } + } + } + action = Action::ReturnValue(value); + } + Ok(ExecutionResult::Yield(_)) => { + panic!("Yield in non-generator frame") + } + Err(new_exc) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop(base); + } + } + } + action = Action::Unwind(new_exc); + } + } + } + Ok(Some(ExecutionResult::Return(value))) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = caller_iframe.release_datastack_frame() { + self.datastack_pop(base); + } + } + } + action = Action::ReturnValue(value); + } + Ok(Some(_)) => { + panic!("Unexpected execution result in trampoline unwind") + } + Err(new_exc) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = caller_iframe.release_datastack_frame() { + self.datastack_pop(base); + } + } + } + action = Action::Unwind(new_exc); + } + } + } + } } } @@ -1805,15 +2151,14 @@ impl VirtualMachine { result } - /// Execute a stack-allocated InterpreterFrame without heap-allocating - /// a FrameObject. This is the fast path for regular function calls. - /// The frame is pushed onto the chain as a `*const InterpreterFrame`. - #[inline(always)] - pub fn with_iframe( + /// Push `iframe` onto the frame chain: recursion/C-stack check, TLS + /// link, exception save. Returns the saved state needed by + /// `exit_iframe`. + #[inline] + pub(crate) fn enter_iframe( &self, iframe: &mut crate::frame::InterpreterFrame, - f: impl FnOnce(&mut crate::frame::InterpreterFrame) -> PyResult, - ) -> PyResult { + ) -> PyResult { self.check_recursive_call("")?; let depth = self.recursion_depth.get(); @@ -1821,6 +2166,23 @@ impl VirtualMachine { return Err(self.new_recursion_error(String::new())); } + self.enter_iframe_unchecked(iframe) + } + + /// Like `enter_iframe` but skips the Python recursion depth check + /// (already verified by `specialization_call_recursion_guard`). + /// Still checks C-stack overflow since each `run_iframe` call + /// consumes Rust stack space. + #[inline(always)] + pub(crate) fn enter_iframe_unchecked( + &self, + iframe: &mut crate::frame::InterpreterFrame, + ) -> PyResult { + let depth = self.recursion_depth.get(); + if depth & 7 == 0 && self.check_c_stack_overflow() { + return Err(self.new_recursion_error(String::new())); + } + self.recursion_depth.update(|d| d + 1); let iframe_ptr = iframe as *const crate::frame::InterpreterFrame; @@ -1839,29 +2201,35 @@ impl VirtualMachine { None }; - let result = f(iframe); + Ok(IframeEntryState { + iframe_ptr, + old_chain, + saved_exc, + save_exc, + }) + } + + /// Pop `iframe` from the frame chain: sync materialized state, restore + /// exception, TLS unlink, GC tracking. + pub(crate) fn exit_iframe(&self, state: IframeEntryState) { + let IframeEntryState { + iframe_ptr, + old_chain, + saved_exc, + save_exc, + } = state; // If this iframe was materialized, capture f_back so that code - // holding a reference to the FrameObject (e.g. sys._getframe() - // return value, traceback frames) can walk the chain after return. - // - // Read materialized through the raw TLS pointer instead of the - // &mut iframe reference. During f(iframe), bytecode can - // materialize the frame via the TLS chain (a raw pointer alias); - // the &mut borrow lets LLVM assume no aliased writes, which can - // cause the store to be invisible through `iframe.materialized`. + // holding a reference to the FrameObject can walk the chain after + // return. Read materialized through read_volatile to bypass + // LLVM's noalias on the &mut iframe borrow. { - // Use read_volatile through the original raw pointer to bypass - // LLVM's noalias assumptions on the &mut iframe borrow. let mat_ptr = unsafe { let field_ptr = core::ptr::addr_of!((*iframe_ptr).materialized); core::ptr::read_volatile(field_ptr as *const usize) }; if mat_ptr != 0 { let fo = unsafe { &*(mat_ptr as *const crate::Py) }; - // Sync localsplus, prev_line, lasti from the live iframe to - // the materialized FrameObject so f_locals, f_lineno, f_lasti - // reflect the final state after execution. unsafe { let live_iframe = &*iframe_ptr; fo.iframe_mut() @@ -1879,15 +2247,9 @@ impl VirtualMachine { } if !old_chain.is_null() { let prev_iframe = unsafe { &*old_chain }; - // Use materialize_chain to avoid cloning localsplus, which - // would create extra refcounts on local variables. The - // lightweight frame has empty localsplus; live values are - // read through find_live_source_iframe when needed. let back_fo = prev_iframe.materialize_chain(self); *fo.iframe().cold().retained_back.lock() = Some(back_fo); } - // Set owner to FrameObject since this frame is no longer - // executing on a thread. fo.iframe().owner.store( crate::frame::FrameOwner::FrameObject as i8, core::sync::atomic::Ordering::Release, @@ -1898,15 +2260,22 @@ impl VirtualMachine { if save_exc { self.restore_exception(saved_exc); } - // Restore the frame chain BEFORE clearing temporary_refs, so - // top_frame no longer points at the materialized FrameObject - // when its last strong reference is released. + // Clear previous before popping — it may point to a stack-allocated + // iframe that will be freed when the caller's with_iframe exits. + { + #[allow(unused_imports)] + use rustpython_common::atomic::Radium; + unsafe { + (*iframe_ptr) + .previous + .store(0, core::sync::atomic::Ordering::Relaxed); + } + } let _ = crate::vm::thread::set_current_frame(old_chain); self.recursion_depth.update(|d| d - 1); - // Now that the frame is off the chain, track the materialized - // FrameObject in the GC and release temporary_refs so cycle - // collection can detect and reclaim reference cycles. + // Track the materialized FrameObject in GC and release + // temporary_refs after the frame is off the chain. { let mat_ptr = unsafe { let field_ptr = core::ptr::addr_of!((*iframe_ptr).materialized); @@ -1922,7 +2291,19 @@ impl VirtualMachine { } } } + } + pub fn with_iframe( + &self, + iframe: &mut crate::frame::InterpreterFrame, + f: impl FnOnce(&mut crate::frame::InterpreterFrame) -> PyResult, + ) -> PyResult { + let state = self.enter_iframe(iframe)?; + // Ensure exit_iframe runs even if f(iframe) panics. + let guard = scopeguard::guard(state, |s| self.exit_iframe(s)); + let result = f(iframe); + let state = scopeguard::ScopeGuard::into_inner(guard); + self.exit_iframe(state); result } diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 1520b2cd8..1bab539a0 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -1022,6 +1022,8 @@ impl VirtualMachine { asyncio_running_task: RefCell::new(None), callable_cache: self.callable_cache.clone(), audit_hooks: RefCell::new(vec![]), + pending_tailcall_frame: Cell::new(None), + pending_tailcall_refs: core::cell::UnsafeCell::new(Vec::with_capacity(2)), }; ThreadedVirtualMachine { vm } } From 02e3656cb341bec46e7e621b31309195b71885a7 Mon Sep 17 00:00:00 2001 From: fanninpm <27117322+fanninpm@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:22:30 -0400 Subject: [PATCH 24/94] Update Python version to 3.14.7 (#8456) --- .github/workflows/update-doc-db.yml | 2 +- .github/workflows/upgrade-pylib.md | 2 +- .python-version | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/update-doc-db.yml b/.github/workflows/update-doc-db.yml index e9fa285ae..c42235c86 100644 --- a/.github/workflows/update-doc-db.yml +++ b/.github/workflows/update-doc-db.yml @@ -8,7 +8,7 @@ on: python-version: description: Target python version to generate doc db for type: string - default: "3.14.3" + default: "3.14.7" base-ref: description: Base branch to create the update branch from type: string diff --git a/.github/workflows/upgrade-pylib.md b/.github/workflows/upgrade-pylib.md index ac71f3d72..cd05eb777 100644 --- a/.github/workflows/upgrade-pylib.md +++ b/.github/workflows/upgrade-pylib.md @@ -52,7 +52,7 @@ cache: - cpython-lib- env: - PYTHON_VERSION: "v3.14.6" + PYTHON_VERSION: "v3.14.7" ISSUE_ID: "6839" --- diff --git a/.python-version b/.python-version index 3f0a10fda..a128d5c0d 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14.6 +3.14.7 From e7e02c14d2681e2398064e417b5a46237c94369d Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:23:05 -0400 Subject: [PATCH 25/94] Fix a few WASI lints (#8457) I left one lint (a clippy deny) unfixed because it's a lot more involved, so I'll work on it in a different patch. --- crates/vm/src/frame.rs | 1 + crates/vm/src/gc_state.rs | 12 ++++++------ crates/vm/src/stdlib/_signal.rs | 7 +++++++ crates/vm/src/stdlib/posix_compat.rs | 2 +- crates/vm/src/stdlib/time.rs | 1 + 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index e1836ad02..e7141525f 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -3046,6 +3046,7 @@ impl ExecutingFrame<'_> { } } + #[cfg_attr(not(feature = "threading"), allow(clippy::collapsible_if))] if vm.eval_breaker_tripped() { if let Err(exception) = vm.check_signals() { #[cold] diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index 5843dfe24..9744d4ae9 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -11,8 +11,8 @@ use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::collections::HashSet; fn elapsed_secs( - #[cfg(target_arch = "wasm32")] _start: &(), - #[cfg(not(target_arch = "wasm32"))] start: &std::time::Instant, + #[cfg(target_arch = "wasm32")] _start: (), + #[cfg(not(target_arch = "wasm32"))] start: std::time::Instant, ) -> f64 { cfg_select! { target_arch = "wasm32" => 0.0, @@ -528,7 +528,7 @@ impl GcState { self.generations[i].count.store(0, Ordering::SeqCst); } - let duration = elapsed_secs(&start_time); + let duration = elapsed_secs(start_time); self.generations[generation].update_stats(0, 0, 0, duration); return CollectResult { @@ -704,7 +704,7 @@ impl GcState { self.generations[i].count.store(0, Ordering::SeqCst); } - let duration = elapsed_secs(&start_time); + let duration = elapsed_secs(start_time); self.generations[generation].update_stats(0, 0, candidates, duration); return CollectResult { @@ -727,7 +727,7 @@ impl GcState { self.generations[i].count.store(0, Ordering::SeqCst); } - let duration = elapsed_secs(&start_time); + let duration = elapsed_secs(start_time); self.generations[generation].update_stats(0, 0, candidates, duration); return CollectResult { @@ -953,7 +953,7 @@ impl GcState { self.generations[i].count.store(0, Ordering::SeqCst); } - let duration = elapsed_secs(&start_time); + let duration = elapsed_secs(start_time); self.generations[generation].update_stats(collected, 0, candidates, duration); diff --git a/crates/vm/src/stdlib/_signal.rs b/crates/vm/src/stdlib/_signal.rs index d5083962b..5879f8776 100644 --- a/crates/vm/src/stdlib/_signal.rs +++ b/crates/vm/src/stdlib/_signal.rs @@ -429,6 +429,13 @@ pub(crate) mod _signal { } #[pyfunction] + #[cfg_attr( + not(any(unix, windows)), + expect( + clippy::unnecessary_wraps, + reason = "WASI does not support signals yet" + ) + )] fn valid_signals(vm: &VirtualMachine) -> PyResult { use crate::PyPayload; use crate::builtins::PySet; diff --git a/crates/vm/src/stdlib/posix_compat.rs b/crates/vm/src/stdlib/posix_compat.rs index c50134a33..9afea821c 100644 --- a/crates/vm/src/stdlib/posix_compat.rs +++ b/crates/vm/src/stdlib/posix_compat.rs @@ -60,7 +60,7 @@ pub(crate) mod module { #[allow(dead_code)] fn os_unimpl(func: &str, vm: &VirtualMachine) -> PyResult { - Err(vm.new_os_error(format!("{} is not supported on this platform", func))) + Err(vm.new_os_error(format!("{func} is not supported on this platform"))) } pub(crate) fn support_funcs() -> Vec { diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index 54ccd46d7..3d777c24b 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -609,6 +609,7 @@ mod decl { } #[pyfunction] + #[cfg_attr(not(any(unix, windows)), expect(clippy::unnecessary_wraps,))] fn strftime(format: PyStrRef, t: OptionalArg, vm: &VirtualMachine) -> PyResult { #[cfg(any(unix, windows))] { From 16827479d850b20598bd2a39b2a29013dc6aadf7 Mon Sep 17 00:00:00 2001 From: OkJa <151524504+name-of-okja@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:25:21 +0900 Subject: [PATCH 26/94] Implement cell comparison and repr (#8458) The cell type filled neither the richcompare nor the repr slot, so object's address-based defaults showed through: cell(1) == cell(1) was False, ordering raised TypeError, and repr rendered rather than . Compare cells by contents, with empty cells ordering before everything else, and render CPython's repr for both the filled and empty cases. The comparison fills the richcompare slot directly rather than going through Comparable, whose cmp() can only answer with a bool. CPython returns whatever PyObject_RichCompare produced, so a contained __eq__ that yields a non-bool must pass through untouched; coercing it would also call __bool__ and surface exceptions CPython never raises. The empty-cell branch still answers with a bool, so both arms are needed and the slot returns Either. The repr truncates the contained type name the way "%.80s" does: at most 80 bytes, dropping a character the cut would leave incomplete. Mark the type unhashable. Content-based equality combined with the inherited identity hash would break the hash/eq contract, and hashing the contents is not possible either because cell_contents is writable. CPython gets this implicitly, because defining tp_richcompare suppresses tp_hash inheritance. Reference: CPython Objects/cellobject.c, cell_richcompare and cell_repr. Assisted-by: Claude Code:claude-opus-5 --- Lib/test/test_funcattrs.py | 1 - Lib/test/test_reprlib.py | 1 - crates/vm/src/builtins/function.rs | 48 ++++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_funcattrs.py b/Lib/test/test_funcattrs.py index ff696c5c1..bb9c88efe 100644 --- a/Lib/test/test_funcattrs.py +++ b/Lib/test/test_funcattrs.py @@ -432,7 +432,6 @@ def empty_cell(empty=True): class CellTest(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON def test_comparison(self): # These tests are here simply to exercise the comparison code; # their presence should not be interpreted as providing any diff --git a/Lib/test/test_reprlib.py b/Lib/test/test_reprlib.py index db3d87bd1..22a55b57c 100644 --- a/Lib/test/test_reprlib.py +++ b/Lib/test/test_reprlib.py @@ -237,7 +237,6 @@ class ReprTests(unittest.TestCase): eq(r([[[[[[{}]]]]]]), "[[[[[[{}]]]]]]") eq(r([[[[[[[{}]]]]]]]), "[[[[[[[...]]]]]]]") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_cell(self): def get_cell(): x = 42 diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index d1fa52223..90315bcb1 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -15,7 +15,7 @@ use crate::{ class::PyClassImpl, common::wtf8::{Wtf8Buf, wtf8_concat}, frame::{FrameObject, FrameObjectRef}, - function::{FuncArgs, OptionalArg, PyComparisonValue, PySetterValue}, + function::{Either, FuncArgs, OptionalArg, PyComparisonValue, PySetterValue}, scope::Scope, types::{ Callable, Comparable, Constructor, GetAttr, GetDescriptor, Hashable, PyComparisonOp, @@ -1507,7 +1507,7 @@ impl Representable for PyBoundMethod { } } -#[pyclass(module = false, name = "cell", traverse)] +#[pyclass(module = false, name = "cell", unhashable = true, traverse)] #[derive(Debug, Default)] pub(crate) struct PyCell { contents: PyMutex>, @@ -1530,8 +1530,26 @@ impl Constructor for PyCell { } } -#[pyclass(with(Constructor))] +#[pyclass(with(Constructor, Representable))] impl PyCell { + #[pyslot] + fn slot_richcompare( + zelf: &PyObject, + other: &PyObject, + op: PyComparisonOp, + vm: &VirtualMachine, + ) -> PyResult> { + let (Some(zelf), Some(other)) = (zelf.downcast_ref::(), other.downcast_ref::()) + else { + return Ok(Either::B(PyComparisonValue::NotImplemented)); + }; + // compare cells by contents; empty cells come before anything else + match (zelf.get(), other.get()) { + (Some(a), Some(b)) => a.rich_compare(b, op, vm).map(Either::A), + (a, b) => Ok(Either::B(op.eval_ord(b.is_none().cmp(&a.is_none())).into())), + } + } + pub(crate) const fn new(contents: Option) -> Self { Self { contents: PyMutex::new(contents), @@ -1561,6 +1579,30 @@ impl PyCell { } } +impl Representable for PyCell { + #[inline] + fn repr_str(zelf: &Py, _vm: &VirtualMachine) -> PyResult { + let id = zelf.get_id(); + Ok(match zelf.get() { + Some(value) => { + let type_name = value.class().slot_name(); + // CPython renders the type name with "%.80s", which reads at + // most 80 bytes and drops a character left incomplete by the cut. + let mut end = type_name.len().min(80); + while !type_name.is_char_boundary(end) { + end -= 1; + } + format!( + "", + &type_name[..end], + value.get_id() + ) + } + None => format!(""), + }) + } +} + /// Vectorcall implementation for PyFunction (PEP 590). /// Takes owned args to avoid cloning when filling fastlocals. pub(crate) fn vectorcall_function( From 85398d2f70b1ad7058ca508a4843e9606e931b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EB=8F=99=EC=95=88?= Date: Sat, 8 Aug 2026 22:30:05 +0900 Subject: [PATCH 27/94] Specialize list.sort() comparisons for homogeneous lists (#8464) * Use type-specialized comparators for homogeneous list sorts Scan the sort keys once before sorting; when every element is exactly str, int, or float, compare wtf8 bytes / BigInt / f64 directly instead of going through rich_compare_bool dispatch, mirroring CPython's pre-sort check in listsort.c (unsafe_latin_compare and friends). Subclasses and mixed-type lists keep the generic __lt__ path. * Add edge-case tests for specialized list sorts * Cache the richcompare slot for homogeneous list sorts * Specialize tuple sorts on their first elements * Apply ruff formatting to the new list sort tests * Deduplicate the reverse swap across sort dispatch arms --- crates/vm/src/builtins/list.rs | 228 ++++++++++++++++++++++++--- extra_tests/snippets/builtin_list.py | 27 ++++ 2 files changed, 237 insertions(+), 18 deletions(-) diff --git a/crates/vm/src/builtins/list.rs b/crates/vm/src/builtins/list.rs index 2b83a5c10..c2059e288 100644 --- a/crates/vm/src/builtins/list.rs +++ b/crates/vm/src/builtins/list.rs @@ -9,10 +9,10 @@ use crate::common::lock::{ use crate::object::{Traverse, TraverseFn}; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, - builtins::PyStr, + builtins::{PyFloat, PyInt, PyStr, PyTuple}, class::PyClassImpl, convert::ToPyObject, - function::{ArgSize, FuncArgs, OptionalArg, PyComparisonValue}, + function::{ArgSize, Either, FuncArgs, OptionalArg, PyComparisonValue}, iter::PyExactSizeIterator, protocol::{PyIterReturn, PyMappingMethods, PySequenceMethods}, recursion::ReprGuard, @@ -21,7 +21,7 @@ use crate::{ sorting::timsort, types::{ AsMapping, AsSequence, Comparable, Constructor, Initializer, IterNext, Iterable, - PyComparisonOp, Representable, SelfIter, + PyComparisonOp, Representable, RichCompareFunc, SelfIter, }, vm::VirtualMachine, }; @@ -638,34 +638,226 @@ impl Representable for PyList { } } +enum Elem { + Str, + Int, + Float, + Object(RichCompareFunc), + Generic, +} + +enum PreSort { + Str, + Int, + Float, + Object(RichCompareFunc), + Tuple(Elem), + Generic, +} + +impl From for PreSort { + fn from(e: Elem) -> Self { + match e { + Elem::Str => Self::Str, + Elem::Int => Self::Int, + Elem::Float => Self::Float, + Elem::Object(f) => Self::Object(f), + Elem::Generic => Self::Generic, + } + } +} + +fn classify(class: &Py, vm: &VirtualMachine) -> Elem { + if class.is(vm.ctx.types.str_type) { + Elem::Str + } else if class.is(vm.ctx.types.int_type) { + Elem::Int + } else if class.is(vm.ctx.types.float_type) { + Elem::Float + } else if let Some(f) = class.slots.richcompare.load() { + Elem::Object(f) + } else { + Elem::Generic + } +} + +fn pre_sort_check<'a>( + mut keys: impl Iterator, + vm: &VirtualMachine, +) -> PreSort { + let Some(first) = keys.next() else { + return PreSort::Generic; + }; + + if let Some(t) = first + .downcast_ref_if_exact::(vm) + .filter(|t| !t.as_slice().is_empty()) + { + pre_sort_check_tuples(&t.as_slice()[0], keys, vm) + } else { + let class = first.class(); + if keys.all(|k| k.class().is(class)) { + classify(class, vm).into() + } else { + PreSort::Generic + } + } +} + +fn pre_sort_check_tuples<'a>( + first_elem: &PyObjectRef, + keys: impl Iterator, + vm: &VirtualMachine, +) -> PreSort { + let class = first_elem.class(); + let mut all_same_type = true; + + for k in keys { + let Some(t) = k + .downcast_ref_if_exact::(vm) + .filter(|t| !t.as_slice().is_empty()) + else { + return PreSort::Generic; + }; + if all_same_type && !t.as_slice()[0].class().is(class) { + all_same_type = false; + } + } + + let elem = if !all_same_type || class.is(vm.ctx.types.tuple_type) { + Elem::Generic + } else { + classify(class, vm) + }; + PreSort::Tuple(elem) +} + +fn str_lt(a: &PyObjectRef, b: &PyObjectRef) -> bool { + a.downcast_ref::().unwrap().as_bytes() < b.downcast_ref::().unwrap().as_bytes() +} + +fn int_lt(a: &PyObjectRef, b: &PyObjectRef) -> bool { + a.downcast_ref::().unwrap().as_bigint() < b.downcast_ref::().unwrap().as_bigint() +} + +fn float_lt(a: &PyObjectRef, b: &PyObjectRef) -> bool { + a.downcast_ref::().unwrap().to_f64() < b.downcast_ref::().unwrap().to_f64() +} + +fn object_lt( + cmp: RichCompareFunc, + a: &PyObjectRef, + b: &PyObjectRef, + vm: &VirtualMachine, +) -> PyResult { + #[allow(unpredictable_function_pointer_comparisons)] + if a.class().slots.richcompare.load() != Some(cmp) { + return a.rich_compare_bool(b, PyComparisonOp::Lt, vm); + } + match cmp(a, b, PyComparisonOp::Lt, vm)? { + Either::B(PyComparisonValue::Implemented(v)) => Ok(v), + Either::B(PyComparisonValue::NotImplemented) => { + a.rich_compare_bool(b, PyComparisonOp::Lt, vm) + } + Either::A(obj) => { + if obj.is(&vm.ctx.not_implemented) { + a.rich_compare_bool(b, PyComparisonOp::Lt, vm) + } else { + obj.try_to_bool(vm) + } + } + } +} + +fn elem_lt(elem: &Elem, a: &PyObjectRef, b: &PyObjectRef, vm: &VirtualMachine) -> PyResult { + match elem { + Elem::Str => Ok(str_lt(a, b)), + Elem::Int => Ok(int_lt(a, b)), + Elem::Float => Ok(float_lt(a, b)), + Elem::Object(f) => object_lt(*f, a, b, vm), + Elem::Generic => a.rich_compare_bool(b, PyComparisonOp::Lt, vm), + } +} + +fn tuple_lt(elem: &Elem, a: &PyObjectRef, b: &PyObjectRef, vm: &VirtualMachine) -> PyResult { + let a = a.downcast_ref::().unwrap().as_slice(); + let b = b.downcast_ref::().unwrap().as_slice(); + + let mut i = 0; + while i < a.len() && i < b.len() { + if !a[i].rich_compare_bool(&b[i], PyComparisonOp::Eq, vm)? { + break; + } + i += 1; + } + if i >= a.len() || i >= b.len() { + return Ok(a.len() < b.len()); + } + if i == 0 { + elem_lt(elem, &a[0], &b[0], vm) + } else { + a[i].rich_compare_bool(&b[i], PyComparisonOp::Lt, vm) + } +} + +fn timsort_by(items: &mut [T], reverse: bool, key: &K, mut lt: L) -> PyResult<()> +where + T: Clone, + K: Fn(&T) -> &PyObjectRef, + L: FnMut(&PyObjectRef, &PyObjectRef) -> PyResult, +{ + timsort(items, &mut |a, b| { + let (a, b) = if reverse { + (key(b), key(a)) + } else { + (key(a), key(b)) + }; + lt(a, b) + }) +} + +fn timsort_specialized( + vm: &VirtualMachine, + items: &mut [T], + reverse: bool, + key: K, +) -> PyResult<()> +where + T: Clone, + K: Fn(&T) -> &PyObjectRef, +{ + match pre_sort_check(items.iter().map(&key), vm) { + PreSort::Str => timsort_by(items, reverse, &key, |a, b| Ok(str_lt(a, b))), + PreSort::Int => timsort_by(items, reverse, &key, |a, b| Ok(int_lt(a, b))), + PreSort::Float => timsort_by(items, reverse, &key, |a, b| Ok(float_lt(a, b))), + PreSort::Object(cmp) => timsort_by(items, reverse, &key, |a, b| object_lt(cmp, a, b, vm)), + PreSort::Tuple(elem) => timsort_by(items, reverse, &key, |a, b| tuple_lt(&elem, a, b, vm)), + PreSort::Generic => timsort_by(items, reverse, &key, |a, b| { + a.rich_compare_bool(b, PyComparisonOp::Lt, vm) + }), + } +} + fn do_sort( vm: &VirtualMachine, values: &mut Vec, key_func: Option, reverse: bool, ) -> PyResult<()> { - // CPython uses __lt__ for all comparisons in sort. - // `timsort` expects is_lt(a, b) = true when a must be placed BEFORE b. - // For reverse=True, swapping the operands yields a descending order that is - // still stable in the original relative order, matching CPython's - // reverse-sort-reverse approach. - let mut is_lt = |a: &PyObjectRef, b: &PyObjectRef| { - if reverse { - b.rich_compare_bool(a, PyComparisonOp::Lt, vm) - } else { - a.rich_compare_bool(b, PyComparisonOp::Lt, vm) - } - }; - if let Some(ref key_func) = key_func { let mut items = values .iter() .map(|x| Ok((x.clone(), key_func.call((x.clone(),), vm)?))) .collect::, _>>()?; - timsort(&mut items, &mut |a, b| is_lt(&a.1, &b.1))?; + timsort_specialized( + vm, + &mut items, + reverse, + |item: &(PyObjectRef, PyObjectRef)| &item.1, + )?; *values = items.into_iter().map(|(val, _)| val).collect(); } else { - timsort(values, &mut is_lt)?; + timsort_specialized(vm, values, reverse, |x: &PyObjectRef| x)? } Ok(()) diff --git a/extra_tests/snippets/builtin_list.py b/extra_tests/snippets/builtin_list.py index d4afbffa1..d62cae03b 100644 --- a/extra_tests/snippets/builtin_list.py +++ b/extra_tests/snippets/builtin_list.py @@ -242,6 +242,33 @@ assert sorted([(1, 2, 3), (0, 3, 6)], key=lambda x: x[0]) == [(0, 3, 6), (1, 2, assert sorted([(1, 2, 3), (0, 3, 6)], key=lambda x: x[1]) == [(1, 2, 3), (0, 3, 6)] assert sorted([(1, 2), (), (5,)], key=len) == [(), (5,), (1, 2)] +assert sorted(["b", "a", "é", "z\U0001f600", "z"]) == [ + "a", + "b", + "z", + "z\U0001f600", + "é", +] +assert sorted([10**30, -(10**30), 5, 0]) == [-(10**30), 0, 5, 10**30] +assert sorted([True, False, True]) == [False, True, True] + + +class IntSub(int): + pass + + +assert sorted([IntSub(2), 3, IntSub(1)]) == [1, 2, 3] +assert sorted([2.5, 1, 3.0, 2]) == [1, 2, 2.5, 3.0] +assert_raises(TypeError, sorted, [1, "a"]) +nan = float("nan") +assert repr(sorted([nan, 1.0, 2.0])) == "[nan, 1.0, 2.0]" +assert sorted([b"b", b"a", b"c"]) == [b"a", b"b", b"c"] +assert sorted([(2, 9), (1, 5), (2, 1)]) == [(1, 5), (2, 1), (2, 9)] +assert sorted([(1, "b"), (1, "a")]) == [(1, "a"), (1, "b")] +assert sorted([(1,), (1, 2), ()]) == [(), (1,), (1, 2)] +assert sorted([((2,), "x"), ((1,), "y")]) == [((1,), "y"), ((2,), "x")] +assert sorted([(1, "a"), (2.5, "b"), (0, "c")]) == [(0, "c"), (1, "a"), (2.5, "b")] + lst = [3, 1, 5, 2, 4] From 25e2df1731708c855222aa7f4693ba26fb360b68 Mon Sep 17 00:00:00 2001 From: lms0806 Date: Sat, 8 Aug 2026 22:30:43 +0900 Subject: [PATCH 28/94] Implement TclObject support in tkinter varname_converter (#8465) --- crates/stdlib/src/tkinter.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/stdlib/src/tkinter.rs b/crates/stdlib/src/tkinter.rs index 0ccc0a97f..653d6edb7 100644 --- a/crates/stdlib/src/tkinter.rs +++ b/crates/stdlib/src/tkinter.rs @@ -160,10 +160,13 @@ mod _tkinter { return Ok(varname); } - if let Some(_tcl_obj) = obj.downcast_ref::() { - // Assume that the Tcl object has a method to retrieve a string. - // return tcl_obj. - todo!(); + if let Some(tcl_obj) = obj.downcast_ref::() { + let c_str = unsafe { tk_sys::Tcl_GetString(tcl_obj.value) }; + let varname = unsafe { ffi::CStr::from_ptr(c_str as _) } + .to_str() + .map_err(|e| vm.new_unicode_decode_error(e.to_string()))? + .to_owned(); + return Ok(varname); } // Construct an error message using the type name (truncated to 50 characters). From 7545bb1993ced5ab0afcf5d1d32d1ae3b44fcc0e Mon Sep 17 00:00:00 2001 From: Sumi Jeong <125195487+sigmaith@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:32:13 +0900 Subject: [PATCH 29/94] Fix locals()/vars() corrupting/leaking __conditional_annotations__ outside function scope (#8467) CPython's _PyFrame_GetLocals only syncs fastlocals/cells into locals() for function scope; module/class scope just returns the namespace dict as-is. sync_visible_locals_to_mapping did this for every scope, so the implicit __conditional_annotations__ cell (used for PEP 649/749 deferred annotations) broke two ways: at module scope its cell is always empty, so syncing overwrote the dict's real value with None, causing NameError on the next annotated statement; at class scope its cell is the only real value, so syncing leaked it into locals()/dir(), unlike CPython. Skip cell/free slots outside function scope to match CPython, and add a regression snippet. Closes #8379 Assisted-by: Claude Code:claude-sonnet-5 --- crates/vm/src/frame.rs | 7 +-- .../snippets/syntax_annotations_locals.py | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 extra_tests/snippets/syntax_annotations_locals.py diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index e7141525f..ebb158c8f 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -1794,9 +1794,10 @@ impl FrameObject { } } - // Free variables only included for optimized (function-like) scopes. - // Class/module scopes should not expose free vars in locals(). - if kind == CO_FAST_FREE && !is_optimized { + // CPython only syncs fastlocals/cells into locals() for function + // scope; class/module scope just returns the namespace dict as-is + // (_PyFrame_GetLocals, Objects/frameobject.c). + if !is_optimized && kind & (CO_FAST_CELL | CO_FAST_FREE) != 0 { continue; } diff --git a/extra_tests/snippets/syntax_annotations_locals.py b/extra_tests/snippets/syntax_annotations_locals.py new file mode 100644 index 000000000..8669d94b2 --- /dev/null +++ b/extra_tests/snippets/syntax_annotations_locals.py @@ -0,0 +1,50 @@ +"""Module/class-scope locals() must not corrupt or leak __conditional_annotations__. + +CPython's _PyFrame_GetLocals never syncs cell variables into a module/class +scope's namespace dict, it just returns the dict directly (verified against +CPython 3.14.6). __conditional_annotations__ is a cell in both scopes, but +only module codegen also writes it into the dict (StoreName); class codegen +only ever uses the cell (StoreDeref). So it's visible via locals()/dir() at +module scope and absent at class scope. + +RustPython's fast-locals-to-mapping sync used to read every cellvar's value +straight from the cell regardless of scope. At module scope the cell is +always empty, so this overwrote the dict's real value with None -- deleting +it, and the next annotated statement raised NameError. At class scope it +leaked __conditional_annotations__ into locals()/dir(), which CPython never +does. +""" + +count: int = 1 +_ = locals() +maybe: int = None # used to raise NameError before the fix +assert maybe is None + +assert "__conditional_annotations__" in dir(), ( + "module-level annotation should expose __conditional_annotations__, matching CPython" +) + +exec("a: int = 1\nlocals()\nb: int = 2") + +if True: + x: int = 1 +vars() +if True: + y: int = 2 +assert (x, y) == (1, 2) + + +class C: + if True: + cx: int = 1 + locals() + if True: + cy: int = 2 + assert "__conditional_annotations__" not in dir(), ( + "class-level locals() should not leak __conditional_annotations__, matching CPython" + ) + + +assert (C.cx, C.cy) == (1, 2) + +print("ok") From 68989a2c23828d4ee5d9bc53912bf6d92c056178 Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Sat, 8 Aug 2026 22:33:44 +0900 Subject: [PATCH 30/94] Allow automated commits and fix C-API test instructions (#8468) * Allow automated commits through pre-commit hooks * Fix agent C-API test instructions Exclude rustpython-capi from root workspace test commands and run its tests from the crate directory so its Cargo configuration applies. Document the project AI policy and required commit trailer. Assisted-by: Codex:gpt-5 --- AGENTS.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2478a8649..694fc7be6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,17 +17,22 @@ See the "Code organization" section in [CONTRIBUTING.md](CONTRIBUTING.md#code-or ## AI Agent Rules +**CRITICAL: AI Policy** + +- Follow RustPython's [AI Policy](https://github.com/RustPython/.github/blob/main/AI_POLICY.md) for every AI-assisted contribution. +- Disclose AI assistance in commit messages with an `Assisted-by: AGENT_NAME:MODEL_VERSION` trailer. Use one trailer per AI tool, and never use `Co-authored-by` for an AI assistant. + **CRITICAL: Git Operations** - NEVER create pull requests directly without explicit user permission - NEVER push commits to remote without explicit user permission - Always ask the user before performing any git operations that affect the remote repository - Commits can be created locally when requested, but pushing and PR creation require explicit approval -**CRITICAL: Pre-commit Checks** -- Before creating ANY commit, you MUST run `prek run --all-files` (or `pre-commit run --all-files`) AND the full test suite. Both must pass — do not commit if either fails. -- Test commands are documented in the [Testing](#testing) section below. At minimum run `cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher`; if the change touches `extra_tests/snippets/` run `pytest -v` there too, and if it touches `Lib/` or interpreter behavior, run the relevant `cargo run --release -- -m test ` modules. -- If a hook auto-fixes files (e.g. `ruff-format`, `rustfmt`), re-stage the fixes, re-run `prek` until it reports a clean pass, then re-run the tests, then commit. -- NEVER bypass these checks with `--no-verify`, `--no-gpg-sign`, or by skipping tests "because the change is small". If a hook or test fails, fix the underlying issue and create a new commit — do not amend or force the failing commit through. +**CRITICAL: Commit Hooks and Validation** +- Install the repository's pre-commit hook with `prek install` (or `pre-commit install`) after cloning the repository. +- Every commit must run the configured pre-commit hook. NEVER bypass it with `--no-verify`. Automated workflows that use a normal `git commit`, such as `scripts/update_lib quick`, should be allowed to create local commits through the hook. +- If a hook auto-fixes files (e.g. `ruff-format`, `rustfmt`), re-stage the fixes and retry the commit. Do not amend or force a failing commit through. +- Before completing a task, run the tests appropriate for the change. Test commands are documented in the [Testing](#testing) section below. At minimum run `cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --exclude rustpython-capi`, then run `cargo test` from `crates/capi`; if the change touches `extra_tests/snippets/` run `pytest -v` there too, and if it touches `Lib/` or interpreter behavior, run the relevant `cargo run --release -- -m test ` modules. ## Important Development Notes @@ -113,7 +118,10 @@ rm -r target/debug/build/rustpython-* && find . | grep -E "\.pyc$" | xargs rm -r ```bash # Run Rust unit tests -cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher +cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --exclude rustpython-capi + +# Run C-API tests from their directory so their separate Cargo config applies +(cd crates/capi && cargo test) # Run Python snippets tests (debug mode recommended for faster compilation) cargo run -- extra_tests/snippets/builtin_bytes.py From 15f8590f17d9f98f4a8bd4401d025901cfce2ade Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:43:05 -0400 Subject: [PATCH 31/94] host_env: Simplify stat, fstatat with Rustix (#8455) According to POSIX, `time_t` should be 64 bits. `musl` changed its `time_t` to an `i64` over five years ago. `glibc` provides compatibility features that declare `time_t` as either `i32` or `i64`. Rustix uses the raw Linux syscall for stat which returns an `i64`. For our purposes, an `i64` makes sense because it's modern and avoids the year 2038 problem. It also reduces our dependency on what `libc` defines. Sources: * https://www.man7.org/linux/man-pages/man3/time_t.3type.html * https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/sys_types.h.html --- crates/host_env/src/fileutils.rs | 12 ++----- crates/host_env/src/io.rs | 7 ++-- crates/host_env/src/posix.rs | 44 -------------------------- crates/host_env/src/posix_unix_like.rs | 23 ++++++++++++-- crates/host_env/src/posix_wasi.rs | 33 ------------------- crates/vm/src/stdlib/os.rs | 44 +++++++++++--------------- 6 files changed, 47 insertions(+), 116 deletions(-) diff --git a/crates/host_env/src/fileutils.rs b/crates/host_env/src/fileutils.rs index 1713370a9..a4922e7a2 100644 --- a/crates/host_env/src/fileutils.rs +++ b/crates/host_env/src/fileutils.rs @@ -2,22 +2,14 @@ #![allow(non_snake_case)] #[cfg(not(windows))] -pub use libc::stat as StatStruct; +pub use rustix::fs::Stat as StatStruct; #[cfg(windows)] pub use windows::{StatStruct, fstat}; #[cfg(not(windows))] pub fn fstat(fd: crate::crt_fd::Borrowed<'_>) -> std::io::Result { - let mut stat = core::mem::MaybeUninit::uninit(); - unsafe { - let ret = libc::fstat(fd.as_raw(), stat.as_mut_ptr()); - if ret == -1 { - Err(crate::os::errno_io_error()) - } else { - Ok(stat.assume_init()) - } - } + rustix::fs::fstat(fd).map_err(Into::into) } #[cfg(windows)] diff --git a/crates/host_env/src/io.rs b/crates/host_env/src/io.rs index 4ae1e4b36..6df29bcd6 100644 --- a/crates/host_env/src/io.rs +++ b/crates/host_env/src/io.rs @@ -2,6 +2,9 @@ use core::ffi::CStr; use std::io; +#[cfg(any(unix, target_os = "wasi"))] +use rustix::{fs::FileType, io::Errno}; + #[cfg(any(unix, target_os = "wasi"))] use crate::fileutils; use crate::{crt_fd, os}; @@ -148,8 +151,8 @@ pub struct FileTargetInfo { #[cfg(any(unix, target_os = "wasi"))] pub fn inspect_file_target(fd: crt_fd::Borrowed<'_>) -> io::Result { let status = fileutils::fstat(fd)?; - if (status.st_mode & libc::S_IFMT) == libc::S_IFDIR { - return Err(io::Error::from_raw_os_error(libc::EISDIR)); + if FileType::from_raw_mode(status.st_mode).is_dir() { + return Err(io::Error::from(Errno::ISDIR)); } #[allow(clippy::useless_conversion, reason = "needed for 32-bit platforms")] let blksize = (status.st_blksize > 1).then(|| i64::from(status.st_blksize)); diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index e20accee7..50d3f52a6 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -304,50 +304,6 @@ pub fn fchown(fd: BorrowedFd<'_>, uid: Option, gid: Option) -> std::io .map_err(std::io::Error::from) } -#[cfg(not(windows))] -#[expect( - clippy::std_instead_of_core, - reason = "false positive: core::io::ErrorKind is unstable (core_io)" -)] -pub fn stat_path( - path: &OsStr, - dir_fd: Option, - follow_symlinks: bool, -) -> std::io::Result> { - use crate::os::ffi::OsStrExt; - - let path = match CString::new(path.as_bytes()) { - Ok(path) => path, - Err(_) => return Err(std::io::Error::from(std::io::ErrorKind::InvalidInput)), - }; - - let mut stat = core::mem::MaybeUninit::uninit(); - #[cfg(not(target_os = "redox"))] - if let Some(dir_fd) = dir_fd { - let flags = if follow_symlinks { - 0 - } else { - libc::AT_SYMLINK_NOFOLLOW - }; - let ret = unsafe { libc::fstatat(dir_fd, path.as_ptr(), stat.as_mut_ptr(), flags) }; - if ret < 0 { - return Err(std::io::Error::last_os_error()); - } - return Ok(Some(unsafe { stat.assume_init() })); - } - - let ret = if follow_symlinks { - unsafe { libc::stat(path.as_ptr(), stat.as_mut_ptr()) } - } else { - unsafe { libc::lstat(path.as_ptr(), stat.as_mut_ptr()) } - }; - if ret < 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(Some(unsafe { stat.assume_init() })) - } -} - #[cfg(not(windows))] pub fn stat_fd(fd: crate::crt_fd::Borrowed<'_>) -> std::io::Result { crate::fileutils::fstat(fd) diff --git a/crates/host_env/src/posix_unix_like.rs b/crates/host_env/src/posix_unix_like.rs index 9bb29c41c..183feb531 100644 --- a/crates/host_env/src/posix_unix_like.rs +++ b/crates/host_env/src/posix_unix_like.rs @@ -2,11 +2,14 @@ use std::{io, path::Path}; -use rustix::{fd::AsFd, fs}; +use rustix::{ + fd::AsFd, + fs::{self, AtFlags}, +}; pub use rustix::fs::RawMode; -use crate::crt_fd; +use crate::{crt_fd, fileutils::StatStruct}; /// https://pubs.opengroup.org/onlinepubs/9799919799/functions/mkdir.html pub fn make_dir( @@ -45,3 +48,19 @@ pub fn replace( ) -> io::Result<()> { rename(from, from_fd, to, to_fd) } + +pub fn stat_path( + path: impl AsRef, + dir_fd: Option>, + follow_symlinks: bool, +) -> io::Result> { + let flags = if follow_symlinks { + AtFlags::empty() + } else { + AtFlags::SYMLINK_NOFOLLOW + }; + let dir_fd = dir_fd.as_ref().map_or(fs::CWD, AsFd::as_fd); + fs::statat(dir_fd, path.as_ref(), flags) + .map(Option::Some) + .map_err(Into::into) +} diff --git a/crates/host_env/src/posix_wasi.rs b/crates/host_env/src/posix_wasi.rs index 23c3be2fb..791991e98 100644 --- a/crates/host_env/src/posix_wasi.rs +++ b/crates/host_env/src/posix_wasi.rs @@ -12,39 +12,6 @@ pub fn remove_dir_at(dir_fd: i32, path: &CStr) -> io::Result<()> { Ok(()) } -pub fn stat_path( - path: &OsStr, - dir_fd: Option, - follow_symlinks: bool, -) -> io::Result> { - use crate::os::ffi::OsStrExt; - - let path = match CString::new(path.as_bytes()) { - Ok(path) => path, - Err(_) => return Err(io::Error::from(io::ErrorKind::InvalidInput)), - }; - - let mut stat = core::mem::MaybeUninit::uninit(); - if let Some(dir_fd) = dir_fd { - let flags = if follow_symlinks { - 0 - } else { - libc::AT_SYMLINK_NOFOLLOW - }; - unsafe { libc::fstatat(dir_fd, path.as_ptr(), stat.as_mut_ptr(), flags) } - .check_libc_neg()?; - return Ok(Some(unsafe { stat.assume_init() })); - } - - let ret = if follow_symlinks { - unsafe { libc::stat(path.as_ptr(), stat.as_mut_ptr()) } - } else { - unsafe { libc::lstat(path.as_ptr(), stat.as_mut_ptr()) } - }; - ret.check_libc_neg()?; - Ok(Some(unsafe { stat.assume_init() })) -} - pub fn stat_fd(fd: crate::crt_fd::Borrowed<'_>) -> io::Result { crate::fileutils::fstat(fd) } diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index a41e9990f..a934c6d81 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -1183,18 +1183,15 @@ pub(super) mod _os { pub st_gid: PyIntRef, pub st_size: PyIntRef, // Indices 7-9: integer seconds - #[cfg_attr(target_env = "musl", allow(deprecated))] #[pyarg(positional, default)] #[pystruct_sequence(unnamed)] - pub st_atime_int: libc::time_t, - #[cfg_attr(target_env = "musl", allow(deprecated))] + pub st_atime_int: i64, #[pyarg(positional, default)] #[pystruct_sequence(unnamed)] - pub st_mtime_int: libc::time_t, - #[cfg_attr(target_env = "musl", allow(deprecated))] + pub st_mtime_int: i64, #[pyarg(positional, default)] #[pystruct_sequence(unnamed)] - pub st_ctime_int: libc::time_t, + pub st_ctime_int: i64, // Float time attributes #[pyarg(any, default)] #[pystruct_sequence(skip)] @@ -1219,11 +1216,11 @@ pub(super) mod _os { #[cfg(not(windows))] #[pyarg(any, default)] #[pystruct_sequence(skip)] - pub st_blksize: i64, + pub st_blksize: u64, #[cfg(not(windows))] #[pyarg(any, default)] #[pystruct_sequence(skip)] - pub st_blocks: i64, + pub st_blocks: u64, #[cfg(windows)] #[pyarg(any, default)] #[pystruct_sequence(skip)] @@ -1237,19 +1234,12 @@ pub(super) mod _os { impl StatResultData { fn from_stat(stat: &StatStruct, vm: &VirtualMachine) -> Self { let (atime, mtime, ctime); - #[cfg(any(unix, windows))] - #[cfg(not(any(target_os = "netbsd", target_os = "wasi")))] + #[cfg(all(any(unix, windows), not(target_os = "wasi")))] { atime = (stat.st_atime, stat.st_atime_nsec); mtime = (stat.st_mtime, stat.st_mtime_nsec); ctime = (stat.st_ctime, stat.st_ctime_nsec); } - #[cfg(target_os = "netbsd")] - { - atime = (stat.st_atime, stat.st_atimensec); - mtime = (stat.st_mtime, stat.st_mtimensec); - ctime = (stat.st_ctime, stat.st_ctimensec); - } #[cfg(target_os = "wasi")] { atime = (stat.st_atim.tv_sec, stat.st_atim.tv_nsec); @@ -1274,12 +1264,18 @@ pub(super) mod _os { let st_ino = stat.st_ino; #[cfg(not(windows))] - #[allow(clippy::useless_conversion, reason = "needed for 32-bit platforms")] - let st_blksize = i64::from(stat.st_blksize); + #[allow( + clippy::useless_conversion, + reason = "signedness differs between platforms" + )] + let st_blksize = stat.st_blksize.try_into().unwrap_or(4096); #[cfg(not(windows))] - #[allow(clippy::useless_conversion, reason = "needed for 32-bit platforms")] - let st_blocks = i64::from(stat.st_blocks); + #[allow( + clippy::useless_conversion, + reason = "signedness differs between platforms" + )] + let st_blocks = stat.st_blocks.try_into().unwrap_or_default(); Self { st_mode: vm.ctx.new_pyref(stat.st_mode), @@ -1360,11 +1356,9 @@ pub(super) mod _os { follow_symlinks: FollowSymlinks, ) -> io::Result> { match file { - OsPathOrFd::Path(path) => host_posix::stat_path( - path.as_ref().as_os_str(), - dir_fd.raw_opt(), - follow_symlinks.0, - ), + OsPathOrFd::Path(path) => { + host_posix::stat_path(path, dir_fd.get_opt(), follow_symlinks.0) + } OsPathOrFd::Fd(fd) => host_posix::stat_fd(fd).map(Some), } } From ee94b5b72391410cd1277df4d9495ea1f475cd50 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:48:48 +0900 Subject: [PATCH 32/94] sre_engine: resume the tail match at prefix_skip, not past the whole prefix (#8473) `search_info_literal`'s len>1 arm reset the tail-match cursor by advancing one character past the matched prefix. That position equals the INFO block's prefix_skip boundary only when the prefix ends where the skip does; for a pattern like `ab(cd)`, `_get_literal_prefix` reports prefix_len=4 with prefix_skip=2, so the tail resumed two characters too far. `state.cursor` already holds `req.start + skip`, which is what `sre_lib.h` SRE(search) computes as `ptr - (prefix_len - prefix_skip - 1)`, and which the len==1 arm above already uses. Take it unconditionally. Searching "xabcdcd" for `ab(cd)` returned span (1, 7) instead of (1, 5); over a 600-case corpus stamped with CPython's own answers, 94 cases disagreed before this change and none after. The wrong resume also dropped matches outright, not only widened them. Assisted-by: Claude --- crates/sre_engine/src/engine.rs | 10 +++++----- crates/sre_engine/tests/tests.rs | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/sre_engine/src/engine.rs b/crates/sre_engine/src/engine.rs index a110a75d6..690801e0d 100644 --- a/crates/sre_engine/src/engine.rs +++ b/crates/sre_engine/src/engine.rs @@ -998,12 +998,12 @@ fn search_info_literal( return true; } + // `state.cursor` is `req.start + skip`, the position the + // tail match resumes from; advancing past the prefix + // instead would resume at `req.start + len` and only + // agree when the prefix ends at the skip boundary. let mut next_ctx = ctx; - if skip != 0 { - next_ctx.advance_char::(); - } else { - next_ctx.cursor = state.cursor; - } + next_ctx.cursor = state.cursor; if _match(req, state, next_ctx) { return true; diff --git a/crates/sre_engine/tests/tests.rs b/crates/sre_engine/tests/tests.rs index 53f5225d4..0a9f45c37 100644 --- a/crates/sre_engine/tests/tests.rs +++ b/crates/sre_engine/tests/tests.rs @@ -252,4 +252,19 @@ mod tests { #[rustfmt::skip] let p = Pattern { pattern: "\u{e0}+", code: &[14, 4, 0, 1, 4294967295, 24, 6, 1, 4294967295, 16, 224, 1, 1] }; // END GENERATED } + + #[test] + fn search_literal_prefix_longer_than_skip() { + // The INFO block carries prefix_len=4 and prefix_skip=2, so the tail + // match has to resume at the skip boundary rather than past the whole + // prefix. + // pattern p = re.compile('ab(cd)') + // START GENERATED by generate_tests.py + #[rustfmt::skip] let p = Pattern { pattern: "ab(cd)", code: &[14, 14, 1, 4, 4, 4, 2, 97, 98, 99, 100, 0, 0, 0, 0, 16, 97, 16, 98, 17, 0, 16, 99, 16, 100, 17, 1, 1] }; + // END GENERATED + let (req, mut state) = p.state("xabcdcd"); + assert!(state.search(req)); + assert_eq!(state.start, 1); + assert_eq!(state.cursor.position, 5); + } } From 33d98d122ee8a31ecefc9877498afa5b724aaefa Mon Sep 17 00:00:00 2001 From: Seonghun An <53287605+shAn-kor@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:58:07 +0900 Subject: [PATCH 33/94] Implement negative zero coercion format option (#8475) Assisted-by: Codex:gpt-5 --- Lib/test/test_format.py | 1 - crates/common/src/format.rs | 186 ++++++++++++++++++++++++++++++++---- crates/vm/src/format.rs | 6 ++ 3 files changed, 173 insertions(+), 20 deletions(-) diff --git a/Lib/test/test_format.py b/Lib/test/test_format.py index f6452341e..aa2810831 100644 --- a/Lib/test/test_format.py +++ b/Lib/test/test_format.py @@ -558,7 +558,6 @@ class FormatTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, str_err): "{a:%ЫйЯЧ}".format(a='a') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_negative_zero(self): ## default behavior self.assertEqual(f"{-0.:.1f}", "-0.0") diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index b5f062f97..1c5c0a9c9 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -229,6 +229,7 @@ pub struct FormatSpec { align: Option, align_specified: bool, sign: Option, + no_neg_0: bool, alternate_form: bool, width: Option, grouping_option: Option, @@ -285,6 +286,14 @@ fn parse_alternate_form(text: &Wtf8) -> (bool, &Wtf8) { } } +fn parse_no_negative_zero(text: &Wtf8) -> (bool, &Wtf8) { + let mut chars = text.code_points(); + match chars.next().and_then(CodePoint::to_char) { + Some('z') => (true, chars.as_wtf8()), + _ => (false, text), + } +} + fn parse_zero(text: &Wtf8) -> (bool, &Wtf8) { let mut chars = text.code_points(); match chars.next().and_then(CodePoint::to_char) { @@ -349,6 +358,7 @@ impl FormatSpec { let (mut fill, mut align, text) = parse_fill_and_align(text); let align_specified = align.is_some(); let (sign, text) = FormatSign::parse(text); + let (no_neg_0, text) = parse_no_negative_zero(text); let (alternate_form, text) = parse_alternate_form(text); let (zero, text) = parse_zero(text); let (width, text) = parse_number(text)?; @@ -378,6 +388,7 @@ impl FormatSpec { align, align_specified, sign, + no_neg_0, alternate_form, width, grouping_option, @@ -505,6 +516,25 @@ impl FormatSpec { Ok(()) } + fn formatted_magnitude_is_zero(magnitude: &str) -> bool { + let mut saw_digit = false; + for byte in magnitude.bytes() { + if byte.is_ascii_digit() { + saw_digit = true; + if byte != b'0' { + return false; + } + } + } + saw_digit + } + + fn is_negative_after_zero_coercion(&self, num: f64, magnitude: &str) -> bool { + num.is_sign_negative() + && !num.is_nan() + && !(self.no_neg_0 && Self::formatted_magnitude_is_zero(magnitude)) + } + fn validate_complex_padding_and_alignment(&self) -> Result<(), FormatSpecError> { match &self.fill.unwrap_or_else(|| ' '.into()).to_char() { Some('0') => Err(FormatSpecError::ZeroPadding), @@ -674,6 +704,9 @@ impl FormatSpec { Some(FormatType::Number(Case::Lower)) => self.format_int_radix(magnitude, 10), _ => return self.format_int(num), }?; + if self.no_neg_0 { + return Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")); + } let magnitude_str = Self::apply_locale_formatting(raw_magnitude_str, locale); @@ -723,7 +756,7 @@ impl FormatSpec { let magnitude_str = Self::apply_locale_formatting(raw_magnitude_str, locale); let format_sign = self.sign.unwrap_or(FormatSign::Minus); - let sign_str = if num.is_sign_negative() && !num.is_nan() { + let sign_str = if self.is_negative_after_zero_coercion(num, &magnitude_str) { "-" } else { match format_sign { @@ -812,11 +845,16 @@ impl FormatSpec { self.format_float(x as f64) } None => { + if self.no_neg_0 { + return Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")); + } let first_letter = (input.to_string().as_bytes()[0] as char).to_uppercase(); Ok(first_letter.collect::() + &input.to_string()[1..]) } - Some(FormatType::Unknown(c)) => Err(FormatSpecError::UnknownFormatCode(*c, "int")), - _ => Err(FormatSpecError::InvalidFormatSpecifier), + Some(format_type) => { + let ch = char::from(format_type); + Err(FormatSpecError::UnknownFormatCode(ch, "bool")) + } } } @@ -925,8 +963,9 @@ impl FormatSpec { }, }, }; + let raw_magnitude_str = raw_magnitude_str?; let format_sign = self.sign.unwrap_or(FormatSign::Minus); - let sign_str = if num.is_sign_negative() && !num.is_nan() { + let sign_str = if self.is_negative_after_zero_coercion(num, &raw_magnitude_str) { "-" } else { match format_sign { @@ -935,7 +974,7 @@ impl FormatSpec { FormatSign::MinusOrSpace => " ", } }; - let magnitude_str = self.add_magnitude_separators(raw_magnitude_str?, sign_str); + let magnitude_str = self.add_magnitude_separators(raw_magnitude_str, sign_str); let magnitude_str = self.add_frac_separators(magnitude_str); Ok( self.format_sign_and_align( @@ -986,15 +1025,24 @@ impl FormatSpec { Err(FormatSpecError::UnknownFormatCode('N', "int")) } Some(FormatType::String) => Err(FormatSpecError::UnknownFormatCode('s', "int")), - Some(FormatType::Character) => match (self.precision, self.sign, self.alternate_form) { - (Some(_), _, _) => Err(FormatSpecError::PrecisionNotAllowed), - (_, Some(_), _) => Err(FormatSpecError::NotAllowed("Sign")), - (_, _, true) => Err(FormatSpecError::NotAllowed("Alternate form (#)")), - (_, _, _) => match num.to_u32() { - Some(n) if n <= 0x10ffff => Ok(core::char::from_u32(n).unwrap().to_string()), - Some(_) | None => Err(FormatSpecError::CodeNotInRange), - }, - }, + Some(FormatType::Character) => { + if self.precision.is_some() { + Err(FormatSpecError::PrecisionNotAllowed) + } else if self.no_neg_0 { + Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")) + } else { + match (self.sign, self.alternate_form) { + (Some(_), _) => Err(FormatSpecError::NotAllowed("Sign")), + (_, true) => Err(FormatSpecError::NotAllowed("Alternate form (#)")), + _ => match num.to_u32() { + Some(n) if n <= 0x10ffff => { + Ok(core::char::from_u32(n).unwrap().to_string()) + } + Some(_) | None => Err(FormatSpecError::CodeNotInRange), + }, + } + } + } Some( FormatType::GeneralFormat(_) | FormatType::FixedPoint(_) @@ -1007,6 +1055,9 @@ impl FormatSpec { Some(FormatType::Unknown(c)) => Err(FormatSpecError::UnknownFormatCode(c, "int")), None => self.format_int_radix(magnitude, 10), }?; + if self.no_neg_0 { + return Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")); + } let format_sign = self.sign.unwrap_or(FormatSign::Minus); let sign_str = match num.sign() { Sign::Minus => "-", @@ -1032,6 +1083,9 @@ impl FormatSpec { self.validate_format(FormatType::String)?; match self.format_type { Some(FormatType::String) | None => { + if self.no_neg_0 { + return Err(FormatSpecError::NegativeZeroCoercionNotAllowed("string")); + } if self.align == Some(FormatAlign::AfterSign) && self.align_specified { return Err(FormatSpecError::StringAlignmentFlag); } @@ -1074,7 +1128,8 @@ impl FormatSpec { // Format real part let formatted_re = if num.re != 0.0 || num.re.is_negative_zero() || self.format_type.is_some() { - let sign_re = if num.re.is_sign_negative() && !num.is_nan() { + let re = self.format_complex_float(num.re)?; + let sign_re = if self.is_negative_after_zero_coercion(num.re, &re) { "-" } else { match self.sign.unwrap_or(FormatSign::Minus) { @@ -1083,21 +1138,24 @@ impl FormatSpec { FormatSign::MinusOrSpace => " ", } }; - let re = self.format_complex_float(num.re)?; format!("{sign_re}{re}") } else { String::new() }; // Format imaginary part - let sign_im = if num.im.is_sign_negative() && !num.im.is_nan() { + let im = self.format_complex_float(num.im)?; + let sign_im = if self.is_negative_after_zero_coercion(num.im, &im) { "-" } else if formatted_re.is_empty() { - "" + match self.sign.unwrap_or(FormatSign::Minus) { + FormatSign::Plus => "+", + FormatSign::Minus => "", + FormatSign::MinusOrSpace => " ", + } } else { "+" }; - let im = self.format_complex_float(num.im)?; Ok((formatted_re, format!("{sign_im}{im}j"))) } @@ -1274,6 +1332,7 @@ pub enum FormatSpecError { CodeNotInRange, ZeroPadding, AlignmentFlag, + NegativeZeroCoercionNotAllowed(&'static str), StringAlignmentFlag, NotImplemented(char, &'static str), } @@ -1625,6 +1684,7 @@ mod tests { align: None, align_specified: false, sign: None, + no_neg_0: false, alternate_form: false, width: Some(33), grouping_option: None, @@ -1643,6 +1703,7 @@ mod tests { align: Some(FormatAlign::Right), align_specified: true, sign: None, + no_neg_0: false, alternate_form: false, width: Some(33), grouping_option: None, @@ -1661,6 +1722,7 @@ mod tests { align: Some(FormatAlign::Right), align_specified: true, sign: Some(FormatSign::Minus), + no_neg_0: false, alternate_form: true, width: Some(23), grouping_option: Some(FormatGrouping::Comma), @@ -1779,6 +1841,92 @@ mod tests { ); } + #[test] + fn format_negative_zero_coercion() { + let int_spec = FormatSpec::parse("z8").unwrap(); + assert_eq!( + int_spec.format_int(&BigInt::from(-42)), + Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")) + ); + assert_eq!( + FormatSpec::parse("zs") + .unwrap() + .format_int(&BigInt::from(0)), + Err(FormatSpecError::UnknownFormatCode('s', "int")) + ); + assert_eq!( + FormatSpec::parse("z.1d") + .unwrap() + .format_int(&BigInt::from(0)), + Err(FormatSpecError::PrecisionNotAllowed) + ); + assert_eq!( + FormatSpec::parse("+zc") + .unwrap() + .format_int(&BigInt::from(0)), + Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")) + ); + + let float_spec = FormatSpec::parse("z.2f").unwrap(); + assert_eq!(float_spec.format_float(-0.0001), Ok("0.00".to_owned())); + + let complex_spec = FormatSpec::parse("z").unwrap(); + assert_eq!( + complex_spec.format_complex(&Complex64::new(-0.0, -0.0)), + Ok("(0+0j)".to_owned()) + ); + let pure_imaginary = Complex64::new(0.0, -0.0); + assert_eq!( + FormatSpec::parse("+z") + .unwrap() + .format_complex(&pure_imaginary), + Ok("+0j".to_owned()) + ); + assert_eq!( + FormatSpec::parse(" z") + .unwrap() + .format_complex(&pure_imaginary), + Ok(" 0j".to_owned()) + ); + + let string_value = "value".to_owned(); + assert_eq!( + FormatSpec::parse("z").unwrap().format_string(&string_value), + Err(FormatSpecError::NegativeZeroCoercionNotAllowed("string")) + ); + assert_eq!( + FormatSpec::parse("zd") + .unwrap() + .format_string(&string_value), + Err(FormatSpecError::UnknownFormatCode('d', "str")) + ); + assert_eq!( + FormatSpec::parse("zs").unwrap().format_bool(false), + Err(FormatSpecError::UnknownFormatCode('s', "bool")) + ); + + let locale = LocaleInfo { + thousands_sep: ",".to_owned(), + decimal_point: ".".to_owned(), + grouping: vec![3, 0], + }; + let locale_spec = FormatSpec::parse("zn").unwrap(); + assert_eq!( + locale_spec.format_float_locale(-0.0, &locale), + Ok("0".to_owned()) + ); + assert_eq!( + locale_spec.format_complex_locale(&Complex64::new(-0.0, -0.0), &locale), + Ok("0+0j".to_owned()) + ); + assert_eq!( + FormatSpec::parse("z.1n") + .unwrap() + .format_int_locale(&BigInt::from(0), &locale), + Err(FormatSpecError::PrecisionNotAllowed) + ); + } + #[test] fn format_int() { assert_eq!( diff --git a/crates/vm/src/format.rs b/crates/vm/src/format.rs index 80b906505..657601e14 100644 --- a/crates/vm/src/format.rs +++ b/crates/vm/src/format.rs @@ -77,6 +77,12 @@ impl IntoPyException for FormatSpecError { Self::AlignmentFlag => { vm.new_value_error("'=' alignment flag is not allowed in complex format specifier") } + Self::NegativeZeroCoercionNotAllowed(type_name) => { + let msg = format!( + "Negative zero coercion (z) not allowed in {type_name} format specifier" + ); + vm.new_value_error(msg) + } Self::StringAlignmentFlag => { vm.new_value_error("'=' alignment not allowed in string format specifier") } From d6161ac321357c4c2aa0604ebfda267cf4814bae Mon Sep 17 00:00:00 2001 From: chestnut1717 <62554639+chestnut1717@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:58:53 +0900 Subject: [PATCH 34/94] Fix EOF SyntaxError diagnostics (#8429) * Fix EOF SyntaxError diagnostics * Fix Scope EOF SyntaxError location conversion * Fix Additional Unexpected Test * Docs Add Annotation source_location_in_code_points() --- Lib/test/test_eof.py | 5 --- Lib/test/test_exceptions.py | 1 - Lib/test/test_tokenize.py | 1 - crates/compiler/src/lib.rs | 59 ++++++++++++++++++++++++++++++---- crates/vm/src/exceptions.rs | 6 +++- crates/vm/src/stdlib/sys.rs | 19 +++++++++++ crates/vm/src/vm/python_run.rs | 4 +++ crates/vm/src/vm/vm_new.rs | 26 +++++++++++---- 8 files changed, 100 insertions(+), 21 deletions(-) diff --git a/Lib/test/test_eof.py b/Lib/test/test_eof.py index f5a0bc569..582e5b6de 100644 --- a/Lib/test/test_eof.py +++ b/Lib/test/test_eof.py @@ -18,7 +18,6 @@ class EOFTestCase(unittest.TestCase): self.assertEqual(str(cm.exception), expect) self.assertEqual(cm.exception.offset, 1) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_EOFS(self): expect = ("unterminated triple-quoted string literal (detected at line 3) (, line 1)") with self.assertRaises(SyntaxError) as cm: @@ -45,7 +44,6 @@ class EOFTestCase(unittest.TestCase): self.assertEqual(cm.exception.text, "ä = '''thîs is ") self.assertEqual(cm.exception.offset, 5) - @unittest.expectedFailure # TODO: RUSTPYTHON @force_not_colorized def test_EOFS_with_file(self): expect = ("(, line 1)") @@ -86,7 +84,6 @@ class EOFTestCase(unittest.TestCase): ' ^', 'SyntaxError: unterminated triple-quoted string literal (detected at line 4)']) - @unittest.expectedFailure # TODO: RUSTPYTHON @warnings_helper.ignore_warnings(category=SyntaxWarning) def test_eof_with_line_continuation(self): expect = "unexpected EOF while parsing (, line 1)" @@ -94,7 +91,6 @@ class EOFTestCase(unittest.TestCase): compile('"\\Xhh" \\', '', 'exec') self.assertEqual(str(cm.exception), expect) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_line_continuation_EOF(self): """A continuation at the end of input must be an error; bpo2180.""" expect = 'unexpected EOF while parsing (, line 1)' @@ -127,7 +123,6 @@ class EOFTestCase(unittest.TestCase): exec('\\') self.assertEqual(str(cm.exception), expect) - @unittest.expectedFailure # TODO: RUSTPYTHON @unittest.skipIf(not sys.executable, "sys.executable required") @force_not_colorized def test_line_continuation_EOF_from_file_bpo2180(self): diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 7ab4c810a..7c81c4b39 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -2145,7 +2145,6 @@ class AssertionErrorTests(unittest.TestCase): def tearDown(self): unlink(TESTFN) - @unittest.expectedFailure # TODO: RUSTPYTHON @force_not_colorized def test_assertion_error_location(self): cases = [ diff --git a/Lib/test/test_tokenize.py b/Lib/test/test_tokenize.py index 5ed844c34..0e81c6f6d 100644 --- a/Lib/test/test_tokenize.py +++ b/Lib/test/test_tokenize.py @@ -1922,7 +1922,6 @@ class TestTokenize(TestCase): tokens = list(tokenize.tokenize(BytesIO(source.encode('utf-8')).readline)) self.assertEqual(tokens, expected_tokens) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: b'SyntaxError' not found in b'OSError: stream did not contain valid UTF-8\n' def test_invalid_character_in_fstring_middle(self): # See gh-103824 script = b'''F""" diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index 43f7174d8..7562e8939 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -50,9 +50,13 @@ pub enum CompileError { impl CompileError { #[must_use] - pub fn from_ruff_parse_error(error: parser::ParseError, source_file: &SourceFile) -> Self { + pub fn from_ruff_parse_error( + error: parser::ParseError, + source_file: &SourceFile, + mode: Mode, + ) -> Self { let raw_location = error.location; - let diagnostic = match cpython_parse_diagnostic_override(&error, source_file) { + let diagnostic = match cpython_parse_diagnostic_override(&error, source_file, mode) { Some(diagnostic) => diagnostic, None => default_parse_diagnostic(error, source_file), }; @@ -129,6 +133,13 @@ fn source_location(source_file: &SourceFile, offset: TextSize) -> SourceLocation .source_location(offset, PositionEncoding::Utf8) } +// Call only with UTF-8 character boundaries for Python-facing offsets. +fn source_location_in_code_points(source_file: &SourceFile, offset: TextSize) -> SourceLocation { + source_file + .to_source_code() + .source_location(offset, PositionEncoding::Utf32) +} + fn source_locations( source_file: &SourceFile, start: TextSize, @@ -175,6 +186,21 @@ impl NormalizedParseDiagnostic { ) } + fn other_in_code_points( + source_file: &SourceFile, + message: String, + start: usize, + end: usize, + ) -> Self { + let start = TextSize::new(start as u32); + let end = TextSize::new(end as u32); + Self::new( + parser::ParseErrorType::OtherError(message), + source_location_in_code_points(source_file, start), + source_location_in_code_points(source_file, end), + ) + } + const fn with_unclosed_bracket(mut self, is_unclosed_bracket: bool) -> Self { self.is_unclosed_bracket = is_unclosed_bracket; self @@ -184,6 +210,7 @@ impl NormalizedParseDiagnostic { fn cpython_parse_diagnostic_override( error: &parser::ParseError, source_file: &SourceFile, + mode: Mode, ) -> Option { let source_text = source_file.source_text(); @@ -223,6 +250,18 @@ fn cpython_parse_diagnostic_override( &error.error, parser::ParseErrorType::Lexical(parser::LexicalErrorType::LineContinuationError) ) { + // Only a backslash at the end of the source is an EOF error. + let terminal_backslash = source_text.len().checked_sub(1); + if !matches!(mode, Mode::Eval) + && terminal_backslash == Some(error.location.start().to_usize()) + { + let loc = source_line_end_location(source_file, error.location.start()); + return Some(NormalizedParseDiagnostic::new( + parser::ParseErrorType::OtherError("unexpected EOF while parsing".to_owned()), + loc, + loc, + )); + } let loc = source_location(source_file, error.location.start() + TextSize::from(1)); return Some(NormalizedParseDiagnostic::new( error.error.clone(), @@ -231,7 +270,15 @@ fn cpython_parse_diagnostic_override( )); } - source_error!(unterminated_string_error(source_text)); + if let Some((message, start, end)) = unterminated_string_error(source_text) { + // The scanner reports quote positions, which are UTF-8 character boundaries. + return Some(NormalizedParseDiagnostic::other_in_code_points( + source_file, + message, + start, + end, + )); + } source_error!(expected_indented_block_error(error, source_text)); if matches!( @@ -5176,7 +5223,7 @@ fn _compile_with_syntax_warning_handler<'a>( }; let parser_options = parser::ParseOptions::from(parser_mode); let parsed = parser::parse(source_file.source_text(), parser_options) - .map_err(|err| CompileError::from_ruff_parse_error(err, &source_file))?; + .map_err(|err| CompileError::from_ruff_parse_error(err, &source_file, mode))?; if opts.dont_imply_dedent && matches!(mode, Mode::Single) && let Some(error) = dont_imply_dedent_source_error(&source_file) @@ -5235,7 +5282,7 @@ pub fn _compile_symtable( let res = match mode { Mode::Exec | Mode::Single | Mode::BlockExpr => { let ast = ruff_python_parser::parse_module(source_file.source_text()) - .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file))?; + .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file, mode))?; if let Some(error) = post_parse_source_error(&source_file, ast.tokens(), &CompileOpts::default()) { @@ -5254,7 +5301,7 @@ pub fn _compile_symtable( source_file.source_text(), parser::Mode::Expression.into(), ) - .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file))?; + .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file, mode))?; if let Some(error) = post_parse_source_error(&source_file, ast.tokens(), &CompileOpts::default()) { diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index 4facffee1..ffe5a6f0a 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -245,7 +245,11 @@ impl VirtualMachine { _ => true, }; - if same_line { + // A lone continuation at EOF has no highlighted source span. + let lone_line_continuation = + maybe_end_offset == Some(-1) && l_text.to_string_lossy() == "\\"; + + if same_line && !lone_line_continuation { let mut end_offset = match maybe_end_offset { Some(0) | None => offset, Some(end_offset) => end_offset, diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 8f264d7a7..71aeccf34 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -818,8 +818,27 @@ pub mod sys { vm: &VirtualMachine, ) -> PyResult<()> { let stderr = super::get_stderr(vm)?; + // Keep runtime SyntaxErrors on the normal traceback path. + let has_traceback = !vm.is_none(&exc_tb); match vm.normalize_exception(exc_type, exc_val.clone(), exc_tb) { Ok(exc) => { + let native_syntax_error_display = !has_traceback + && exc.fast_isinstance(vm.ctx.exceptions.syntax_error) + && exc + .as_object() + .get_attr("msg", vm) + .ok() + .and_then(|msg| msg.downcast::().ok()) + .is_some_and(|msg| msg.to_string_lossy() == "unexpected EOF while parsing") + && exc + .as_object() + .get_attr("text", vm) + .ok() + .and_then(|text| text.downcast::().ok()) + .is_some_and(|text| text.to_string_lossy().trim_end() == "\\"); + if native_syntax_error_display { + return vm.write_exception(&mut crate::py_io::PyWriter(stderr, vm), &exc); + } // PyErr_Display: try traceback._print_exception_bltin first if let Ok(tb_mod) = vm.import("traceback", 0) && let Ok(print_exc_builtin) = tb_mod.get_attr("_print_exception_bltin", vm) diff --git a/crates/vm/src/vm/python_run.rs b/crates/vm/src/vm/python_run.rs index 91d5885e7..a1c2552ce 100644 --- a/crates/vm/src/vm/python_run.rs +++ b/crates/vm/src/vm/python_run.rs @@ -113,6 +113,10 @@ mod file_run { "source code cannot contain null bytes".into(), )); } + #[cfg(feature = "parser")] + // Match compile() by honoring BOMs and encoding cookies in files. + let source = self.decode_source_bytes(&source_bytes, path, false)?; + #[cfg(not(feature = "parser"))] let source = String::from_utf8(source_bytes) .map_err(|err| self.new_os_error(err.to_string()))?; let code_obj = self diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index 48909c1a4..4df3c6391 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -752,7 +752,7 @@ impl VirtualMachine { Some(line + "\n") } - let statement = source.and_then(|src| get_statement(src, error.location())); + let mut statement = source.and_then(|src| get_statement(src, error.location())); let mut msg = error.to_string(); if !msg.starts_with("Exceeds the limit ") @@ -799,6 +799,16 @@ impl VirtualMachine { } let SyntaxErrorInfo { msg, narrow_caret } = syntax_error_info; + let unterminated_triple_quoted_string = + msg.starts_with("unterminated triple-quoted string literal"); + let unexpected_eof_error = msg == "unexpected EOF while parsing"; + if unterminated_triple_quoted_string + && let Some(statement) = statement.as_mut() + && statement.ends_with('\n') + { + // CPython omits the parser-added final newline from SyntaxError.text. + statement.pop(); + } let check_version_suite_error = msg.starts_with("Async functions are") || msg.starts_with("Async for loops are") || msg.starts_with("Async with statements are") @@ -820,12 +830,14 @@ impl VirtualMachine { // Set end_lineno and end_offset if available if let Some((end_lineno, end_offset)) = error.python_end_location() { - let (end_lineno, end_offset) = if check_version_suite_error - && statement - .as_deref() - .and_then(|line| line.chars().next()) - .is_some_and(|ch| ch.is_ascii_whitespace()) - { + // EOF errors have no source span in CPython. + let no_end_offset = unexpected_eof_error + || (check_version_suite_error + && statement + .as_deref() + .and_then(|line| line.chars().next()) + .is_some_and(|ch| ch.is_ascii_whitespace())); + let (end_lineno, end_offset) = if no_end_offset { (end_lineno, -1) } else if line_end_binary_operator_error && end_offset == offset_raw { (end_lineno, (end_offset + 1) as isize) From ecfc5cd578191b0bb44cc54ef6f4fbd255477720 Mon Sep 17 00:00:00 2001 From: MsfPablo <129399053+MsfPablo@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:06:48 +0200 Subject: [PATCH 35/94] Fix panic in code.replace() with non-interned strings (#8471) code.replace() called as_interned_str().unwrap() on its string arguments, which panics when the caller passes a string that has not been interned. modulefinder's replace_paths_in_code() builds a fresh co_filename, so any use of ModuleFinder(replace_paths=...) aborted the interpreter. Intern the incoming strings instead, and raise TypeError rather than panicking when a non-string appears in co_names/co_varnames/ co_cellvars/co_freevars. Unskips test_modulefinder.test_replace_paths. Co-authored-by: Pablo Garcia --- Lib/test/test_modulefinder.py | 2 -- crates/vm/src/builtins/code.rs | 38 +++++++++++++++++----------------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/Lib/test/test_modulefinder.py b/Lib/test/test_modulefinder.py index 51f7fd257..b64e684f8 100644 --- a/Lib/test/test_modulefinder.py +++ b/Lib/test/test_modulefinder.py @@ -390,8 +390,6 @@ class ModuleFinderTest(unittest.TestCase): os.remove(source_path) self._do_test(bytecode_test) - # TODO: RUSTPYTHON; panics at code.rs with 'called Option::unwrap() on a None value' - @unittest.skip("TODO: RUSTPYTHON; panics in co_filename replacement") def test_replace_paths(self): old_path = os.path.join(self.test_dir, 'a', 'module.py') new_path = os.path.join(self.test_dir, 'a', 'spam.py') diff --git a/crates/vm/src/builtins/code.rs b/crates/vm/src/builtins/code.rs index b1132a55a..b25c9d5c4 100644 --- a/crates/vm/src/builtins/code.rs +++ b/crates/vm/src/builtins/code.rs @@ -1366,19 +1366,25 @@ impl PyCode { OptionalArg::Missing => self.code.instructions.clone(), }; + let intern_all = |objs: Vec, field: &str| -> PyResult> { + objs.into_iter() + .map(|o| { + let s = o.downcast_ref::().ok_or_else(|| { + vm.new_type_error(format!("{field} must be a tuple of strings")) + })?; + Ok(vm.ctx.intern_str(s.as_wtf8())) + }) + .collect::>>() + .map(Vec::into_boxed_slice) + }; + let cellvars = match co_cellvars { - OptionalArg::Present(cellvars) => cellvars - .into_iter() - .map(|o| o.as_interned_str(vm).unwrap()) - .collect(), + OptionalArg::Present(cellvars) => intern_all(cellvars, "co_cellvars")?, OptionalArg::Missing => self.code.cellvars.clone(), }; let freevars = match co_freevars { - OptionalArg::Present(freevars) => freevars - .into_iter() - .map(|o| o.as_interned_str(vm).unwrap()) - .collect(), + OptionalArg::Present(freevars) => intern_all(freevars, "co_freevars")?, OptionalArg::Missing => self.code.freevars.clone(), }; @@ -1411,10 +1417,10 @@ impl PyCode { posonlyarg_count, arg_count, kwonlyarg_count, - source_path: source_path.as_object().as_interned_str(vm).unwrap(), + source_path: vm.ctx.intern_str(source_path.as_wtf8()), first_line_number, - obj_name: obj_name.as_object().as_interned_str(vm).unwrap(), - qualname: qualname.as_object().as_interned_str(vm).unwrap(), + obj_name: vm.ctx.intern_str(obj_name.as_wtf8()), + qualname: vm.ctx.intern_str(qualname.as_wtf8()), max_stackdepth, instructions, @@ -1422,14 +1428,8 @@ impl PyCode { // It can be removed once we move every other code to use linetable only. locations: self.code.locations.clone(), constants: constants.into_iter().map(Literal).collect(), - names: names - .into_iter() - .map(|o| o.as_interned_str(vm).unwrap()) - .collect(), - varnames: varnames - .into_iter() - .map(|o| o.as_interned_str(vm).unwrap()) - .collect(), + names: intern_all(names, "co_names")?, + varnames: intern_all(varnames, "co_varnames")?, cellvars, freevars, localspluskinds: self.code.localspluskinds.clone(), From f89dc115d51746be3b6cb356969910fbe3b37e23 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:08:01 -0400 Subject: [PATCH 36/94] Use C string literals and w! instead of allocs (#8474) Rust supports C string literals which automatically create a CStr with a trailing NUL. `windows-sys` provides an analogous macro for wide strings. Both of these avoid allocations which is nice for constants. --- crates/host_env/src/fileutils.rs | 18 ++++++------- crates/host_env/src/nt.rs | 37 ++++++++++++++------------- crates/host_env/src/winapi.rs | 5 ++-- crates/host_env/src/windows.rs | 43 +++++++++++++++++--------------- crates/host_env/src/wmi.rs | 24 +++++++++--------- 5 files changed, 65 insertions(+), 62 deletions(-) diff --git a/crates/host_env/src/fileutils.rs b/crates/host_env/src/fileutils.rs index a4922e7a2..a8e56bb1c 100644 --- a/crates/host_env/src/fileutils.rs +++ b/crates/host_env/src/fileutils.rs @@ -1,6 +1,8 @@ // Python/fileutils.c in CPython #![allow(non_snake_case)] +use alloc::ffi::CString; + #[cfg(not(windows))] pub use rustix::fs::Stat as StatStruct; @@ -16,9 +18,8 @@ pub fn fstat(fd: crate::crt_fd::Borrowed<'_>) -> std::io::Result { pub mod windows { use crate::crt_fd; use crate::windows::ToWideString; - use alloc::ffi::CString; use libc::{S_IFCHR, S_IFDIR, S_IFMT}; - use std::ffi::{OsStr, OsString}; + use std::ffi::OsStr; use std::os::windows::io::AsRawHandle; use std::sync::OnceLock; use windows_sys::Win32::Foundation::{ @@ -33,6 +34,7 @@ pub mod windows { use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; use windows_sys::Win32::System::SystemServices::IO_REPARSE_TAG_SYMLINK; use windows_sys::core::PCWSTR; + use windows_sys::w; pub const S_IFIFO: libc::c_int = 0o010000; pub const S_IFLNK: libc::c_int = 0o120000; @@ -302,16 +304,13 @@ pub mod windows { let GetFileInformationByName = GET_FILE_INFORMATION_BY_NAME .get_or_init(|| { - let library_name = - OsString::from("api-ms-win-core-file-l2-1-4.dll").to_wide_with_nul(); - let module = unsafe { LoadLibraryW(library_name.as_ptr()) }; + let library_name = w!("api-ms-win-core-file-l2-1-4.dll"); + let module = unsafe { LoadLibraryW(library_name) }; if module.is_null() { return None; } - let name = CString::new("GetFileInformationByName").unwrap(); - if let Some(proc) = - unsafe { GetProcAddress(module, name.as_bytes_with_nul().as_ptr()) } - { + let name = c"GetFileInformationByName"; + if let Some(proc) = unsafe { GetProcAddress(module, name.as_ptr().cast()) } { Some(unsafe { core::mem::transmute::< unsafe extern "system" fn() -> isize, @@ -458,7 +457,6 @@ pub unsafe fn fclose(fp: *mut CFile) -> core::ffi::c_int { reason = "false positive: core::io::ErrorKind is unstable (core_io)" )] pub fn fopen(path: &std::path::Path, mode: &str) -> std::io::Result<*mut CFile> { - use alloc::ffi::CString; use std::fs::File; // Currently only supports read mode diff --git a/crates/host_env/src/nt.rs b/crates/host_env/src/nt.rs index 780a75910..7e0591600 100644 --- a/crates/host_env/src/nt.rs +++ b/crates/host_env/src/nt.rs @@ -22,19 +22,22 @@ use crate::{ windows::{CheckWin32Bool, CheckWin32Handle, CheckWin32Sentinel, HandleToOwned, ToWideString}, }; use libc::intptr_t; -use windows_sys::Win32::{ - Foundation::{ - CloseHandle, ERROR_INVALID_HANDLE, GetLastError, HANDLE, INVALID_HANDLE_VALUE, MAX_PATH, +use windows_sys::{ + Win32::{ + Foundation::{ + CloseHandle, ERROR_INVALID_HANDLE, GetLastError, HANDLE, INVALID_HANDLE_VALUE, MAX_PATH, + }, + Globalization::{CP_UTF8, MultiByteToWideChar, WideCharToMultiByte}, + Storage::FileSystem::{ + CreateFileW, FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_READ_ATTRIBUTES, FILE_TYPE_UNKNOWN, FileBasicInfo, FindClose, FindFirstFileW, + GetFileAttributesW, GetFileInformationByHandleEx, GetFileType, GetFullPathNameW, + INVALID_FILE_ATTRIBUTES, OPEN_EXISTING, SetFileAttributesW, SetFileInformationByHandle, + WIN32_FIND_DATAW, + }, + System::{Console, Threading}, }, - Globalization::{CP_UTF8, MultiByteToWideChar, WideCharToMultiByte}, - Storage::FileSystem::{ - CreateFileW, FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, - FILE_READ_ATTRIBUTES, FILE_TYPE_UNKNOWN, FileBasicInfo, FindClose, FindFirstFileW, - GetFileAttributesW, GetFileInformationByHandleEx, GetFileType, GetFullPathNameW, - INVALID_FILE_ATTRIBUTES, OPEN_EXISTING, SetFileAttributesW, SetFileInformationByHandle, - WIN32_FIND_DATAW, - }, - System::{Console, Threading}, + w, }; pub type Handle = HANDLE; @@ -1172,12 +1175,10 @@ pub fn mkdir(path: &widestring::WideCStr, mode: i32) -> io::Result<()> { lpSecurityDescriptor: core::ptr::null_mut(), bInheritHandle: 0, }; - let sddl: Vec = "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;OW)\0" - .encode_utf16() - .collect(); + let sddl = w!("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;OW)"); unsafe { ConvertStringSecurityDescriptorToSecurityDescriptorW( - sddl.as_ptr(), + sddl, SDDL_REVISION_1, &mut sec_attr.lpSecurityDescriptor, core::ptr::null_mut(), @@ -1699,10 +1700,10 @@ pub fn get_terminal_size_handle(h: HANDLE) -> io::Result<(usize, usize)> { if err != windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED { return Err(io::Error::last_os_error()); } - let conout: Vec = "CONOUT$\0".encode_utf16().collect(); + let conout = w!("CONOUT$"); let console_handle = unsafe { CreateFileW( - conout.as_ptr(), + conout, windows_sys::Win32::Foundation::GENERIC_READ | windows_sys::Win32::Foundation::GENERIC_WRITE, windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ diff --git a/crates/host_env/src/winapi.rs b/crates/host_env/src/winapi.rs index af5391008..19e18f32d 100644 --- a/crates/host_env/src/winapi.rs +++ b/crates/host_env/src/winapi.rs @@ -65,6 +65,7 @@ pub use windows_sys::Win32::{ }, UI::WindowsAndMessaging::SW_HIDE, }; +use windows_sys::w; pub type Handle = HANDLE; pub type StdHandle = windows_sys::Win32::System::Console::STD_HANDLE; @@ -1093,14 +1094,14 @@ where return Err(MimeRegistryReadError::Os(err)); } - let content_type_key: Vec = "Content Type\0".encode_utf16().collect(); + let content_type_key = w!("Content Type"); let mut type_buf = [0u16; 256]; let mut cb_type = (type_buf.len() * 2) as u32; let mut reg_type = 0; let err = unsafe { RegQueryValueExW( subkey, - content_type_key.as_ptr(), + content_type_key, core::ptr::null_mut(), &mut reg_type, type_buf.as_mut_ptr().cast(), diff --git a/crates/host_env/src/windows.rs b/crates/host_env/src/windows.rs index bde8d6797..635f12f3f 100644 --- a/crates/host_env/src/windows.rs +++ b/crates/host_env/src/windows.rs @@ -4,24 +4,27 @@ use std::{ io, os::windows::ffi::{OsStrExt, OsStringExt}, }; -use windows_sys::Win32::{ - Foundation::{ - E_POINTER, ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FLAGS, ERROR_NO_UNICODE_TRANSLATION, - MAX_PATH, S_OK, - }, - Networking::WinSock::WSAStartup, - Storage::FileSystem::{ - GetFileVersionInfoSizeW, GetFileVersionInfoW, VS_FIXEDFILEINFO, VerQueryValueW, - }, - System::{ - Diagnostics::Debug::{ - FORMAT_MESSAGE_ALLOCATE_BUFFER, FORMAT_MESSAGE_FROM_SYSTEM, - FORMAT_MESSAGE_IGNORE_INSERTS, FormatMessageW, +use windows_sys::{ + Win32::{ + Foundation::{ + E_POINTER, ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FLAGS, + ERROR_NO_UNICODE_TRANSLATION, MAX_PATH, S_OK, + }, + Networking::WinSock::WSAStartup, + Storage::FileSystem::{ + GetFileVersionInfoSizeW, GetFileVersionInfoW, VS_FIXEDFILEINFO, VerQueryValueW, + }, + System::{ + Diagnostics::Debug::{ + FORMAT_MESSAGE_ALLOCATE_BUFFER, FORMAT_MESSAGE_FROM_SYSTEM, + FORMAT_MESSAGE_IGNORE_INSERTS, FormatMessageW, + }, + LibraryLoader::{GetModuleFileNameW, GetModuleHandleW}, + SystemInformation::{GetVersionExW, OSVERSIONINFOEXW, OSVERSIONINFOW}, + Threading::{GetCurrentThreadStackLimits, SetThreadStackGuarantee}, }, - LibraryLoader::{GetModuleFileNameW, GetModuleHandleW}, - SystemInformation::{GetVersionExW, OSVERSIONINFOEXW, OSVERSIONINFOW}, - Threading::{GetCurrentThreadStackLimits, SetThreadStackGuarantee}, }, + w, }; /// _MAX_ENV from Windows CRT stdlib.h - maximum environment variable size @@ -154,8 +157,8 @@ pub struct WindowsVersionInfo { fn get_kernel32_version() -> io::Result<(u32, u32, u32)> { unsafe { - let module_name: Vec = OsStr::new("kernel32.dll").to_wide_with_nul(); - let h_kernel32 = GetModuleHandleW(module_name.as_ptr()).check_nonnull()?; + let module_name = w!("kernel32.dll"); + let h_kernel32 = GetModuleHandleW(module_name).check_nonnull()?; let mut kernel32_path = [0u16; MAX_PATH as usize]; let len = GetModuleFileNameW( @@ -181,13 +184,13 @@ fn get_kernel32_version() -> io::Result<(u32, u32, u32)> { ) .check_win32_bool()?; - let sub_block: Vec = OsStr::new("").to_wide_with_nul(); + let sub_block = w!(""); let mut ffi_ptr: *mut VS_FIXEDFILEINFO = core::ptr::null_mut(); let mut ffi_len: u32 = 0; VerQueryValueW( ver_block.as_ptr() as *const _, - sub_block.as_ptr(), + sub_block, &mut ffi_ptr as *mut *mut VS_FIXEDFILEINFO as *mut *mut _, &mut ffi_len as *mut u32, ) diff --git a/crates/host_env/src/wmi.rs b/crates/host_env/src/wmi.rs index 592ffd1dc..2b46eebcb 100644 --- a/crates/host_env/src/wmi.rs +++ b/crates/host_env/src/wmi.rs @@ -7,15 +7,17 @@ use core::ffi::c_void; use core::ptr::{NonNull, null, null_mut}; +use widestring::WideCString; use windows_sys::Win32::Foundation::{ - CloseHandle, ERROR_BROKEN_PIPE, ERROR_MORE_DATA, ERROR_NOT_ENOUGH_MEMORY, GetLastError, HANDLE, - WAIT_OBJECT_0, WAIT_TIMEOUT, + CloseHandle, ERROR_BROKEN_PIPE, ERROR_INVALID_NAME, ERROR_MORE_DATA, ERROR_NOT_ENOUGH_MEMORY, + GetLastError, HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT, }; use windows_sys::Win32::Storage::FileSystem::{ReadFile, WriteFile}; use windows_sys::Win32::System::Pipes::CreatePipe; use windows_sys::Win32::System::Threading::{ CreateEventW, CreateThread, GetExitCodeThread, SetEvent, WaitForSingleObject, }; +use windows_sys::w; use crate::ctypes::wcslen; @@ -256,10 +258,6 @@ const fn failed(hr: HRESULT) -> bool { hr < 0 } -fn wide_str(s: &str) -> Vec { - s.encode_utf16().chain(core::iter::once(0)).collect() -} - unsafe fn wait_event(event: HANDLE, timeout: u32) -> u32 { match unsafe { WaitForSingleObject(event, timeout) } { WAIT_OBJECT_0 => 0, @@ -346,8 +344,8 @@ unsafe fn query_thread_impl(param: *mut c_void) -> u32 { } if succeeded(hr) { - let root_cimv2 = wide_str("ROOT\\CIMV2"); - let bstr_root = unsafe { SysAllocString(root_cimv2.as_ptr()) }; + let root_cimv2 = w!("ROOT\\CIMV2"); + let bstr_root = unsafe { SysAllocString(root_cimv2) }; hr = unsafe { locator_connect_server( locator, @@ -384,8 +382,8 @@ unsafe fn query_thread_impl(param: *mut c_void) -> u32 { }; } if succeeded(hr) { - let wql = wide_str("WQL"); - let bstr_wql = unsafe { SysAllocString(wql.as_ptr()) }; + let wql = w!("WQL"); + let bstr_wql = unsafe { SysAllocString(wql) }; hr = unsafe { services_exec_query( services, @@ -557,7 +555,9 @@ unsafe fn query_thread_impl(param: *mut c_void) -> u32 { } pub fn exec_query(query_str: &str) -> Result { - let query_wide = wide_str(query_str); + let query = WideCString::from_str(query_str) + .map_err(|_| ExecQueryError::Code(ERROR_INVALID_NAME))? + .into(); let mut h_thread: HANDLE = null_mut(); let mut err: u32 = 0; @@ -579,7 +579,7 @@ pub fn exec_query(query_str: &str) -> Result { err = GetLastError(); } else { let thread_data = Box::new(QueryThreadData { - query: query_wide, + query, write_pipe, init_event, connect_event, From 6104cb159f99f2c3cbd929f3ad57038d1c85ecea Mon Sep 17 00:00:00 2001 From: Jiwoo Ahn Date: Sun, 9 Aug 2026 15:09:18 +0900 Subject: [PATCH 37/94] wasi: fix os.environb byte handling (#8476) Part of: #4583 This will enable -m test for wasi preview 1 Signed-off-by: Jiwoo Ahn --- crates/host_env/src/os.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/host_env/src/os.rs b/crates/host_env/src/os.rs index 01711687c..7af8f5861 100644 --- a/crates/host_env/src/os.rs +++ b/crates/host_env/src/os.rs @@ -519,13 +519,14 @@ pub fn set_errno(value: i32) { #[cfg(not(any(unix, windows, target_os = "wasi")))] pub fn set_errno(_value: i32) {} -#[cfg(unix)] +// WASIp1, like Unix, provides byte-preserving OsStr conversions. +#[cfg(any(unix, all(target_os = "wasi", not(target_env = "p2"))))] pub fn bytes_as_os_str(b: &[u8]) -> Result<&std::ffi::OsStr, Utf8Error> { - use std::os::unix::ffi::OsStrExt; + use self::ffi::OsStrExt; Ok(std::ffi::OsStr::from_bytes(b)) } -#[cfg(not(unix))] +#[cfg(not(any(unix, all(target_os = "wasi", not(target_env = "p2")))))] pub fn bytes_as_os_str(b: &[u8]) -> Result<&std::ffi::OsStr, Utf8Error> { Ok(core::str::from_utf8(b)?.as_ref()) } From 6b747b5a767563e2f4ddc95eb9a3c8c68be76af5 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:43:44 +0900 Subject: [PATCH 38/94] ssl: support explicit session reuse (#8466) * ssl: support explicit session reuse Assisted-by: Codex-5.6-sol * ssl: support explicit TLS 1.3 session reuse Assisted-by: Codex-5.6-sol --- Lib/test/test_ssl.py | 2 - crates/stdlib/src/ssl.rs | 388 +++++++++++++++++++++++++-------------- 2 files changed, 247 insertions(+), 143 deletions(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 8dad1ba73..e1759f1aa 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -4556,7 +4556,6 @@ class ThreadedTests(unittest.TestCase): s.sendfile(file) self.assertEqual(s.recv(1024), TEST_DATA) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_session(self): client_context, server_context, hostname = testing_context() # TODO: sessions aren't compatible with TLSv1.3 yet @@ -4614,7 +4613,6 @@ class ThreadedTests(unittest.TestCase): self.assertEqual(sess_stat['accept'], 4) self.assertEqual(sess_stat['hits'], 2) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: False != True def test_session_handling(self): client_context, server_context, hostname = testing_context() client_context2, _, _ = testing_context() diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 454fdcba8..04b905d54 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -137,6 +137,8 @@ mod _ssl { #[pyattr] const PROTOCOL_TLSv1_3: i32 = 6; + static NEXT_SSL_SESSION_NONCE: AtomicUsize = AtomicUsize::new(1); + // Protocol version constants for TLSVersion enum #[pyattr] const PROTO_SSLv3: i32 = 0x0300; @@ -439,6 +441,31 @@ mod _ssl { lifetime: u64, } + impl SessionData { + // NOTE: This is NOT the actual TLS session ID, just a unique identifier. + fn new(server_name: &str, lifetime: u64) -> Self { + let creation_time = SystemTime::now(); + let nonce = NEXT_SSL_SESSION_NONCE.fetch_add(1, Ordering::Relaxed); + let mut hasher = Sha256::new(); + hasher.update(server_name.as_bytes()); + hasher.update( + creation_time + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_le_bytes(), + ); + hasher.update(nonce.to_le_bytes()); + + Self { + _server_name: server_name.to_owned(), + session_id: hasher.finalize()[..16].to_vec(), + creation_time, + lifetime, + } + } + } + // Type alias to simplify complex session cache type type SessionCache = Arc, Arc>>>>; @@ -466,20 +493,6 @@ mod _ssl { // ✓ session_reused - tracked via handshake_kind() // ✗ Actual TLS session ID/ticket data - NOT ACCESSIBLE - // Generate a synthetic session ID from server name and timestamp - // NOTE: This is NOT the actual TLS session ID, just a unique identifier - fn generate_session_id_from_metadata(server_name: &str, time: SystemTime) -> Vec { - let mut hasher = Sha256::new(); - hasher.update(server_name.as_bytes()); - hasher.update( - time.duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs() - .to_le_bytes(), - ); - hasher.finalize()[..16].to_vec() - } - // Custom ClientSessionStore that tracks session metadata for Python access // NOTE: This wraps ClientSessionMemoryCache and records metadata when sessions are stored #[derive(Debug)] @@ -488,6 +501,39 @@ mod _ssl { session_cache: SessionCache, } + impl PythonClientSessionStore { + fn new(session_cache: SessionCache) -> Self { + Self { + inner: Arc::new(ClientSessionMemoryCache::new(SSL_SESSION_CACHE_SIZE)), + session_cache, + } + } + + fn transfer_session( + &self, + target: &Self, + server_name: &ServerName<'static>, + kind: ClientSessionKind, + ) { + if let Some(group) = self.kx_hint(server_name) { + target.set_kx_hint(server_name.clone(), group); + } + + match kind { + ClientSessionKind::Tls12 => { + if let Some(session) = self.tls12_session(server_name) { + target.set_tls12_session(server_name.clone(), session); + } + } + ClientSessionKind::Tls13 => { + if let Some(ticket) = self.take_tls13_ticket(server_name) { + target.insert_tls13_ticket(server_name.clone(), ticket); + } + } + } + } + } + impl ClientSessionStore for PythonClientSessionStore { fn set_kx_hint(&self, server_name: ServerName<'static>, group: rustls::NamedGroup) { self.inner.set_kx_hint(server_name, group); @@ -508,17 +554,8 @@ mod _ssl { // Record metadata in Python-accessible cache // NOTE: We can't access value.session_id or value.ticket (private fields) // So we generate a synthetic ID from metadata - let creation_time = SystemTime::now(); let server_name_str = server_name.to_str(); - let session_data = SessionData { - _server_name: server_name_str.as_ref().to_string(), - session_id: generate_session_id_from_metadata( - server_name_str.as_ref(), - creation_time, - ), - creation_time, - lifetime: 7200, // TLS 1.2 default session lifetime - }; + let session_data = SessionData::new(server_name_str.as_ref(), 7200); let key = server_name_str.as_bytes().to_vec(); self.session_cache @@ -552,17 +589,8 @@ mod _ssl { // Record metadata in Python-accessible cache // NOTE: We can't access value.ticket or value.lifetime_secs (private fields) // So we use default values - let creation_time = SystemTime::now(); let server_name_str = server_name.to_str(); - let session_data = SessionData { - _server_name: server_name_str.to_string(), - session_id: generate_session_id_from_metadata( - server_name_str.as_ref(), - creation_time, - ), - creation_time, - lifetime: 7200, // Default TLS 1.3 ticket lifetime (Rustls uses this) - }; + let session_data = SessionData::new(server_name_str.as_ref(), 7200); let key = server_name_str.as_bytes().to_vec(); self.session_cache @@ -745,6 +773,8 @@ mod _ssl { #[pyclass(name = "_SSLContext", module = "ssl", traverse)] #[derive(Debug, PyPayload)] struct PySSLContext { + #[pytraverse(skip)] + context_identity: Arc<()>, #[pytraverse(skip)] protocol: i32, #[pytraverse(skip)] @@ -807,9 +837,6 @@ mod _ssl { // Session management #[pytraverse(skip)] client_session_cache: SessionCache, - // Rustls session store for actual TLS session resumption - #[pytraverse(skip)] - rustls_session_store: Arc, // Rustls server session store for server-side session resumption #[pytraverse(skip)] rustls_server_session_store: Arc, @@ -1926,8 +1953,9 @@ mod _ssl { .map(|o| o.downgrade(None, vm)) .transpose()?, ), - // Filter out Python None objects - only store actual SSLSession objects - session: PyRwLock::new(args.session.into_option().filter(|s| !vm.is_none(s))), + session: PyRwLock::new(None), + client_config: PyRwLock::new(None), + client_session_store: PyRwLock::new(None), incoming_bio: None, outgoing_bio: None, sni_state: PyRwLock::new(None), @@ -1945,6 +1973,12 @@ mod _ssl { .into_ref_with_type(vm, vm.class("_ssl", "_SSLSocket")) .map_err(|_| vm.new_type_error("Failed to create SSLSocket"))?; + if let Some(session) = args.session.into_option() + && !vm.is_none(&session) + { + ssl_socket_ref.set_session(session, vm)?; + } + Ok(ssl_socket_ref) } @@ -2008,8 +2042,9 @@ mod _ssl { .map(|o| o.downgrade(None, vm)) .transpose()?, ), - // Filter out Python None objects - only store actual SSLSession objects - session: PyRwLock::new(args.session.into_option().filter(|s| !vm.is_none(s))), + session: PyRwLock::new(None), + client_config: PyRwLock::new(None), + client_session_store: PyRwLock::new(None), incoming_bio: Some(args.incoming), outgoing_bio: Some(args.outgoing), sni_state: PyRwLock::new(None), @@ -2026,6 +2061,12 @@ mod _ssl { .into_ref_with_type(vm, vm.class("_ssl", "_SSLSocket")) .map_err(|_| vm.new_type_error("Failed to create SSLSocket"))?; + if let Some(session) = args.session.into_option() + && !vm.is_none(&session) + { + ssl_socket_ref.set_session(session, vm)?; + } + Ok(ssl_socket_ref) } @@ -2304,19 +2345,11 @@ mod _ssl { _ => (PROTO_MINIMUM_SUPPORTED, PROTO_MAXIMUM_SUPPORTED), // Auto-negotiate }; - // IMPORTANT: Create shared session cache BEFORE PySSLContext - // Both client_session_cache and PythonClientSessionStore.session_cache - // MUST point to the same HashMap to ensure Python-level and Rustls-level - // sessions are synchronized + // Session metadata is scoped to the SSLContext. Each per-connection + // rustls session store records into this shared cache. let shared_session_cache = Arc::new(ParkingRwLock::new(HashMap::new())); - let rustls_client_store = Arc::new(PythonClientSessionStore { - inner: Arc::new(rustls::client::ClientSessionMemoryCache::new( - SSL_SESSION_CACHE_SIZE, - )), - session_cache: shared_session_cache.clone(), - }); - Ok(Self { + context_identity: Arc::new(()), protocol, check_hostname: PyRwLock::new(protocol == PROTOCOL_TLS_CLIENT), verify_mode: PyRwLock::new(default_verify_mode), @@ -2340,7 +2373,6 @@ mod _ssl { x509_cert_count: PyRwLock::new(0), // Use the shared cache created above client_session_cache: shared_session_cache, - rustls_session_store: rustls_client_store, rustls_server_session_store: rustls::server::ServerSessionMemoryCache::new( SSL_SESSION_CACHE_SIZE, ), @@ -2390,6 +2422,13 @@ mod _ssl { owner: PyRwLock>>, // Session for resumption session: PyRwLock>, + // Client configuration used by this connection. Retained so the resulting + // SSLSession can reuse the same verifier and client credentials. + #[pytraverse(skip)] + client_config: PyRwLock>>, + // Per-connection store containing the session selected by this connection. + #[pytraverse(skip)] + client_session_store: PyRwLock>>, // MemoryBIO mode (optional) incoming_bio: Option>, outgoing_bio: Option>, @@ -2487,31 +2526,27 @@ mod _ssl { } // Create and store a session object after successful handshake - fn create_session_after_handshake(&self, vm: &VirtualMachine) { + fn create_session_after_handshake(&self, was_resumed: bool, vm: &VirtualMachine) { // Only create session for client-side connections if self.server_side { return; } - // Check if session already exists - let session_opt = self.session.read().clone(); - if let Some(ref s) = session_opt { - if vm.is_none(s) { - } else { - return; - } - } - // Get server hostname let server_name = self.server_hostname.read().clone(); + let previous_session = self.session.read().clone(); // Try to get session data from context's session cache // IMPORTANT: Acquire and release locks quickly to avoid deadlock - let context = self.context.read(); - let session_cache_arc = context.client_session_cache.clone(); - drop(context); // Release context lock ASAP + let (context_identity, session_cache_arc) = { + let context = self.context.read(); + ( + context.context_identity.clone(), + context.client_session_cache.clone(), + ) + }; - let (session_id, creation_time, lifetime) = if let Some(ref name) = server_name { + let cached_session_data = if let Some(ref name) = server_name { let key = name.as_bytes().to_vec(); // Clone the data we need while holding the lock, then immediately release @@ -2521,29 +2556,57 @@ mod _ssl { }; // Lock released here if let Some(session_data_arc) = session_data_opt { - let data = session_data_arc.lock(); - let result = (data.session_id.clone(), data.creation_time, data.lifetime); - drop(data); // Explicit unlock - result + session_data_arc.lock().clone() } else { - // Create new session ID if not in cache - let time = std::time::SystemTime::now(); - (generate_session_id_from_metadata(name, time), time, 7200) + SessionData::new(name, 7200) } } else { - // No server name, use defaults - let time = std::time::SystemTime::now(); - (vec![0; 16], time, 7200) + SessionData::new("", 7200) + }; + + let session_data = if was_resumed { + previous_session + .as_ref() + .and_then(|session| session.downcast_ref::()) + .map_or(cached_session_data, |session| SessionData { + _server_name: server_name.clone().unwrap_or_default(), + session_id: session.session_id.clone(), + creation_time: session.creation_time, + lifetime: session.lifetime, + }) + } else { + cached_session_data + }; + + let rustls_server_name = server_name.and_then(|name| ServerName::try_from(name).ok()); + let protocol_version = self + .connection + .lock() + .as_ref() + .and_then(|connection| connection.protocol_version()); + let session_kind = match protocol_version { + Some(rustls::ProtocolVersion::TLSv1_2) => ClientSessionKind::Tls12, + Some(rustls::ProtocolVersion::TLSv1_3) => ClientSessionKind::Tls13, + _ => return, + }; + + let Some(client_config) = self.client_config.write().take() else { + return; + }; + let Some(session_store) = self.client_session_store.write().take() else { + return; }; - // Create a new SSLSession object with real metadata let session = PySSLSession { - // Use dummy session data to indicate we have a ticket - // TLS 1.2+ always uses session tickets/resumption - session_data: vec![1], // Non-empty to indicate has_ticket=True - session_id, - creation_time, - lifetime, + context_identity, + client_config, + session_store, + server_name: rustls_server_name, + kind: session_kind, + session_id: session_data.session_id, + creation_time: session_data.creation_time, + lifetime: session_data.lifetime, + has_ticket: true, }; let py_session = session.into_pyobject(vm); @@ -2638,7 +2701,7 @@ mod _ssl { let _ = self.track_used_ca_from_capath(); } - self.create_session_after_handshake(vm); + self.create_session_after_handshake(was_resumed, vm); } // Internal implementation with timeout control @@ -3486,9 +3549,9 @@ mod _ssl { let check_hostname = *ctx.check_hostname.read(); let verify_flags = *ctx.verify_flags.read(); + let context_identity = ctx.context_identity.clone(); - // Get session store before dropping ctx - let session_store = ctx.rustls_session_store.clone(); + let session_cache = ctx.client_session_cache.clone(); // Get CRLs for revocation checking let crls_clone = ctx.crls.read().clone(); @@ -3496,31 +3559,6 @@ mod _ssl { // Drop ctx early to avoid borrow conflicts drop(ctx); - // Build client config using compat helper - let config_options = ClientConfigOptions { - protocol_settings, - root_store: if verify_mode != CERT_NONE { - Some(root_store_clone) - } else { - None - }, - ca_certs_der: ca_certs_der_clone, - cert_chain: if !cert_chain_clone.is_empty() { - Some(cert_chain_clone) - } else { - None - }, - private_key: private_key_opt, - verify_server_cert: verify_mode != CERT_NONE, - check_hostname, - verify_flags, - session_store: Some(session_store), - crls: crls_clone, - }; - - let config = - create_client_config(config_options).map_err(|e| vm.new_value_error(e))?; - // Parse server name for SNI // Convert to ServerName use rustls::pki_types::ServerName; @@ -3539,10 +3577,61 @@ mod _ssl { ) }; - let conn = ClientConnection::new(Arc::new(config), server_name.clone()) - .map_err(|e| { - vm.new_value_error(format!("Failed to create client connection: {e}")) - })?; + let explicit_session = self.session.read().clone(); + let session_store = Arc::new(PythonClientSessionStore::new(session_cache)); + let config = if let Some(session) = explicit_session { + let session = session + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("Value is not a SSLSession."))?; + if !Arc::ptr_eq(&session.context_identity, &context_identity) { + return Err( + vm.new_value_error("Session refers to a different SSLContext.") + ); + } + if session.server_name.as_ref() == Some(&server_name) { + session.session_store.transfer_session( + &session_store, + &server_name, + session.kind, + ); + } + let mut config = (*session.client_config).clone(); + config.resumption = + rustls::client::Resumption::store(session_store.clone()); + Arc::new(config) + } else { + let config_options = ClientConfigOptions { + protocol_settings, + root_store: if verify_mode != CERT_NONE { + Some(root_store_clone) + } else { + None + }, + ca_certs_der: ca_certs_der_clone, + cert_chain: if !cert_chain_clone.is_empty() { + Some(cert_chain_clone) + } else { + None + }, + private_key: private_key_opt, + verify_server_cert: verify_mode != CERT_NONE, + check_hostname, + verify_flags, + session_store: Some(session_store.clone()), + crls: crls_clone, + }; + Arc::new( + create_client_config(config_options) + .map_err(|e| vm.new_value_error(e))?, + ) + }; + + *self.client_config.write() = Some(config.clone()); + *self.client_session_store.write() = Some(session_store); + + let conn = ClientConnection::new(config, server_name).map_err(|e| { + vm.new_value_error(format!("Failed to create client connection: {e}")) + })?; *conn_guard = Some(Connection::Client(conn)); } @@ -4046,12 +4135,15 @@ mod _ssl { #[pygetset(setter)] fn set_session(&self, value: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - // Validate that value is an SSLSession - if !value.is(vm.ctx.types.none_type) { - // Try to downcast to SSLSession to validate - let _ = value - .downcast_ref::() - .ok_or_else(|| vm.new_type_error("Value is not a SSLSession."))?; + let session = value + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("Value is not a SSLSession."))?; + + if !Arc::ptr_eq( + &session.context_identity, + &self.context.read().context_identity, + ) { + return Err(vm.new_value_error("Session refers to a different SSLContext.")); } // Check if this is a client socket @@ -4065,11 +4157,7 @@ mod _ssl { } // Store the session for potential use during handshake - *self.session.write() = if value.is(vm.ctx.types.none_type) { - None - } else { - Some(value) - }; + *self.session.write() = Some(value); Ok(()) } @@ -4751,22 +4839,31 @@ mod _ssl { // SSLSession - represents a cached SSL session // NOTE: This is an EMULATION - actual session data is managed by Rustls internally + #[derive(Debug, Clone, Copy)] + enum ClientSessionKind { + Tls12, + Tls13, + } + #[pyattr] #[pyclass(name = "SSLSession", module = "ssl")] #[derive(Debug, PyPayload)] struct PySSLSession { - // Session data - serialized rustls session (EMULATED - kept empty) - session_data: Vec, + context_identity: Arc<()>, + client_config: Arc, + session_store: Arc, + server_name: Option>, + kind: ClientSessionKind, // Session ID - synthetic ID generated from metadata (NOT actual TLS session ID) - #[allow(dead_code)] session_id: Vec, // Session metadata creation_time: std::time::SystemTime, // Lifetime in seconds (default 7200 = 2 hours) lifetime: u64, + has_ticket: bool, } - #[pyclass(flags(BASETYPE))] + #[pyclass(flags(BASETYPE), with(Comparable))] impl PySSLSession { #[pygetset] fn time(&self) -> i64 { @@ -4791,20 +4888,29 @@ mod _ssl { #[pygetset] fn id(&self, vm: &VirtualMachine) -> PyBytesRef { - // Return session ID (hash of session data for uniqueness) - - let mut hasher = DefaultHasher::new(); - self.session_data.hash(&mut hasher); - let hash = hasher.finish(); - - // Convert hash to bytes - vm.ctx.new_bytes(hash.to_be_bytes().to_vec()) + vm.ctx.new_bytes(self.session_id.clone()) } #[pygetset] fn has_ticket(&self) -> bool { - // For rustls, if we have session data, we have a ticket - !self.session_data.is_empty() + self.has_ticket + } + } + + impl Comparable for PySSLSession { + fn cmp( + zelf: &Py, + other: &PyObject, + op: PyComparisonOp, + _vm: &VirtualMachine, + ) -> PyResult { + op.eq_only(|| { + if let Some(other_session) = other.downcast_ref::() { + Ok((zelf.session_id == other_session.session_id).into()) + } else { + Ok(PyComparisonValue::NotImplemented) + } + }) } } From 862797d1ceee0010db06d950a396b0696d4b18ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EB=8F=99=EC=95=88?= Date: Mon, 10 Aug 2026 00:50:46 +0900 Subject: [PATCH 39/94] Add new_payload_exception helper for constructing built-in payload exceptions (#8403) * Add new_payload_exception helper for built-in payload exceptions * Use new_payload_exception for SystemExit raise sites * Use new_payload_exception for BlockingIoError raise sites * Use new_payload_exception for OSError errno dispatch in slot_new * Run rustfmt * Run clippy * Add type guard to new_payload_exception and extract new_system_exit * Extract new_system_exit and fix stale expect messages The four SystemExit raise sites repeated the same construction, so route them through a new_system_exit helper next to new_stop_iteration. The expect messages still described invoke_exception's downcast failure, which no longer applies after moving to new_payload_exception. * Update crates/vm/src/vm/vm_new.rs --------- Co-authored-by: Jeong, YunWon <69878+youknowone@users.noreply.github.com> --- crates/vm/src/exceptions.rs | 15 ++++---- crates/vm/src/stdlib/_io.rs | 60 +++++++++++++++++++------------- crates/vm/src/stdlib/_thread.rs | 2 +- crates/vm/src/stdlib/builtins.rs | 2 +- crates/vm/src/stdlib/sys.rs | 3 +- crates/vm/src/vm/mod.rs | 2 +- crates/vm/src/vm/vm_new.rs | 49 +++++++++++++++++++------- 7 files changed, 82 insertions(+), 51 deletions(-) diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index ffe5a6f0a..9c42df966 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -1389,14 +1389,8 @@ impl OSErrorBuilder { vec![strerror.to_pyobject(vm)] }; - let payload = PyOSError::py_new(&exc_type, args.clone().into(), vm) - .expect("new_os_error usage error"); - let os_error = payload - .into_ref_with_type_lazy_dict(vm, exc_type) - .expect("new_os_error usage error"); - PyOSError::slot_init(os_error.as_object().to_owned(), args.into(), vm) - .expect("new_os_error usage error"); - os_error + vm.new_payload_exception::(exc_type, args.into()) + .expect("new_os_error usage error") } } @@ -2148,7 +2142,10 @@ pub(super) mod types { .downcast_ref::() .and_then(|errno| errno.try_to_primitive::(vm).ok()) .and_then(|errno| super::errno_to_exc_type(errno, vm)) - .and_then(|typ| vm.invoke_exception(typ, args_vec).ok()) + .and_then(|typ| { + vm.new_payload_exception::(typ.to_owned(), args_vec.into()) + .ok() + }) { return error.to_pyresult(vm); } diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 43a869fbe..ab1be4297 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -20,7 +20,8 @@ cfg_select! { } use crate::{ - AsObject, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, builtins::PyModule, + AsObject, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, + builtins::{PyModule, PyOSError}, }; pub use _io::{OpenArgs, io_open as open}; use rustpython_host_env::io as host_io; @@ -943,14 +944,17 @@ mod _io { Some(n) => n, None => { // BlockingIOError(errno, msg, characters_written=0) - return Err(vm.invoke_exception( - vm.ctx.exceptions.blocking_io_error, - vec![ - vm.new_pyobj(EAGAIN), - vm.new_pyobj("write could not complete without blocking"), - vm.new_pyobj(0), - ], - )?); + return Err(vm + .new_payload_exception::( + vm.ctx.exceptions.blocking_io_error.to_owned(), + vec![ + vm.new_pyobj(EAGAIN), + vm.new_pyobj("write could not complete without blocking"), + vm.new_pyobj(0), + ] + .into(), + )? + .upcast()); } }; self.write_pos += n as Offset; @@ -1154,14 +1158,17 @@ mod _io { self.buffer[self.write_end as usize..][..avail].copy_from_slice(&buf[..avail]); self.write_end += avail as Offset; self.pos += avail as Offset; - return Err(vm.invoke_exception( - vm.ctx.exceptions.blocking_io_error, - vec![ - vm.new_pyobj(EAGAIN), - vm.new_pyobj("write could not complete without blocking"), - vm.new_pyobj(avail), - ], - )?); + return Err(vm + .new_payload_exception::( + vm.ctx.exceptions.blocking_io_error.to_owned(), + vec![ + vm.new_pyobj(EAGAIN), + vm.new_pyobj("write could not complete without blocking"), + vm.new_pyobj(avail), + ] + .into(), + )? + .upcast()); } Err(e) => return Err(e), } @@ -1200,14 +1207,17 @@ mod _io { self.write_end = buffer_size; // BlockingIOError(errno, msg, characters_written) let chars_written = written + buffer_len; - return Err(vm.invoke_exception( - vm.ctx.exceptions.blocking_io_error, - vec![ - vm.new_pyobj(EAGAIN), - vm.new_pyobj("write could not complete without blocking"), - vm.new_pyobj(chars_written), - ], - )?); + return Err(vm + .new_payload_exception::( + vm.ctx.exceptions.blocking_io_error.to_owned(), + vec![ + vm.new_pyobj(EAGAIN), + vm.new_pyobj("write could not complete without blocking"), + vm.new_pyobj(chars_written), + ] + .into(), + )? + .upcast()); } None => break, } diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 0a80285db..70304e639 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -635,7 +635,7 @@ pub(crate) mod _thread { #[pyfunction] fn exit(vm: &VirtualMachine) -> PyResult { - Err(vm.invoke_exception(vm.ctx.exceptions.system_exit, vec![])?) + Err(vm.new_system_exit(vec![].into())) } thread_local!(static SENTINELS: RefCell>> = const { RefCell::new(Vec::new()) }); diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 7817d6ecb..35f404f0f 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -1041,7 +1041,7 @@ mod builtins { #[pyfunction] pub(super) fn exit(exit_code_arg: OptionalArg, vm: &VirtualMachine) -> PyResult { let code = exit_code_arg.unwrap_or_else(|| vm.ctx.new_int(0).into()); - Err(vm.invoke_exception(vm.ctx.exceptions.system_exit, vec![code])?) + Err(vm.new_system_exit(vec![code].into())) } #[derive(Debug, Default, FromArgs)] diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 71aeccf34..c7cc2fd29 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -776,8 +776,7 @@ pub mod sys { } else { vec![status] }; - let exc = vm.invoke_exception(vm.ctx.exceptions.system_exit, args)?; - Err(exc) + Err(vm.new_system_exit(args.into())) } #[pyfunction] diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index e0a086c10..6009f421e 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -2737,7 +2737,7 @@ impl VirtualMachine { if self.state.finalizing.load(Ordering::Acquire) && !self.is_main_thread() { // once finalization starts, // non-main Python threads should stop running bytecode. - return Err(self.invoke_exception(self.ctx.exceptions.system_exit, vec![])?); + return Err(self.new_system_exit(vec![].into())); } // Suspend this thread if stop-the-world is in progress diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index 4df3c6391..6110a3d5b 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -12,17 +12,18 @@ use rustpython_compiler::{CompileError, ParseError}; use crate::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, builtins::{ - PyBaseException, PyBaseExceptionRef, PyBytesRef, PyDictRef, PyModule, PyOSError, PyStrRef, - PyType, PyTypeRef, + PyBaseException, PyBaseExceptionRef, PyBytesRef, PyDictRef, PyModule, PyOSError, + PyStopIteration, PyStrRef, PySystemExit, PyType, PyTypeRef, builtin_func::PyNativeFunction, descriptor::PyMethodDescriptor, tuple::{IntoPyTuple, PyTupleRef}, }, convert::{ToPyException, ToPyObject}, exceptions::OSErrorBuilder, - function::{IntoPyNativeFn, PyMethodFlags}, + function::{FuncArgs, IntoPyNativeFn, PyMethodFlags}, scope::Scope, set_attrs, + types::{Constructor, Initializer}, vm::VirtualMachine, }; @@ -353,6 +354,26 @@ impl VirtualMachine { .expect("vm.new_exception() called with an invalid exception type") } + /// Construct a built-in exception type that carries a payload, directly + /// (`py_new` + `slot_init`), without routing through `PyType::call`. + /// Only valid for a built-in `T` whose exact type is known at compile time. + pub fn new_payload_exception(&self, cls: PyTypeRef, args: FuncArgs) -> PyResult> + where + T: Constructor + Initializer, + { + debug_assert_eq!( + cls.slots.basicsize, + size_of::(), + "vm.new_payload_exception::<{}>() called with mismatched type '{}'", + core::any::type_name::(), + cls.name() + ); + let payload = T::py_new(&cls, args.clone(), self)?; + let exc = payload.into_ref_with_type_lazy_dict(self, cls)?; + T::slot_init(exc.as_object().to_owned(), args, self)?; + Ok(exc) + } + pub fn new_os_error(&self, msg: impl ToPyObject) -> PyRef { self.new_os_subtype_error(self.ctx.exceptions.os_error.to_owned(), None, msg) .upcast() @@ -905,16 +926,20 @@ impl VirtualMachine { exc } - pub fn new_stop_iteration(&self, value: Option) -> PyBaseExceptionRef { - let stop_iteration_error = self.ctx.exceptions.stop_iteration; - let args = if let Some(value) = value { - vec![value] - } else { - Vec::new() - }; - let exc = self.invoke_exception(stop_iteration_error, args); + pub fn new_system_exit(&self, args: FuncArgs) -> PyBaseExceptionRef { + self.new_payload_exception::(self.ctx.exceptions.system_exit.to_owned(), args) + .expect("SystemExit construction from internal args is infallible") + .upcast() + } - exc.expect("StopIteration is a BaseException Subclass.") + pub fn new_stop_iteration(&self, value: Option) -> PyBaseExceptionRef { + let args: FuncArgs = value.map(|v| vec![v]).unwrap_or_default().into(); + self.new_payload_exception::( + self.ctx.exceptions.stop_iteration.to_owned(), + args, + ) + .expect("StopIteration construction from internal args is infallible") + .upcast() } fn new_downcast_error( From 1c9daa0ef679116fa37565a251857798383ca39f Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:45:16 +0900 Subject: [PATCH 40/94] Add JIT support for returning None (#8479) --- crates/jit/src/instructions.rs | 39 ++++++++++++++++++---------------- crates/jit/src/lib.rs | 32 ++++++++++++++-------------- crates/jit/tests/misc_tests.rs | 19 ++++++++--------- 3 files changed, 46 insertions(+), 44 deletions(-) diff --git a/crates/jit/src/instructions.rs b/crates/jit/src/instructions.rs index 9b4656da5..67cf07f6e 100644 --- a/crates/jit/src/instructions.rs +++ b/crates/jit/src/instructions.rs @@ -39,6 +39,7 @@ impl JitValue { JitType::Int => Self::Int(val), JitType::Float => Self::Float(val), JitType::Bool => Self::Bool(val), + JitType::None => unreachable!("None cannot be used as an argument type"), } } @@ -47,7 +48,8 @@ impl JitValue { Self::Int(_) => Some(JitType::Int), Self::Float(_) => Some(JitType::Float), Self::Bool(_) => Some(JitType::Bool), - Self::None | Self::Null | Self::Tuple(_) | Self::FuncRef(_) => None, + Self::None => Some(JitType::None), + Self::Null | Self::Tuple(_) | Self::FuncRef(_) => None, } } @@ -112,8 +114,9 @@ impl<'a, 'b> FunctionCompiler<'a, 'b> { #[expect(clippy::mut_mut, reason = "This seems like a false positive")] let builder = &mut self.builder; let ty = val.to_jit_type().ok_or(JitCompileError::NotSupported)?; + let cranelift_ty = ty.to_cranelift().ok_or(JitCompileError::NotSupported)?; let local = self.variables[idx].get_or_insert_with(|| { - let var = builder.declare_var(ty.to_cranelift()); + let var = builder.declare_var(cranelift_ty); Local { var, ty: ty.clone(), @@ -328,27 +331,27 @@ impl<'a, 'b> FunctionCompiler<'a, 'b> { } fn return_value(&mut self, val: JitValue) -> Result<(), JitCompileError> { - if let Some(ref ty) = self.sig.ret { - // If the signature has a return type, enforce it - if val.to_jit_type().as_ref() != Some(ty) { + let val_type = val.to_jit_type().ok_or(JitCompileError::NotSupported)?; + if let Some(ref ret_type) = self.sig.ret { + if ret_type != &val_type { return Err(JitCompileError::NotSupported); } } else { - // First time we see a return, define it in the signature - let ty = val.to_jit_type().ok_or(JitCompileError::NotSupported)?; - self.sig.ret = Some(ty.clone()); - self.builder - .func - .signature - .returns - .push(AbiParam::new(ty.to_cranelift())); + self.sig.ret = Some(val_type.clone()); + if let Some(val_type) = val_type.to_cranelift() { + self.builder + .func + .signature + .returns + .push(AbiParam::new(val_type)); + } } - // If this is e.g. an Int, Float, or Bool we have a Cranelift `Value`. - // If we have JitValue::None or .Tuple(...) but can't handle that, error out (or handle differently). - let cr_val = val.into_value().ok_or(JitCompileError::NotSupported)?; - - self.builder.ins().return_(&[cr_val]); + if let Some(cr_val) = val.into_value() { + self.builder.ins().return_(&[cr_val]); + } else { + self.builder.ins().return_(&[]); + } Ok(()) } diff --git a/crates/jit/src/lib.rs b/crates/jit/src/lib.rs index dbaa4a3eb..0c700e93c 100644 --- a/crates/jit/src/lib.rs +++ b/crates/jit/src/lib.rs @@ -61,19 +61,12 @@ impl Jit { ret: Option, ) -> Result<(FuncId, JitSig), JitCompileError> { for arg in args { - self.ctx - .func - .signature - .params - .push(AbiParam::new(arg.to_cranelift())); + let arg = arg.to_cranelift().ok_or(JitCompileError::NotSupported)?; + self.ctx.func.signature.params.push(AbiParam::new(arg)); } - if ret.is_some() { - self.ctx - .func - .signature - .returns - .push(AbiParam::new(ret.clone().unwrap().to_cranelift())); + if let Some(ret) = ret.as_ref().and_then(JitType::to_cranelift) { + self.ctx.func.signature.returns.push(AbiParam::new(ret)); } let id = self.module.declare_function( @@ -167,7 +160,10 @@ impl CompiledCode { libffi::middle::CodePtr::from_ptr(self.code as *const _), cif_args, ); - self.sig.ret.as_ref().map(|ty| value.to_typed(ty)) + match self.sig.ret.as_ref() { + Some(JitType::None) | None => None, + Some(ty) => Some(value.to_typed(ty)), + } } } } @@ -193,14 +189,16 @@ pub enum JitType { Int, Float, Bool, + None, } impl JitType { - fn to_cranelift(&self) -> types::Type { + fn to_cranelift(&self) -> Option { match self { - Self::Int => types::I64, - Self::Float => types::F64, - Self::Bool => types::I8, + Self::Int => Some(types::I64), + Self::Float => Some(types::F64), + Self::Bool => Some(types::I8), + Self::None => None, } } @@ -209,6 +207,7 @@ impl JitType { Self::Int => libffi::middle::Type::i64(), Self::Float => libffi::middle::Type::f64(), Self::Bool => libffi::middle::Type::u8(), + Self::None => libffi::middle::Type::void(), } } } @@ -306,6 +305,7 @@ impl UnTypedAbiValue { JitType::Int => AbiValue::Int(self.int), JitType::Float => AbiValue::Float(self.float), JitType::Bool => AbiValue::Bool(self.boolean != 0), + JitType::None => unreachable!("None has no ABI value"), } } } diff --git a/crates/jit/tests/misc_tests.rs b/crates/jit/tests/misc_tests.rs index b73100ad6..5404df0a7 100644 --- a/crates/jit/tests/misc_tests.rs +++ b/crates/jit/tests/misc_tests.rs @@ -2,16 +2,15 @@ mod tests { use rustpython_jit::{AbiValue, JitArgumentError}; - // TODO currently broken - // #[test] - // fn test_no_return_value() { - // let func = jit_function! { func() => r##" - // def func(): - // pass - // "## }; - // - // assert_eq!(func(), Ok(())); - // } + #[test] + fn no_return_value() { + let func = jit_function! { func() => r##" + def func(): + pass + "## }; + + assert_eq!(func(), Ok(())); + } #[test] fn invoke() { From 3236002f9a46e3a9497ed8dcaa4b34b464692e6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:25:19 +0900 Subject: [PATCH 41/94] build(deps): bump cargo-bins/cargo-binstall from 1.21.0 to 1.21.1 (#8482) Bumps [cargo-bins/cargo-binstall](https://github.com/cargo-bins/cargo-binstall) from 1.21.0 to 1.21.1. - [Release notes](https://github.com/cargo-bins/cargo-binstall/releases) - [Changelog](https://github.com/cargo-bins/cargo-binstall/blob/main/release-plz.toml) - [Commits](https://github.com/cargo-bins/cargo-binstall/compare/ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4...e00d2c94cc0067b77737821097a62d91c0301baa) --- updated-dependencies: - dependency-name: cargo-bins/cargo-binstall dependency-version: 1.21.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ae82353c2..3a295f742 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -526,7 +526,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0 + - uses: cargo-bins/cargo-binstall@e00d2c94cc0067b77737821097a62d91c0301baa # v1.21.1 - name: cargo shear run: | From d8afff609451b8d13645ce39665c22e0b66b7d9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:25:28 +0900 Subject: [PATCH 42/94] build(deps): bump j178/prek-action from 2.0.6 to 3.0.0 (#8483) Bumps [j178/prek-action](https://github.com/j178/prek-action) from 2.0.6 to 3.0.0. - [Release notes](https://github.com/j178/prek-action/releases) - [Commits](https://github.com/j178/prek-action/compare/5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16...4e14d07f9231acabce116ccfca13b13dd9755ece) --- updated-dependencies: - dependency-name: j178/prek-action dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3a295f742..4222579f6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -567,7 +567,7 @@ jobs: - name: install prek id: prek - uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6 + uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 with: cache: false show-verbose-logs: false From cf06b45920ca624431cd676d1029350b75026cc4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:25:40 +0900 Subject: [PATCH 43/94] build(deps): bump jiff in the jiff group across 1 directory (#8484) Bumps the jiff group with 1 update in the / directory: [jiff](https://github.com/BurntSushi/jiff). Updates `jiff` from 0.2.31 to 0.2.35 - [Release notes](https://github.com/BurntSushi/jiff/releases) - [Changelog](https://github.com/BurntSushi/jiff/blob/master/CHANGELOG.md) - [Commits](https://github.com/BurntSushi/jiff/compare/jiff-static-0.2.31...jiff-static-0.2.35) --- updated-dependencies: - dependency-name: jiff dependency-version: 0.2.35 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: jiff ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3acba3351..b8762619a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1768,11 +1768,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.31" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", + "jiff-core", "jiff-static", "jiff-tzdb-platform", "js-sys", @@ -1785,11 +1786,21 @@ dependencies = [ ] [[package]] -name = "jiff-static" -version = "0.2.31" +name = "jiff-core" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", "proc-macro2", "quote", "syn 2.0.119", From 03518f88567880db2254406b02536bc6f0c8ed51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:25:50 +0900 Subject: [PATCH 44/94] build(deps): bump pyo3 in the pyo3 group across 1 directory (#8485) Bumps the pyo3 group with 1 update in the / directory: [pyo3](https://github.com/pyo3/pyo3). Updates `pyo3` from 0.29.0 to 0.29.2 - [Release notes](https://github.com/pyo3/pyo3/releases) - [Changelog](https://github.com/PyO3/pyo3/blob/main/CHANGELOG.md) - [Commits](https://github.com/pyo3/pyo3/compare/v0.29.0...v0.29.2) --- updated-dependencies: - dependency-name: pyo3 dependency-version: 0.29.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pyo3 ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8762619a..4af185c04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2790,9 +2790,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" dependencies = [ "libc", "once_cell", @@ -2804,18 +2804,18 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" dependencies = [ "libc", "pyo3-build-config", @@ -2823,9 +2823,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -2835,9 +2835,9 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" dependencies = [ "heck", "proc-macro2", From b2e6b12976141f444f7e7df62ac37270599b2700 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:25:59 +0900 Subject: [PATCH 45/94] build(deps): bump libz-rs-sys from 0.6.6 to 0.6.7 (#8486) Bumps [libz-rs-sys](https://github.com/trifectatechfoundation/zlib-rs) from 0.6.6 to 0.6.7. - [Release notes](https://github.com/trifectatechfoundation/zlib-rs/releases) - [Changelog](https://github.com/trifectatechfoundation/zlib-rs/blob/main/docs/release.md) - [Commits](https://github.com/trifectatechfoundation/zlib-rs/compare/v0.6.6...v0.6.7) --- updated-dependencies: - dependency-name: libz-rs-sys dependency-version: 0.6.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4af185c04..26015d399 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2033,9 +2033,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50474818739ccab820cd57bca432d6b02d090b47f9e85501d963cd05851f82eb" +checksum = "03dcace986b149f29509af6ca70e6182bccce916b644424ecf484faa8ddc899a" dependencies = [ "zlib-rs", ] @@ -5081,9 +5081,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" From 58bff0b63acf885eaeda8a6dca8e8d197cd486ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:26:10 +0900 Subject: [PATCH 46/94] build(deps): bump github/gh-aw/actions/setup from 0.83.4 to 0.84.3 (#8487) Bumps [github/gh-aw/actions/setup](https://github.com/github/gh-aw) from 0.83.4 to 0.84.3. - [Release notes](https://github.com/github/gh-aw/releases) - [Changelog](https://github.com/github/gh-aw/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/gh-aw/compare/bbb8042878459948333b15b66f27113f4b5c1b9a...53258938b59e0797fefeed05ec0c681514b2a827) --- updated-dependencies: - dependency-name: github/gh-aw/actions/setup dependency-version: 0.84.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/upgrade-pylib.lock.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index 4f2944408..3ac445859 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -58,7 +58,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -99,7 +99,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -806,7 +806,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -927,7 +927,7 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Download agent artifacts @@ -1039,7 +1039,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Download agent output artifact From 973c3f48526dcd1c2b93b7820fc811b013fb2a8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:26:22 +0900 Subject: [PATCH 47/94] build(deps): bump https://github.com/astral-sh/ruff-pre-commit (#8488) Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.16.0 to 0.16.1. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.0...v0.16.1) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.16.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index db9869bb3..9ffc4b8d4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.0 + rev: v0.16.1 hooks: - id: ruff-format priority: 0 From efa1057e76d1a1815a6aea00fa90cbc4d47e0c29 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:37:49 +0900 Subject: [PATCH 48/94] build(deps): bump zizmorcore/zizmor-action from 0.6.1 to 0.6.2 (#8481) Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.6.1 to 0.6.2. - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/6fc4b006235f201fdab3722e17240ab420d580e5...3dc1ecc9bcb9e94e9b2c709687979e1298497054) --- updated-dependencies: - dependency-name: zizmorcore/zizmor-action dependency-version: 0.6.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4222579f6..d832398eb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -557,7 +557,7 @@ jobs: uses: reviewdog/action-actionlint@50842263c20a7c46bd0065b9e624d3c569db061e # v1.73.0 - name: zizmor - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 - name: restore prek cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 From 267c7916b16e8c8bef9ef0426962294a79002c6f Mon Sep 17 00:00:00 2001 From: William Goode <95141298+william-goode@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:16:10 -0400 Subject: [PATCH 49/94] Fix map no iterables (#8478) * map: raise TypeError when called with no iterables Assisted-by: Claude Code:claude-fable-5 * add test for map with no iterables Assisted-by: Claude Code:claude-fable-5 * removed expectedFailures * removed test for map constructed with no iterables - covered by existing in test_itertools --- Lib/test/test_itertools.py | 1 - crates/vm/src/builtins/map.rs | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_itertools.py b/Lib/test/test_itertools.py index e865c9bf0..585f6611a 100644 --- a/Lib/test/test_itertools.py +++ b/Lib/test/test_itertools.py @@ -1136,7 +1136,6 @@ class TestBasicOps(unittest.TestCase): self.assertEqual(repr(repeat('a', times=-1)), "repeat('a', 0)") self.assertEqual(repr(repeat('a', times=-2)), "repeat('a', 0)") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_map(self): self.assertEqual(list(map(operator.pow, range(3), range(1,7))), [0**1, 1**2, 2**3]) diff --git a/crates/vm/src/builtins/map.rs b/crates/vm/src/builtins/map.rs index 4dda9caf2..cb8db23e6 100644 --- a/crates/vm/src/builtins/map.rs +++ b/crates/vm/src/builtins/map.rs @@ -37,9 +37,12 @@ impl Constructor for PyMap { fn py_new( _cls: &Py, (mapper, iterators, args): Self::Args, - _vm: &VirtualMachine, + vm: &VirtualMachine, ) -> PyResult { let iterators = iterators.into_vec(); + if iterators.is_empty() { + return Err(vm.new_type_error("map() must have at least two arguments.")); + } let strict = Radium::new(args.strict.unwrap_or(false)); Ok(Self { mapper, From b304919a63010f78daa65bc5db14fd487829aeb9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:07:02 +0900 Subject: [PATCH 50/94] Replace deleted README demo script reference (#8492) * Initial plan * Fix stale README example Assisted-by: Copilot: GPT-5.6 Co-authored-by: youknowone <69878+youknowone@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: youknowone <69878+youknowone@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 37b577d0f..cb086687d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ needed to prevent stack overflow on Windows): ```bash $ cd RustPython -$ cargo run --release demo_closures.py +$ cargo run --release -- -c 'print("Hello, RustPython!")' Hello, RustPython! ``` From 856c3da0beacb47c34a1fcf19b9cffb86988749f Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Wed, 12 Aug 2026 16:20:37 +0900 Subject: [PATCH 51/94] Reuse stored hashes when building a set from a set/frozenset/dict (#8491) * Reuse stored hashes when building a set from a set/frozenset/dict set and frozenset recomputed __hash__ for every element even when the source object already stored a hash per entry. CPython's set_update_internal branches on PyAnySet_Check / PyDict_CheckExact and feeds set_add_entry the hash read from the source table; RustPython always iterated generically. Split the hash computation out of the Dict entry points so callers can supply a hash they already hold, add keys_with_hashes() to hand out (key, hash) pairs, and take the fast path in the set constructors and in the set operations whose argument is a set/frozenset/exact dict. ArgIterable::as_object() exposes the pre-__iter__ object so the set operations can dispatch on the source type without changing any of their signatures. Closes #8489. dict.fromkeys() is the dict-target counterpart and is tracked in #8490, so test_do_not_rehash_dict_keys keeps its expectedFailure marker until that lands too. Co-Authored-By: Claude Opus 5 (1M context) * Rename the hash-carrying variants to *_known_hash `_with_hash` said nothing about which direction the hash travels, and the same suffix was already used both ways in this file: keys_with_hashes() hands hashes out, while insert_with_hash() takes one in. The pre-existing insert_with_hint()/get_with_hint() pair has the same problem. Follow CPython's "KnownHash" variants (_PyDict_SetItem_KnownHash, _PyDict_Contains_KnownHash, _PyDict_DelItem_KnownHash) instead, so the suffix marks the argument direction and matches the name a reader familiar with CPython already expects. keys_with_hashes() keeps `with` because it really does return the hashes. Co-Authored-By: Claude Opus 5 (1M context) * Narrow visibility of the new helpers and trim their docs from_object() and ArgIterable::as_object() had no caller outside their own file and crate respectively, so drop them to private and pub(crate). The dict_inner helpers stay pub(crate) because builtins::set calls them. Also shorten the doc comments to match the density of the surrounding code; only insert_known_hash keeps a real note, since a wrong hash there corrupts the table silently. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- crates/vm/src/builtins/set.rs | 121 ++++++++++++++++++++++++----- crates/vm/src/dict_inner.rs | 84 +++++++++++++++++++- crates/vm/src/function/protocol.rs | 5 ++ 3 files changed, 190 insertions(+), 20 deletions(-) diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index cd724abc5..62bdb0f0d 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -195,6 +195,26 @@ impl PySetInner { Ok(set) } + /// Build a set from an arbitrary object, reusing stored hashes when the + /// source is a set/frozenset/dict. + fn from_object(iterable: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let set = Self::default(); + set.update_internal(iterable, vm)?; + Ok(set) + } + + /// Elements of `obj` with their stored hashes, or `None` if `obj` keeps + /// none and must be iterated generically. Mirrors the `PyAnySet_Check` / + /// `PyDict_CheckExact` fast paths in CPython's `set_update_internal`. + fn cached_hashes(obj: &PyObject, vm: &VirtualMachine) -> Option> { + if let Some(set) = extract_set(obj) { + Some(set.content.keys_with_hashes()) + } else { + obj.downcast_ref_if_exact::(vm) + .map(|dict| dict._as_dict_inner().keys_with_hashes()) + } + } + fn fold_op( &self, others: impl core::iter::Iterator, @@ -228,6 +248,17 @@ impl PySetInner { Self::wrap_unhashable_error(result, needle, vm) } + /// [`Self::contains`] with a known hash. Such a needle came out of a + /// set/dict, so it is hashable and needs no frozenset retry. + fn contains_known_hash( + &self, + needle: &PyObject, + hash: PyHash, + vm: &VirtualMachine, + ) -> PyResult { + self.content.contains_known_hash(vm, needle, hash) + } + fn compare(&self, other: &Self, op: PyComparisonOp, vm: &VirtualMachine) -> PyResult { if op == PyComparisonOp::Ne { return self.compare(other, PyComparisonOp::Eq, vm).map(|eq| !eq); @@ -251,6 +282,12 @@ impl PySetInner { pub(super) fn union(&self, other: ArgIterable, vm: &VirtualMachine) -> PyResult { let set = self.clone(); + if let Some(elements) = Self::cached_hashes(other.as_object(), vm) { + for (item, hash) in elements { + set.add_known_hash(item, hash, vm)?; + } + return Ok(set); + } for item in other.iter(vm)? { set.add(item?, vm)?; } @@ -260,6 +297,14 @@ impl PySetInner { pub(super) fn intersection(&self, other: ArgIterable, vm: &VirtualMachine) -> PyResult { let set = Self::default(); + if let Some(elements) = Self::cached_hashes(other.as_object(), vm) { + for (obj, hash) in elements { + if self.contains_known_hash(&obj, hash, vm)? { + set.add_known_hash(obj, hash, vm)?; + } + } + return Ok(set); + } for item in other.iter(vm)? { let obj = item?; if self.contains(&obj, vm)? { @@ -271,6 +316,12 @@ impl PySetInner { pub(super) fn difference(&self, other: ArgIterable, vm: &VirtualMachine) -> PyResult { let set = self.copy(); + if let Some(elements) = Self::cached_hashes(other.as_object(), vm) { + for (item, hash) in elements { + set.content.delete_if_exists_known_hash(vm, &*item, hash)?; + } + return Ok(set); + } for item in other.iter(vm)? { set.content.delete_if_exists(vm, &*item?)?; } @@ -284,6 +335,16 @@ impl PySetInner { ) -> PyResult { let new_inner = self.clone(); + if let Some(elements) = Self::cached_hashes(other.as_object(), vm) { + // the source is already duplicate-free + for (item, hash) in elements { + new_inner + .content + .delete_or_insert_known_hash(vm, &item, hash, ())?; + } + return Ok(new_inner); + } + // We want to remove duplicates in other let other_set = Self::from_iter(other.iter(vm)?, vm)?; @@ -333,6 +394,12 @@ impl PySetInner { Self::wrap_unhashable_error(result, &item, vm) } + /// [`Self::add`] with a known hash. + fn add_known_hash(&self, item: PyObjectRef, hash: PyHash, vm: &VirtualMachine) -> PyResult<()> { + let result = self.content.insert_known_hash(vm, &*item, hash, ()); + Self::wrap_unhashable_error(result, &item, vm) + } + fn remove(&self, item: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { let result = self.retry_op_with_frozenset(&item, vm, |item, vm| self.content.delete(vm, item)); @@ -393,15 +460,15 @@ impl PySetInner { } fn merge_set(&self, any_set: AnySet, vm: &VirtualMachine) -> PyResult<()> { - for item in any_set.as_inner().elements() { - self.add(item, vm)?; + for (item, hash) in any_set.as_inner().content.keys_with_hashes() { + self.add_known_hash(item, hash, vm)?; } Ok(()) } fn merge_dict(&self, dict: PyDictRef, vm: &VirtualMachine) -> PyResult<()> { - for (key, _value) in dict { - self.add(key, vm)?; + for (key, hash) in dict._as_dict_inner().keys_with_hashes() { + self.add_known_hash(key, hash, vm)?; } Ok(()) } @@ -413,8 +480,8 @@ impl PySetInner { ) -> PyResult<()> { let temp_inner = self.fold_op(others, Self::intersection, vm)?; self.clear(); - for obj in temp_inner.elements() { - self.add(obj, vm)?; + for (obj, hash) in temp_inner.content.keys_with_hashes() { + self.add_known_hash(obj, hash, vm)?; } Ok(()) } @@ -425,6 +492,12 @@ impl PySetInner { vm: &VirtualMachine, ) -> PyResult<()> { for iterable in others { + if let Some(elements) = Self::cached_hashes(iterable.as_object(), vm) { + for (item, hash) in elements { + self.content.delete_if_exists_known_hash(vm, &*item, hash)?; + } + continue; + } let items = iterable.iter(vm)?.collect::, _>>()?; for item in items { self.content.delete_if_exists(vm, &*item)?; @@ -439,6 +512,14 @@ impl PySetInner { vm: &VirtualMachine, ) -> PyResult<()> { for iterable in others { + if let Some(elements) = Self::cached_hashes(iterable.as_object(), vm) { + // the source is already duplicate-free + for (item, hash) in elements { + self.content + .delete_or_insert_known_hash(vm, &item, hash, ())?; + } + continue; + } // We want to remove duplicates in iterable let iterable_set = Self::from_iter(iterable.iter(vm)?, vm)?; for item in iterable_set.elements() { @@ -955,7 +1036,7 @@ impl Representable for PySet { } impl Constructor for PyFrozenSet { - type Args = Vec; + type Args = OptionalArg; fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { let is_exact_frozenset = cls.is(vm.ctx.types.frozenset_type); @@ -988,11 +1069,11 @@ impl Constructor for PyFrozenSet { return Ok(input.clone()); } - iterable.into_option() + iterable } else { match &args.args[..] { - [] => None, - [iterable] => Some(iterable.clone()), + [] => OptionalArg::Missing, + [iterable] => OptionalArg::Present(iterable.clone()), slice => { return Err(vm.new_type_error(format!( "frozenset expected at most 1 argument, got {}", @@ -1002,23 +1083,25 @@ impl Constructor for PyFrozenSet { } }; - let elements = if let Some(iterable) = iterable_opt { - iterable.try_to_value(vm)? - } else { - vec![] - }; + let payload = Self::py_new(&cls, iterable_opt, vm)?; // Return empty frozenset singleton - if is_exact_frozenset && elements.is_empty() { + if is_exact_frozenset && payload.inner.len() == 0 { return Ok(vm.ctx.empty_frozenset.clone().into()); } - let payload = Self::py_new(&cls, elements, vm)?; payload.into_ref_with_type(vm, cls).map(Into::into) } - fn py_new(_cls: &Py, elements: Self::Args, vm: &VirtualMachine) -> PyResult { - Self::from_iter(vm, elements) + fn py_new(_cls: &Py, iterable: Self::Args, vm: &VirtualMachine) -> PyResult { + let inner = match iterable { + OptionalArg::Present(iterable) => PySetInner::from_object(iterable, vm)?, + OptionalArg::Missing => PySetInner::default(), + }; + Ok(Self { + inner, + ..Default::default() + }) } } diff --git a/crates/vm/src/dict_inner.rs b/crates/vm/src/dict_inner.rs index 3e75e6f27..9dda6194a 100644 --- a/crates/vm/src/dict_inner.rs +++ b/crates/vm/src/dict_inner.rs @@ -463,6 +463,24 @@ impl Dict { K: DictKey + ?Sized, { let hash = key.key_hash(vm)?; + self.insert_known_hash(vm, key, hash, value) + } + + /// Store a key whose hash the caller already knows. + /// + /// `hash` must equal `key.key_hash(vm)`; a wrong one lands the entry in a + /// bucket no lookup probes, silently losing the key. Only pass a hash from + /// [`Self::keys_with_hashes`] on a container holding this same key. + pub(crate) fn insert_known_hash( + &self, + vm: &VirtualMachine, + key: &K, + hash: HashValue, + value: T, + ) -> PyResult<()> + where + K: DictKey + ?Sized, + { let _removed = loop { let (entry_index, index_index) = self.lookup(vm, key, hash, None)?; let mut inner = self.write(); @@ -512,7 +530,18 @@ impl Dict { key: &K, ) -> PyResult { let key_hash = key.key_hash(vm)?; - let (entry, _) = self.lookup(vm, key, key_hash, None)?; + self.contains_known_hash(vm, key, key_hash) + } + + /// [`Self::contains`] with a known hash. Same contract as + /// [`Self::insert_known_hash`]. + pub(crate) fn contains_known_hash( + &self, + vm: &VirtualMachine, + key: &K, + hash: HashValue, + ) -> PyResult { + let (entry, _) = self.lookup(vm, key, hash, None)?; Ok(entry.index().is_some()) } @@ -697,6 +726,21 @@ impl Dict { self.remove_if_exists(vm, key).map(|opt| opt.is_some()) } + /// [`Self::delete_if_exists`] with a known hash. Same contract as + /// [`Self::insert_known_hash`]. + pub(crate) fn delete_if_exists_known_hash( + &self, + vm: &VirtualMachine, + key: &K, + hash: HashValue, + ) -> PyResult + where + K: DictKey + ?Sized, + { + self.remove_if_known_hash(vm, key, hash, |_| Ok(true)) + .map(|opt| opt.is_some()) + } + pub(crate) fn delete_if(&self, vm: &VirtualMachine, key: &K, pred: F) -> PyResult where K: DictKey + ?Sized, @@ -725,6 +769,22 @@ impl Dict { F: Fn(&T) -> PyResult, { let hash = key.key_hash(vm)?; + self.remove_if_known_hash(vm, key, hash, pred) + } + + /// [`Self::remove_if`] with a known hash. Same contract as + /// [`Self::insert_known_hash`]. + fn remove_if_known_hash( + &self, + vm: &VirtualMachine, + key: &K, + hash: HashValue, + pred: F, + ) -> PyResult> + where + K: DictKey + ?Sized, + F: Fn(&T) -> PyResult, + { let removed = loop { let lookup = self.lookup(vm, key, hash, None)?; match self.pop_inner_if(lookup, &pred)? { @@ -742,6 +802,18 @@ impl Dict { value: T, ) -> PyResult<()> { let hash = key.key_hash(vm)?; + self.delete_or_insert_known_hash(vm, key, hash, value) + } + + /// [`Self::delete_or_insert`] with a known hash. Same contract as + /// [`Self::insert_known_hash`]. + pub(crate) fn delete_or_insert_known_hash( + &self, + vm: &VirtualMachine, + key: &PyObject, + hash: HashValue, + value: T, + ) -> PyResult<()> { let _removed = loop { let lookup = self.lookup(vm, key, hash, None)?; let (entry, index_index) = lookup; @@ -888,6 +960,16 @@ impl Dict { .collect() } + /// All keys paired with the hash stored in their entry, for feeding + /// [`Self::insert_known_hash`] without re-calling `__hash__`. + pub(crate) fn keys_with_hashes(&self) -> Vec<(PyObjectRef, HashValue)> { + self.read() + .entries + .iter() + .filter_map(|v| v.as_ref().map(|v| (v.key.clone(), v.hash))) + .collect() + } + pub(crate) fn values(&self) -> Vec { self.read() .entries diff --git a/crates/vm/src/function/protocol.rs b/crates/vm/src/function/protocol.rs index 25ef62b45..d503fabac 100644 --- a/crates/vm/src/function/protocol.rs +++ b/crates/vm/src/function/protocol.rs @@ -86,6 +86,11 @@ unsafe impl Traverse for ArgIterable { } impl ArgIterable { + #[must_use] + pub(crate) fn as_object(&self) -> &PyObject { + &self.iterable + } + /// Returns an iterator over this sequence of objects. /// /// This operation may fail if an exception is raised while invoking the From dc19be06f2b4007ca4cec44055cff96eb2e57840 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:22:05 +0900 Subject: [PATCH 52/94] Fix __qualname__ of compiler-generated __annotate__ functions (#8498) Assisted-by: Codex:5.6-sol --- Lib/test/test_type_annotations.py | 1 - crates/codegen/src/compile.rs | 64 ++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/Lib/test/test_type_annotations.py b/Lib/test/test_type_annotations.py index 3f056f2b7..c98b99e98 100644 --- a/Lib/test/test_type_annotations.py +++ b/Lib/test/test_type_annotations.py @@ -843,7 +843,6 @@ class RegressionTests(unittest.TestCase): lamb = list(genexp)[0] self.assertEqual(lamb(), 42) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: '__annotate__' != 'f.__annotate__' def test_annotate_qualname(self): code = """ def f() -> None: diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index ba2d41f12..b16540762 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -2175,7 +2175,7 @@ impl<'warnings> Compiler<'warnings> { /// On success, returns the saved CompileContext to pass to exit_annotation_scope. fn enter_annotation_scope( &mut self, - _func_name: &str, + func_name: &str, loc: TextRange, ) -> CompileResult> { if !self.push_annotation_symbol_table() { @@ -2200,6 +2200,12 @@ impl<'warnings> Compiler<'warnings> { lineno.to_u32(), )?; + // enter_scope() qualified the scope by the enclosing scope only; redo it + // now that the annotated function is known. Only signature annotations + // get this treatment - deferred class and module annotations are + // compiled inside the scope they belong to and are already qualified. + self.set_annotation_qualname(func_name); + // Keep the internal ".format" name; exit_annotation_scope() // renames it to "format" on the final code object. self.current_code_info() @@ -2605,11 +2611,24 @@ impl<'warnings> Compiler<'warnings> { /// Set the qualified name for the current code object // = compiler_set_qualname fn set_qualname(&mut self) -> String { - let qualname = self.make_qualname(); + self.set_qualname_for_function(None) + } + + /// Set the qualname of an annotation scope, qualified by the function whose + /// signature it annotates. CPython records that name on the annotation + /// block's symbol table entry (`ste_function_name`) and folds it into the + /// qualname, so `f`'s annotation scope is named `f.__annotate__`. + fn set_annotation_qualname(&mut self, function_name: &str) { + self.set_qualname_for_function(Some(function_name)); + } + + fn set_qualname_for_function(&mut self, function_name: Option<&str>) -> String { + let qualname = self.make_qualname(function_name); self.current_code_info().metadata.qualname = Some(qualname.clone()); qualname } - fn make_qualname(&mut self) -> String { + + fn make_qualname(&mut self, function_name: Option<&str>) -> String { let stack_size = self.code_stack.len(); assert!(stack_size >= 1); @@ -2693,10 +2712,10 @@ impl<'warnings> Compiler<'warnings> { } } - // Build the qualified name - if force_global { + // Build the prefix the current name is qualified by, if any + let base = if force_global { // For global symbols, qualname is just the name - current_obj_name + None } else { // Check parent scope type let parent_obj_name = &parent.metadata.name; @@ -2709,23 +2728,32 @@ impl<'warnings> Compiler<'warnings> { ) ); + // Use parent's qualname if available, otherwise use parent_obj_name + let parent_qualname = parent.metadata.qualname.as_ref().unwrap_or(parent_obj_name); + if is_function_parent { // For functions, append . to parent qualname - // Use parent's qualname if available, otherwise use parent_obj_name - let parent_qualname = parent.metadata.qualname.as_ref().unwrap_or(parent_obj_name); - format!("{parent_qualname}..{current_obj_name}") + Some(format!("{parent_qualname}.")) + } else if parent_qualname == "" { + // Module level, nothing to qualify by + None } else { // For classes and other scopes, use parent's qualname directly - // Use parent's qualname if available, otherwise use parent_obj_name - let parent_qualname = parent.metadata.qualname.as_ref().unwrap_or(parent_obj_name); - if parent_qualname == "" { - // Module level, just use the name - current_obj_name - } else { - // Concatenate parent qualname with current name - format!("{parent_qualname}.{current_obj_name}") - } + Some(parent_qualname.clone()) } + }; + + // An annotation scope is compiled in the scope enclosing the function it + // annotates, so the function itself is missing from the prefix above. + let base = match (base, function_name) { + (Some(base), Some(function_name)) => Some(format!("{base}.{function_name}")), + (None, Some(function_name)) => Some(function_name.to_owned()), + (base, None) => base, + }; + + match base { + Some(base) => format!("{base}.{current_obj_name}"), + None => current_obj_name, } } From 980be72e4f412deb95ec41e34bf77d2764a8f199 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:24:44 +0900 Subject: [PATCH 53/94] jit: preserve return types for recursive calls (#8499) Assisted-by: Codex:5.6-sol --- crates/jit/src/instructions.rs | 18 ++++++++++++++++-- crates/jit/tests/bool_tests.rs | 14 ++++++++++++++ crates/jit/tests/float_tests.rs | 14 ++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/jit/src/instructions.rs b/crates/jit/src/instructions.rs index 67cf07f6e..a3c4ca800 100644 --- a/crates/jit/src/instructions.rs +++ b/crates/jit/src/instructions.rs @@ -548,8 +548,22 @@ impl<'a, 'b> FunctionCompiler<'a, 'b> { match self.stack.pop().ok_or(JitCompileError::BadBytecode)? { JitValue::FuncRef(reference) => { let call = self.builder.ins().call(reference, &args); - let returns = self.builder.inst_results(call); - self.stack.push(JitValue::Int(returns[0])); + // The only callable reachable here is this function itself, + // so the result carries the declared return type - it is not + // always an Int. A function whose return type is still + // unknown has no return slot in the signature it was + // declared with, and there is nothing to type the result as. + let ret = match *self.builder.inst_results(call) { + [] => None, + [val] => Some(val), + _ => return Err(JitCompileError::NotSupported), + }; + let val = match (self.sig.ret.clone(), ret) { + (Some(JitType::None), None) => JitValue::None, + (Some(ty), Some(val)) => JitValue::from_type_and_value(ty, val), + _ => return Err(JitCompileError::NotSupported), + }; + self.stack.push(val); Ok(()) } diff --git a/crates/jit/tests/bool_tests.rs b/crates/jit/tests/bool_tests.rs index 8a5f4ea9d..1874ee4d5 100644 --- a/crates/jit/tests/bool_tests.rs +++ b/crates/jit/tests/bool_tests.rs @@ -202,4 +202,18 @@ mod tests { assert_eq!(lte(false, 1), Ok(1)); assert_eq!(lte(true, 0), Ok(0)); } + + #[test] + fn recursive_bool() { + let recursive_bool = jit_function! { recursive_bool(n: i64) -> bool => r##" + def recursive_bool(n: int) -> bool: + if n == 0: + return True + return not recursive_bool(n - 1) + "## }; + + assert_eq!(recursive_bool(0), Ok(true)); + assert_eq!(recursive_bool(1), Ok(false)); + assert_eq!(recursive_bool(4), Ok(true)); + } } diff --git a/crates/jit/tests/float_tests.rs b/crates/jit/tests/float_tests.rs index b9bbb3ea6..f667b1e76 100644 --- a/crates/jit/tests/float_tests.rs +++ b/crates/jit/tests/float_tests.rs @@ -379,4 +379,18 @@ mod tests { assert_eq!(float_lte(f64::NAN, f64::NAN), Ok(false)); assert_eq!(float_lte(f64::INFINITY, f64::NEG_INFINITY), Ok(false)); } + + #[test] + fn recursive_float() { + let recursive_float = jit_function! { recursive_float(n: i64) -> f64 => r##" + def recursive_float(n: int) -> float: + if n == 0: + return 1.0 + return recursive_float(n - 1) / 2.0 + "## }; + + assert_eq!(recursive_float(0), Ok(1.0)); + assert_eq!(recursive_float(1), Ok(0.5)); + assert_eq!(recursive_float(4), Ok(0.0625)); + } } From 84a0ba786423c0e3f760c68290ec8c9848591e4e Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:28:09 +0900 Subject: [PATCH 54/94] Fix marshal recursive reference loading (#8501) * Fix marshal recursive reference loading Create reference-tracked containers before reading their children so recursive list, dict, set, and tuple graphs can be unmarshaled. Preserve interned string markers through the runtime bag and add an initialization-only tuple construction path. Assisted-by: Codex:gpt-5 * Preserve marshal container insertion errors Keep Python exceptions raised while constructing unmarshaled sets, frozensets, and dictionaries instead of collapsing them into ValueError. This makes abnormal recursive hash-container streams report TypeError like CPython and removes the remaining test_marshal expected failure. Assisted-by: Codex:gpt-5 * Enable full abnormal marshal reference loop test * Test direct marshal tuple reference loop Assisted-by: Codex:gpt-5 --- Lib/test/test_marshal.py | 21 ++-- crates/compiler-core/src/marshal.rs | 168 ++++++++++++++++++++++++---- crates/vm/src/builtins/tuple.rs | 101 ++++++++++++++--- crates/vm/src/stdlib/marshal.rs | 165 +++++++++++++++++++++------ 4 files changed, 366 insertions(+), 89 deletions(-) diff --git a/Lib/test/test_marshal.py b/Lib/test/test_marshal.py index ad4c6095a..1f04a7f69 100644 --- a/Lib/test/test_marshal.py +++ b/Lib/test/test_marshal.py @@ -319,7 +319,6 @@ class BugsTestCase(unittest.TestCase): last.append([0]) self.assertRaises(ValueError, marshal.dumps, head) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_reference_loop_list(self): a = [] a.append(a) @@ -331,7 +330,6 @@ class BugsTestCase(unittest.TestCase): self.assertIsInstance(b, list) self.assertIs(b[0], b) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_reference_loop_dict(self): a = {} a[None] = a @@ -343,7 +341,6 @@ class BugsTestCase(unittest.TestCase): self.assertIsInstance(b, dict) self.assertIs(b[None], b) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_reference_loop_tuple(self): a = ([],) a[0].append(a) @@ -387,21 +384,18 @@ class BugsTestCase(unittest.TestCase): for v in range(marshal.version + 1): self.assertRaises(ValueError, marshal.dumps, a, v) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_loads_reference_loop_list(self): data = b'\xdb\x01\x00\x00\x00r\x00\x00\x00\x00' # [] a = marshal.loads(data) self.assertIsInstance(a, list) self.assertIs(a[0], a) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_loads_reference_loop_dict(self): data = b'\xfbNr\x00\x00\x00\x000' # {None: } a = marshal.loads(data) self.assertIsInstance(a, dict) self.assertIs(a[None], a) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_loads_abnormal_reference_loops(self): # Indirect self-references of tuples. data = b'\xa8\x01\x00\x00\x00[\x01\x00\x00\x00r\x00\x00\x00\x00' # ([],) @@ -416,13 +410,13 @@ class BugsTestCase(unittest.TestCase): self.assertIsInstance(a[0], dict) self.assertIs(a[0][None], a) - # Direct self-reference which cannot be created in Python. - # This creates a reference loop which cannot be collected. - if False: - data = b'\xa8\x01\x00\x00\x00r\x00\x00\x00\x00' # (,) - a = marshal.loads(data) - self.assertIsInstance(a, tuple) - self.assertIs(a[0], a) + # Direct self-reference which cannot be created in Python. CPython + # leaves this disabled because its reference counting cannot collect + # the resulting cycle; RustPython's tracing collector can. + data = b'\xa8\x01\x00\x00\x00r\x00\x00\x00\x00' # (,) + a = marshal.loads(data) + self.assertIsInstance(a, tuple) + self.assertIs(a[0], a) # Direct self-references which cannot be created in Python # because of unhashability. @@ -748,7 +742,6 @@ class InterningTestCase(unittest.TestCase, HelperMixin): strobj = "this is an interned string" strobj = sys.intern(strobj) - @unittest.expectedFailure # TODO: RUSTPYTHON def testIntern(self): s = marshal.loads(marshal.dumps(self.strobj)) self.assertEqual(s, self.strobj) diff --git a/crates/compiler-core/src/marshal.rs b/crates/compiler-core/src/marshal.rs index a2a23054e..dd5d4f2cd 100644 --- a/crates/compiler-core/src/marshal.rs +++ b/crates/compiler-core/src/marshal.rs @@ -516,7 +516,7 @@ fn read_const_value( let code = deserialize_code_inner(rdr, bag, depth - 1, refs)?; bag.make_code(code) } else { - deserialize_value_typed(rdr, bag, depth, refs, typ)? + deserialize_value_typed(rdr, bag, depth, refs, typ, slot)? }; if let Some(idx) = slot { refs[idx] = Some(value.clone()); @@ -540,6 +540,10 @@ pub trait MarshalBag: Copy { fn make_str(&self, value: &Wtf8) -> Self::Value; + fn make_interned_str(&self, value: &Wtf8) -> Self::Value { + self.make_str(value) + } + fn make_bytes(&self, value: &[u8]) -> Self::Value; fn make_int(&self, value: BigInt) -> Self::Value; @@ -564,6 +568,51 @@ pub trait MarshalBag: Copy { it: impl Iterator, ) -> Result; + /// Install partially-built containers in the marshal reference table + /// before reading their children, as CPython's `r_object()` does. + /// Runtime bags can opt in; constant bags retain collect-then-construct. + fn make_tuple_placeholder(&self, _len: usize) -> Option { + None + } + + fn set_tuple_item( + &self, + _tuple: &Self::Value, + _index: usize, + _value: Self::Value, + ) -> Result<()> { + Err(MarshalError::BadType) + } + + fn make_list_placeholder(&self, _len: usize) -> Option { + None + } + + fn set_list_item(&self, _list: &Self::Value, _index: usize, _value: Self::Value) -> Result<()> { + Err(MarshalError::BadType) + } + + fn make_set_placeholder(&self) -> Option { + None + } + + fn insert_set_item(&self, _set: &Self::Value, _value: Self::Value) -> Result<()> { + Err(MarshalError::BadType) + } + + fn make_dict_placeholder(&self) -> Option { + None + } + + fn insert_dict_item( + &self, + _dict: &Self::Value, + _key: Self::Value, + _value: Self::Value, + ) -> Result<()> { + Err(MarshalError::BadType) + } + fn make_slice( &self, _start: Self::Value, @@ -755,7 +804,7 @@ fn deserialize_value_after_header( let code = deserialize_code_inner(rdr, bag.constant_bag(), depth - 1, &mut inner_refs)?; bag.make_code(code) } else { - deserialize_value_typed(rdr, bag, depth, refs, typ)? + deserialize_value_typed(rdr, bag, depth, refs, typ, slot)? }; if let Some(idx) = slot { @@ -770,6 +819,7 @@ fn deserialize_value_typed( depth: usize, refs: &mut Vec>, typ: Type, + slot: Option, ) -> Result { if depth == 0 { return Err(MarshalError::InvalidBytecode); @@ -806,21 +856,42 @@ fn deserialize_value_typed( let value = Complex64 { re, im }; bag.make_complex(value) } - Type::Ascii | Type::AsciiInterned | Type::Unicode | Type::Interned => { + Type::Ascii | Type::Unicode => { let len = rdr.read_u32()?; let value = rdr.read_wtf8(len)?; bag.make_str(value) } - Type::ShortAscii | Type::ShortAsciiInterned => { + Type::AsciiInterned | Type::Interned => { + let len = rdr.read_u32()?; + let value = rdr.read_wtf8(len)?; + bag.make_interned_str(value) + } + Type::ShortAscii => { let len = rdr.read_u8()? as u32; let value = rdr.read_wtf8(len)?; bag.make_str(value) } + Type::ShortAsciiInterned => { + let len = rdr.read_u8()? as u32; + let value = rdr.read_wtf8(len)?; + bag.make_interned_str(value) + } Type::SmallTuple => { let len = rdr.read_u8()? as usize; let d = depth - 1; - let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); - itertools::process_results(it, |it| bag.make_tuple(it))? + if let Some(index) = slot + && let Some(tuple) = bag.make_tuple_placeholder(len) + { + refs[index] = Some(tuple.clone()); + for item_index in 0..len { + let item = deserialize_value_depth(rdr, bag, d, refs)?; + bag.set_tuple_item(&tuple, item_index, item)?; + } + tuple + } else { + let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); + itertools::process_results(it, |it| bag.make_tuple(it))? + } } Type::Null => { return Err(MarshalError::BadType); @@ -830,22 +901,55 @@ fn deserialize_value_typed( return Err(MarshalError::BadType); } Type::Tuple => { - let len = rdr.read_u32()?; + let len = rdr.read_u32()? as usize; let d = depth - 1; - let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); - itertools::process_results(it, |it| bag.make_tuple(it))? + if let Some(index) = slot + && let Some(tuple) = bag.make_tuple_placeholder(len) + { + refs[index] = Some(tuple.clone()); + for item_index in 0..len { + let item = deserialize_value_depth(rdr, bag, d, refs)?; + bag.set_tuple_item(&tuple, item_index, item)?; + } + tuple + } else { + let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); + itertools::process_results(it, |it| bag.make_tuple(it))? + } } Type::List => { - let len = rdr.read_u32()?; + let len = rdr.read_u32()? as usize; let d = depth - 1; - let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); - itertools::process_results(it, |it| bag.make_list(it))?? + if let Some(index) = slot + && let Some(list) = bag.make_list_placeholder(len) + { + refs[index] = Some(list.clone()); + for item_index in 0..len { + let item = deserialize_value_depth(rdr, bag, d, refs)?; + bag.set_list_item(&list, item_index, item)?; + } + list + } else { + let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); + itertools::process_results(it, |it| bag.make_list(it))?? + } } Type::Set => { - let len = rdr.read_u32()?; + let len = rdr.read_u32()? as usize; let d = depth - 1; - let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); - itertools::process_results(it, |it| bag.make_set(it))?? + if let Some(index) = slot + && let Some(set) = bag.make_set_placeholder() + { + refs[index] = Some(set.clone()); + for _ in 0..len { + let item = deserialize_value_depth(rdr, bag, d, refs)?; + bag.insert_set_item(&set, item)?; + } + set + } else { + let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); + itertools::process_results(it, |it| bag.make_set(it))?? + } } Type::FrozenSet => { let len = rdr.read_u32()?; @@ -855,17 +959,33 @@ fn deserialize_value_typed( } Type::Dict => { let d = depth - 1; - let mut pairs = Vec::new(); - loop { - let raw = rdr.read_u8()?; - if raw & !FLAG_REF == b'0' { - break; + if let Some(index) = slot + && let Some(dict) = bag.make_dict_placeholder() + { + refs[index] = Some(dict.clone()); + loop { + let raw = rdr.read_u8()?; + if raw & !FLAG_REF == b'0' { + break; + } + let key = deserialize_value_after_header(rdr, bag, d, refs, raw)?; + let value = deserialize_value_depth(rdr, bag, d, refs)?; + bag.insert_dict_item(&dict, key, value)?; } - let k = deserialize_value_after_header(rdr, bag, d, refs, raw)?; - let v = deserialize_value_depth(rdr, bag, d, refs)?; - pairs.push((k, v)); + dict + } else { + let mut pairs = Vec::new(); + loop { + let raw = rdr.read_u8()?; + if raw & !FLAG_REF == b'0' { + break; + } + let key = deserialize_value_after_header(rdr, bag, d, refs, raw)?; + let value = deserialize_value_depth(rdr, bag, d, refs)?; + pairs.push((key, value)); + } + bag.make_dict(pairs.into_iter())? } - bag.make_dict(pairs.into_iter())? } Type::Bytes => { // After marshaling, byte arrays are converted into bytes. diff --git a/crates/vm/src/builtins/tuple.rs b/crates/vm/src/builtins/tuple.rs index d48639b2c..06fa25192 100644 --- a/crates/vm/src/builtins/tuple.rs +++ b/crates/vm/src/builtins/tuple.rs @@ -23,12 +23,60 @@ use crate::{ vm::VirtualMachine, }; use alloc::fmt; -use core::cell::Cell; +use core::cell::{Cell, UnsafeCell}; use core::ptr::NonNull; #[pyclass(module = false, name = "tuple", traverse = "manual")] pub struct PyTuple { - elements: Box<[R]>, + elements: TupleElements, +} + +/// Tuple storage is immutable after publication, but marshal must publish a +/// tuple in its reference table before recursively reading its children. +/// This mirrors CPython's `PyTuple_New` followed by `PyTuple_SET_ITEM`. +struct TupleElements(UnsafeCell>); + +unsafe impl Send for TupleElements {} +unsafe impl Sync for TupleElements {} + +impl TupleElements { + const fn new(elements: Box<[R]>) -> Self { + Self(UnsafeCell::new(elements)) + } + + fn as_slice(&self) -> &[R] { + // SAFETY: initialization writes happen only while the tuple is owned by + // the synchronous marshal decoder; afterwards the storage is immutable. + unsafe { &*self.0.get() } + } + + fn get_mut(&mut self) -> &mut Box<[R]> { + self.0.get_mut() + } + + /// # Safety + /// The tuple must still be in its private initialization phase, and each + /// placeholder index must be replaced at most once before it is observable. + unsafe fn set_initializing(&self, index: usize, value: R) { + unsafe { (*self.0.get())[index] = value }; + } +} + +impl core::ops::Deref for TupleElements { + type Target = [R]; + + fn deref(&self) -> &Self::Target { + self.as_slice() + } +} + +impl<'a, R> IntoIterator for &'a TupleElements { + type Item = &'a R; + type IntoIter = core::slice::Iter<'a, R>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } } impl fmt::Debug for PyTuple { @@ -42,11 +90,11 @@ impl fmt::Debug for PyTuple { // Note: Only impl for PyTuple (the default) unsafe impl Traverse for PyTuple { fn traverse(&self, traverse_fn: &mut TraverseFn<'_>) { - self.elements.traverse(traverse_fn); + self.elements.as_slice().traverse(traverse_fn); } fn clear(&mut self, out: &mut Vec) { - let elements = core::mem::take(&mut self.elements); + let elements = core::mem::take(self.elements.get_mut()); out.extend(elements.into_vec()); } } @@ -206,7 +254,7 @@ impl Constructor for PyTuple { fn py_new(_cls: &Py, elements: Self::Args, _vm: &VirtualMachine) -> PyResult { Ok(Self { - elements: elements.into_boxed_slice(), + elements: TupleElements::new(elements.into_boxed_slice()), }) } } @@ -245,19 +293,19 @@ impl<'a, R> core::iter::IntoIterator for &'a Py> { impl PyTuple { #[must_use] - pub const fn as_slice(&self) -> &[R] { + pub fn as_slice(&self) -> &[R] { &self.elements } #[inline] #[must_use] - pub const fn len(&self) -> usize { + pub fn len(&self) -> usize { self.elements.len() } #[inline] #[must_use] - pub const fn is_empty(&self) -> bool { + pub fn is_empty(&self) -> bool { self.elements.is_empty() } @@ -274,7 +322,13 @@ impl PyTuple { ctx.empty_tuple.clone() } else { let elements = elements.into_boxed_slice(); - PyRef::new_ref(Self { elements }, ctx.types.tuple_type.to_owned(), None) + PyRef::new_ref( + Self { + elements: TupleElements::new(elements), + }, + ctx.types.tuple_type.to_owned(), + None, + ) } } @@ -283,7 +337,20 @@ impl PyTuple { /// Calling this function implies trying micro optimization for non-zero-sized tuple. #[must_use] pub const fn new_unchecked(elements: Box<[PyObjectRef]>) -> Self { - Self { elements } + Self { + elements: TupleElements::new(elements), + } + } + + pub(crate) fn new_marshal_placeholder(len: usize, ctx: &Context) -> PyRef { + Self::new_ref(vec![ctx.none(); len], ctx) + } + + /// # Safety + /// This tuple must be a marshal placeholder which has not escaped the + /// decoder, and `index` must not have been replaced previously. + pub(crate) unsafe fn set_marshal_item(&self, index: usize, value: PyObjectRef) { + unsafe { self.elements.set_initializing(index, value) }; } fn repeat(zelf: PyRef, value: isize, vm: &VirtualMachine) -> PyResult> { @@ -298,7 +365,10 @@ impl PyTuple { } else { let v = zelf.elements.mul(vm, value)?; let elements = v.into_boxed_slice(); - Self { elements }.into_ref(&vm.ctx) + Self { + elements: TupleElements::new(elements), + } + .into_ref(&vm.ctx) }) } @@ -341,7 +411,10 @@ impl PyTuple { .chain(other.as_slice()) .cloned() .collect::>(); - Self { elements }.into_ref(&vm.ctx) + Self { + elements: TupleElements::new(elements), + } + .into_ref(&vm.ctx) } }); PyArithmeticValue::from_option(added.ok()) @@ -360,7 +433,7 @@ impl PyTuple { #[inline] #[must_use] - pub const fn __len__(&self) -> usize { + pub fn __len__(&self) -> usize { self.elements.len() } @@ -425,7 +498,7 @@ impl PyTuple { let tup_arg = if zelf.class().is(vm.ctx.types.tuple_type) { zelf } else { - Self::new_ref(zelf.elements.clone().into_vec(), &vm.ctx) + Self::new_ref(zelf.elements.as_slice().to_vec(), &vm.ctx) }; (tup_arg,) } diff --git a/crates/vm/src/stdlib/marshal.rs b/crates/vm/src/stdlib/marshal.rs index ace3aff58..a4665d0f5 100644 --- a/crates/vm/src/stdlib/marshal.rs +++ b/crates/vm/src/stdlib/marshal.rs @@ -9,14 +9,16 @@ mod decl { use crate::{ PyObjectRef, PyResult, TryFromObject, VirtualMachine, builtins::{ - PyBool, PyByteArray, PyBytes, PyCode, PyComplex, PyDict, PyEllipsis, PyFloat, - PyFrozenSet, PyInt, PyList, PyNone, PySet, PyStopIteration, PyStr, PyTuple, + PyBaseExceptionRef, PyBool, PyByteArray, PyBytes, PyCode, PyComplex, PyDict, + PyEllipsis, PyFloat, PyFrozenSet, PyInt, PyList, PyNone, PySet, PyStopIteration, PyStr, + PyTuple, }, convert::ToPyObject, function::{ArgBytesLike, OptionalArg}, object::{AsObject, PyPayload}, protocol::PyBuffer, }; + use core::cell::RefCell; use malachite_bigint::BigInt; use num_traits::Zero; use rustpython_compiler_core::marshal::{self, DumpableValue}; @@ -386,79 +388,165 @@ mod decl { } #[derive(Copy, Clone)] - struct PyMarshalBag<'a>(&'a VirtualMachine); + struct PyMarshalBag<'a> { + vm: &'a VirtualMachine, + pending_error: &'a RefCell>, + } + + impl<'a> PyMarshalBag<'a> { + fn new( + vm: &'a VirtualMachine, + pending_error: &'a RefCell>, + ) -> Self { + Self { vm, pending_error } + } + + fn remember_python_error(&self, error: PyBaseExceptionRef) -> marshal::MarshalError { + let mut pending = self.pending_error.borrow_mut(); + if pending.is_none() { + *pending = Some(error); + } + marshal::MarshalError::BadType + } + } impl<'a> marshal::MarshalBag for PyMarshalBag<'a> { type Value = PyObjectRef; type ConstantBag = PyVmBag<'a>; fn make_bool(&self, value: bool) -> Self::Value { - self.0.ctx.new_bool(value).into() + self.vm.ctx.new_bool(value).into() } fn make_none(&self) -> Self::Value { - self.0.ctx.none() + self.vm.ctx.none() } fn make_ellipsis(&self) -> Self::Value { - self.0.ctx.ellipsis.clone().into() + self.vm.ctx.ellipsis.clone().into() } fn make_float(&self, value: f64) -> Self::Value { - self.0.ctx.new_float(value).into() + self.vm.ctx.new_float(value).into() } fn make_complex(&self, value: num_complex::Complex64) -> Self::Value { - self.0.ctx.new_complex(value).into() + self.vm.ctx.new_complex(value).into() } fn make_str(&self, value: &Wtf8) -> Self::Value { - self.0.ctx.new_str(value).into() + self.vm.ctx.new_str(value).into() + } + fn make_interned_str(&self, value: &Wtf8) -> Self::Value { + self.vm.ctx.intern_str(value).to_owned().into() } fn make_bytes(&self, value: &[u8]) -> Self::Value { - self.0.ctx.new_bytes(value.to_vec()).into() + self.vm.ctx.new_bytes(value.to_vec()).into() } fn make_int(&self, value: BigInt) -> Self::Value { - self.0.ctx.new_int(value).into() + self.vm.ctx.new_int(value).into() } fn make_tuple(&self, elements: impl Iterator) -> Self::Value { - self.0.ctx.new_tuple(elements.collect()).into() + self.vm.ctx.new_tuple(elements.collect()).into() + } + fn make_tuple_placeholder(&self, len: usize) -> Option { + Some(PyTuple::new_marshal_placeholder(len, &self.vm.ctx).into()) + } + fn set_tuple_item( + &self, + tuple: &Self::Value, + index: usize, + value: Self::Value, + ) -> Result<(), marshal::MarshalError> { + let tuple = tuple + .downcast_ref::() + .ok_or(marshal::MarshalError::BadType)?; + // SAFETY: compiler-core calls this only on a fresh placeholder, + // once per index, before returning it to Python code. + unsafe { tuple.set_marshal_item(index, value) }; + Ok(()) } fn make_code(&self, code: CodeObject) -> Self::Value { - crate::builtins::PyCode::new_ref_with_bag(self.0, code).into() + crate::builtins::PyCode::new_ref_with_bag(self.vm, code).into() } fn make_stop_iter(&self) -> Result { - Ok(self.0.ctx.exceptions.stop_iteration.to_owned().into()) + Ok(self.vm.ctx.exceptions.stop_iteration.to_owned().into()) } fn make_list( &self, it: impl Iterator, ) -> Result { - Ok(self.0.ctx.new_list(it.collect()).into()) + Ok(self.vm.ctx.new_list(it.collect()).into()) + } + fn make_list_placeholder(&self, len: usize) -> Option { + Some(self.vm.ctx.new_list(vec![self.vm.ctx.none(); len]).into()) + } + fn set_list_item( + &self, + list: &Self::Value, + index: usize, + value: Self::Value, + ) -> Result<(), marshal::MarshalError> { + let list = list + .downcast_ref::() + .ok_or(marshal::MarshalError::BadType)?; + list.borrow_vec_mut()[index] = value; + Ok(()) } fn make_set( &self, it: impl Iterator, ) -> Result { - let set = PySet::default().into_ref(&self.0.ctx); + let set = PySet::default().into_ref(&self.vm.ctx); for elem in it { - set.add(elem, self.0).unwrap() + set.add(elem, self.vm) + .map_err(|error| self.remember_python_error(error))?; } Ok(set.into()) } + fn make_set_placeholder(&self) -> Option { + Some(PySet::default().into_ref(&self.vm.ctx).into()) + } + fn insert_set_item( + &self, + set: &Self::Value, + value: Self::Value, + ) -> Result<(), marshal::MarshalError> { + let set = set + .downcast_ref::() + .ok_or(marshal::MarshalError::BadType)?; + set.add(value, self.vm) + .map_err(|error| self.remember_python_error(error)) + } fn make_frozenset( &self, it: impl Iterator, ) -> Result { - Ok(PyFrozenSet::from_iter(self.0, it) - .unwrap() - .to_pyobject(self.0)) + PyFrozenSet::from_iter(self.vm, it) + .map(|set| set.to_pyobject(self.vm)) + .map_err(|error| self.remember_python_error(error)) } fn make_dict( &self, it: impl Iterator, ) -> Result { - let dict = self.0.ctx.new_dict(); + let dict = self.vm.ctx.new_dict(); for (k, v) in it { - dict.set_item(&*k, v, self.0).unwrap() + dict.set_item(&*k, v, self.vm) + .map_err(|error| self.remember_python_error(error))?; } Ok(dict.into()) } + fn make_dict_placeholder(&self) -> Option { + Some(self.vm.ctx.new_dict().into()) + } + fn insert_dict_item( + &self, + dict: &Self::Value, + key: Self::Value, + value: Self::Value, + ) -> Result<(), marshal::MarshalError> { + let dict = dict + .downcast_ref::() + .ok_or(marshal::MarshalError::BadType)?; + dict.set_item(&*key, value, self.vm) + .map_err(|error| self.remember_python_error(error)) + } fn make_slice( &self, start: Self::Value, @@ -466,7 +554,7 @@ mod decl { step: Self::Value, ) -> Result { use crate::builtins::PySlice; - let vm = self.0; + let vm = self.vm; Ok(PySlice { start: if vm.is_none(&start) { None @@ -480,7 +568,21 @@ mod decl { .into()) } fn constant_bag(self) -> Self::ConstantBag { - PyVmBag(self.0) + PyVmBag(self.vm) + } + } + + fn deserialize_value( + rdr: &mut impl marshal::Read, + vm: &VirtualMachine, + ) -> PyResult { + let pending_error = RefCell::new(None); + match marshal::deserialize_value(rdr, PyMarshalBag::new(vm, &pending_error)) { + Ok(value) => Ok(value), + Err(error) => Err(pending_error.into_inner().unwrap_or_else(|| match error { + marshal::MarshalError::Eof => vm.new_eof_error("marshal data too short"), + _ => vm.new_value_error("bad marshal data"), + })), } } @@ -502,11 +604,7 @@ mod decl { vm.new_buffer_error("Buffer provided to marshal.loads() is not contiguous") })?; - let result = - marshal::deserialize_value(&mut &buf[..], PyMarshalBag(vm)).map_err(|e| match e { - marshal::MarshalError::Eof => vm.new_eof_error("marshal data too short"), - _ => vm.new_value_error("bad marshal data"), - })?; + let result = deserialize_value(&mut &buf[..], vm)?; if !allow_code { check_no_code(&result, vm)?; } @@ -534,14 +632,7 @@ mod decl { let mut rdr: &[u8] = &buf; let len_before = rdr.len(); - let result = - marshal::deserialize_value(&mut rdr, PyMarshalBag(vm)).map_err(|e| match e { - marshal::MarshalError::Eof => vm.new_exception_msg( - vm.ctx.exceptions.eof_error.to_owned(), - "marshal data too short".into(), - ), - _ => vm.new_value_error("bad marshal data"), - })?; + let result = deserialize_value(&mut rdr, vm)?; let consumed = len_before - rdr.len(); // Seek file to just after the consumed bytes From 161ba746fc806fdc66341153ef6ae12c2a0cf529 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:55:23 +0900 Subject: [PATCH 55/94] ssl: pass test_ssl with the rustls backend (#8502) * Fix rustls test_ssl compatibility Assisted-by: OpenAI Codex:GPT-5 * Keep urllib3 compatible SSL version prefix Assisted-by: OpenAI Codex:GPT-5 --- crates/stdlib/src/ssl.rs | 16 ++++--- crates/stdlib/src/ssl/cert.rs | 40 +++++++++++------ crates/vm/src/stdlib/_thread.rs | 76 ++++++++++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 04b905d54..18d171a85 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -323,15 +323,17 @@ mod _ssl { #[pyattr] const ALERT_DESCRIPTION_NO_APPLICATION_PROTOCOL: i32 = 120; - // Version info - reporting as OpenSSL 3.3.0 for compatibility + // `ssl.py` still requires OpenSSL-shaped numeric compatibility fields even + // for non-OpenSSL TLS providers. Keep them in the supported 3.x ABI range, + // but report the actual rustls/AWS-LC backend in the human-readable string. #[pyattr] - const OPENSSL_VERSION_NUMBER: i32 = 0x30300000; // OpenSSL 3.3.0 (808452096) + const OPENSSL_VERSION_NUMBER: i32 = 0x30300000; #[pyattr] - const OPENSSL_VERSION: &str = "OpenSSL 3.3.0 (rustls/0.23)"; + const OPENSSL_VERSION: &str = "OpenSSL 3.3.0-compatible (AWS-LC/rustls 0.23)"; #[pyattr] - const OPENSSL_VERSION_INFO: (i32, i32, i32, i32, i32) = (3, 3, 0, 0, 15); // 3.3.0 release + const OPENSSL_VERSION_INFO: (i32, i32, i32, i32, i32) = (3, 3, 0, 0, 15); #[pyattr] - const _OPENSSL_API_VERSION: (i32, i32, i32, i32, i32) = (3, 3, 0, 0, 15); // 3.3.0 release + const _OPENSSL_API_VERSION: (i32, i32, i32, i32, i32) = (3, 3, 0, 0, 15); // Default cipher list for rustls - using modern secure ciphers #[pyattr] @@ -2816,8 +2818,8 @@ mod _ssl { super::compat::SslError::create_ssl_error_with_reason( vm, Some("SSL"), - "CALLBACK_FAILED", - "[SSL: CALLBACK_FAILED] callback failed", + "PARSE_TLSEXT", + "[SSL: PARSE_TLSEXT] SNI callback owner is no longer available", ) })?; let server_name_py: PyObjectRef = match sni_name { diff --git a/crates/stdlib/src/ssl/cert.rs b/crates/stdlib/src/ssl/cert.rs index f12f43072..47d11f730 100644 --- a/crates/stdlib/src/ssl/cert.rs +++ b/crates/stdlib/src/ssl/cert.rs @@ -287,9 +287,11 @@ pub(super) fn is_ca_certificate(cert_der: &[u8]) -> bool { return ext.value.ca; } - // No Basic Constraints extension -> NOT a CA certificate - // (matches OpenSSL X509_check_ca() behavior) - false + // X509_check_ca() also retains OpenSSL's legacy trust-anchor rule: a + // self-issued X.509v1 certificate has no extensions at all, but is still + // classified as a CA. CPython's test CA at capath/4e1295a3.0 exercises + // precisely this case. + cert.version().0 == 0 && cert.subject() == cert.issuer() } /// Convert an X509Name to Python nested tuple format for SSL certificate dicts @@ -867,26 +869,36 @@ impl ServerCertVerifier for NoVerifier { fn verify_tls12_signature( &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, ) -> Result { - // Accept all signatures without verification - Ok(HandshakeSignatureValid::assertion()) + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &CryptoExt::get_provider().signature_verification_algorithms, + ) } fn verify_tls13_signature( &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, ) -> Result { - // Accept all signatures without verification - Ok(HandshakeSignatureValid::assertion()) + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &CryptoExt::get_provider().signature_verification_algorithms, + ) } fn supported_verify_schemes(&self) -> Vec { - ALL_SIGNATURE_SCHEMES.to_vec() + CryptoExt::get_provider() + .signature_verification_algorithms + .supported_schemes() } } diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 70304e639..377c68dca 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -605,14 +605,35 @@ pub(crate) mod _thread { vm.state.thread_count.fetch_sub(1); } + /// Default stack size for Python threads in **debug builds only**, where + /// Rust stack frames are substantially larger than in release. Rust's + /// `std::thread::Builder` otherwise defaults to 2 MB, which is too small + /// for the call chains the Python stdlib runs on helper threads in debug + /// (e.g. the SSL test server, see #7941). Release builds keep the prior + /// behavior — leave the stack size unset and let Rust's std default apply + /// — to avoid oversized virtual stack mappings when many threads spawn. + #[cfg(debug_assertions)] + const DEFAULT_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024; + + /// Configure a `thread::Builder` with the stack size to use for a new + /// Python thread. Uses the value set via `threading.stack_size(N)` when + /// the user has provided one (non-zero). Otherwise, debug builds fall + /// back to [`DEFAULT_THREAD_STACK_SIZE`] and release builds leave the + /// builder unmodified (Rust's std default applies). fn apply_thread_stack_size( thread_builder: thread::Builder, vm: &VirtualMachine, ) -> thread::Builder { let configured = vm.state.stacksize.load(); if configured != 0 { - thread_builder.stack_size(configured) - } else { + return thread_builder.stack_size(configured); + } + #[cfg(debug_assertions)] + { + thread_builder.stack_size(DEFAULT_THREAD_STACK_SIZE) + } + #[cfg(not(debug_assertions))] + { thread_builder } } @@ -1996,4 +2017,55 @@ pub(crate) mod _thread { Ok(handle_clone) } + + #[cfg(test)] + mod tests { + #[cfg(all(debug_assertions, any(target_os = "linux", target_os = "macos")))] + use super::*; + #[cfg(all(debug_assertions, any(target_os = "linux", target_os = "macos")))] + use crate::Interpreter; + + /// Regression test for #7941: a Python thread started without an + /// explicit `threading.stack_size()` must not run on Rust's 2 MiB + /// std default in debug builds, where the call chains the stdlib + /// runs on helper threads (e.g. the SSL test server) overflowed it. + #[test] + #[cfg(all(debug_assertions, any(target_os = "linux", target_os = "macos")))] + fn default_python_thread_stack_size_debug() { + Interpreter::without_stdlib(Default::default()).enter(|vm| { + assert_eq!(vm.state.stacksize.load(), 0); + let builder = apply_thread_stack_size(thread::Builder::new(), vm); + let stack_size = builder + .spawn(current_thread_stack_size) + .expect("failed to spawn thread") + .join() + .expect("thread panicked"); + assert!( + stack_size >= DEFAULT_THREAD_STACK_SIZE, + "Python thread stack size is {stack_size} bytes, expected at least {DEFAULT_THREAD_STACK_SIZE}" + ); + }); + } + + #[cfg(all(debug_assertions, target_os = "linux"))] + fn current_thread_stack_size() -> usize { + use libc::{ + pthread_attr_destroy, pthread_attr_getstacksize, pthread_attr_t, + pthread_getattr_np, pthread_self, + }; + let mut attr: pthread_attr_t = unsafe { core::mem::zeroed() }; + unsafe { + assert_eq!(pthread_getattr_np(pthread_self(), &mut attr), 0); + let mut size = 0; + assert_eq!(pthread_attr_getstacksize(&attr, &mut size), 0); + pthread_attr_destroy(&mut attr); + size + } + } + + #[cfg(all(debug_assertions, target_os = "macos"))] + fn current_thread_stack_size() -> usize { + unsafe { libc::pthread_get_stacksize_np(libc::pthread_self()) } + } + } } From 8cfa667182c0e9d85b511c774660467b291e2980 Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Thu, 13 Aug 2026 04:29:13 +0900 Subject: [PATCH 56/94] ci: automate OSCCA pull request triage (#8505) Assisted-by: Codex:gpt-5.6-sol --- .github/workflows/oscca-pr.yml | 70 ++++++++++++++++++++++++++++++++++ .github/zizmor.yml | 5 +++ 2 files changed, 75 insertions(+) create mode 100644 .github/workflows/oscca-pr.yml diff --git a/.github/workflows/oscca-pr.yml b/.github/workflows/oscca-pr.yml new file mode 100644 index 000000000..f96c18fd3 --- /dev/null +++ b/.github/workflows/oscca-pr.yml @@ -0,0 +1,70 @@ +name: Manage OSCCA pull requests + +on: + pull_request_target: + types: [opened] + +permissions: {} + +jobs: + label-and-assign: + name: Label and assign OSCCA pull request + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Label and assign pull request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const osccaUsers = new Set( + [ + "2jiyong", + "chestnut1717", + "devyubin", + "fregataa", + "hyoinandout", + "HyoJongPark", + "JaceJung-dev", + "jinmay", + "jiwahn", + "kangdora", + "kim-jaedeok", + "kyokuping", + "leehanjeong", + "lms0806", + "lsahn-gh", + "moreal", + "name-of-okja", + "rlaisqls", + "seungje0612", + "shAn-kor", + "sigmaith", + "teddygood", + "widehyo1", + "YangSiJun528", + "zzarbttoo", + ].map((login) => login.toLowerCase()), + ); + const pullRequest = context.payload.pull_request; + const author = pullRequest.user.login; + + if (!osccaUsers.has(author.toLowerCase())) { + core.info(`${author} is not an OSCCA participant; skipping.`); + return; + } + + const issue = { + ...context.repo, + issue_number: pullRequest.number, + }; + + await github.rest.issues.addLabels({ + ...issue, + labels: ["z-ca-2026"], + }); + await github.rest.issues.addAssignees({ + ...issue, + assignees: [author], + }); diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 33ac61c64..02ceb805c 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,4 +1,9 @@ rules: + dangerous-triggers: + ignore: + # pull_request_target is needed to label and assign PRs from forks with issues: write. + # The workflow does not check out or execute pull request code. + - oscca-pr.yml:3 excessive-permissions: ignore: # pull_request_target is needed to post PR comments with pull-requests: write. From df44fa50df2ab7ea9f59b13e096f0309a007c107 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:30:53 -0400 Subject: [PATCH 57/94] Allow and document clippy::drain_collect (#8500) RustPython's tail call machinery pre-allocates and reuses a vector. The code drains the vector into a new vector which is stored elsewhere. Clippy warns that this pattern causes a spurious location. Clippy is usually right that this pattern is suspect, but in this case the initial vector is reused so we want to keep the initial location. --- crates/vm/src/stdlib/_codecs.rs | 11 +++++------ crates/vm/src/vm/mod.rs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/vm/src/stdlib/_codecs.rs b/crates/vm/src/stdlib/_codecs.rs index 69d9e0e4f..497d62fcc 100644 --- a/crates/vm/src/stdlib/_codecs.rs +++ b/crates/vm/src/stdlib/_codecs.rs @@ -791,19 +791,18 @@ mod _codecs_windows { // Convert code point to UTF-16 let mut wchars = [0u16; 2]; - let wchar_len; let is_surrogate = (0xD800..=0xDFFF).contains(&ch); - if is_surrogate { - wchar_len = 0; // Can't encode surrogates normally + let wchar_len = if is_surrogate { + 0 // Can't encode surrogates normally } else if ch < 0x10000 { wchars[0] = ch as u16; - wchar_len = 1; + 1 } else { wchars[0] = ((ch - 0x10000) >> 10) as u16 + 0xD800; wchars[1] = ((ch - 0x10000) & 0x3FF) as u16 + 0xDC00; - wchar_len = 2; - } + 2 + }; if !is_surrogate { let mut buf = [0u8; 8]; diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 6009f421e..7c7d017c1 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -1467,6 +1467,10 @@ impl VirtualMachine { let initial_ptr = self.take_pending_tailcall(); // Drain the refs that keep the initial callee's raw pointers alive. + #[allow( + clippy::drain_collect, + reason = "`pending_tailcall_refs`'s allocation is intentionally reused" + )] let initial_refs = unsafe { &mut *self.pending_tailcall_refs.get() } .drain(..) .collect(); @@ -1498,6 +1502,10 @@ impl VirtualMachine { let result = crate::frame::run_iframe(callee, self); match result { Ok(ExecutionResult::TailCall) => { + #[allow( + clippy::drain_collect, + reason = "`pending_tailcall_refs`'s allocation is intentionally reused" + )] let refs = unsafe { &mut *self.pending_tailcall_refs.get() } .drain(..) .collect(); @@ -1548,6 +1556,10 @@ impl VirtualMachine { let result = crate::frame::run_iframe(caller_iframe, self); match result { Ok(ExecutionResult::TailCall) => { + #[allow( + clippy::drain_collect, + reason = "`pending_tailcall_refs`'s allocation is intentionally reused" + )] let refs = unsafe { &mut *self.pending_tailcall_refs.get() } .drain(..) .collect(); @@ -1609,6 +1621,10 @@ impl VirtualMachine { let result = crate::frame::run_iframe(caller_iframe, self); match result { Ok(ExecutionResult::TailCall) => { + #[allow( + clippy::drain_collect, + reason = "`pending_tailcall_refs`'s allocation is intentionally reused" + )] let refs = unsafe { &mut *self.pending_tailcall_refs.get() } .drain(..) .collect(); From 7e6eaa8e3cf2eba6a825c1fe378368e7977361f4 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Thu, 13 Aug 2026 04:31:53 +0900 Subject: [PATCH 58/94] Reuse stored hashes in dict.fromkeys() (#8503) * Reuse stored hashes in dict.fromkeys() Closes #8490. Co-Authored-By: Claude Opus 5 (1M context) * Make fromkeys_known_hashes a PyDict associated fn --------- Co-authored-by: Claude Opus 5 (1M context) --- Lib/test/test_set.py | 1 - crates/vm/src/builtins/dict.rs | 30 ++++++++++++++++++++++++++---- crates/vm/src/builtins/set.rs | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_set.py b/Lib/test/test_set.py index 4d062b42d..42f11c9eb 100644 --- a/Lib/test/test_set.py +++ b/Lib/test/test_set.py @@ -330,7 +330,6 @@ class TestJointOps: name = repr(s).partition('(')[0] # strip class name self.assertEqual(repr(s), '%s({%s(...)})' % (name, name)) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_do_not_rehash_dict_keys(self): n = 10 d = dict.fromkeys(map(HashCountingInt, range(n))) diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index 5db071e1d..fbc23a0dd 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -1,6 +1,6 @@ use super::{ IterStatus, PositionIterInternal, PyBaseExceptionRef, PyGenericAlias, PyMappingProxy, PySet, - PyStr, PyStrRef, PyTupleRef, PyType, PyTypeRef, set::PySetInner, + PyStr, PyStrRef, PyTupleRef, PyType, PyTypeRef, set, set::PySetInner, }; use crate::common::lock::LazyLock; use crate::object::{Traverse, TraverseFn}; @@ -9,7 +9,7 @@ use crate::{ TryFromObject, atomic_func, builtins::{PyList, PyTuple, iter::builtins_iter, type_::PyAttributes}, class::{PyClassDef, PyClassImpl}, - common::ascii, + common::{ascii, hash::PyHash}, dict_inner::{self, DictKey}, function::{ArgIterable, FuncArgs, KwArgs, OptionalArg, PyArithmeticValue, PyComparisonValue}, iter::PyExactSizeIterator, @@ -354,6 +354,20 @@ impl PyDict { ) -> PyResult> { self.entries.get(vm, key) } + + /// Keys of `obj` with their stored hashes, or `None` if it must be iterated + /// generically. Only exact dicts and sets qualify, as in CPython's + /// `_PyDict_FromKeys`: a subclass may override `__iter__`. + fn fromkeys_known_hashes( + obj: &PyObject, + vm: &VirtualMachine, + ) -> Option> { + if let Some(dict) = obj.downcast_ref_if_exact::(vm) { + Some(dict.entries.keys_with_hashes()) + } else { + set::exact_set_keys_with_hashes(obj, vm) + } + } } // Python dict methods: @@ -384,8 +398,16 @@ impl PyDict { let d = PyType::call(&class, ().into(), vm)?; match d.downcast_exact::(vm) { Ok(pydict) => { - for key in iterable.iter(vm)? { - pydict.__setitem__(key?, value.clone(), vm)?; + if let Some(keys) = Self::fromkeys_known_hashes(iterable.as_object(), vm) { + for (key, hash) in keys { + pydict + .entries + .insert_known_hash(vm, &*key, hash, value.clone())?; + } + } else { + for key in iterable.iter(vm)? { + pydict.__setitem__(key?, value.clone(), vm)?; + } } Ok(pydict.into_pyref().into()) } diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index 62bdb0f0d..6961040c7 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -605,6 +605,23 @@ fn extract_set(obj: &PyObject) -> Option<&PySetInner> { }) } +/// Elements of `obj` with their stored hashes, or `None` unless `obj` is exactly +/// a `set` or `frozenset` — `PyAnySet_CheckExact`, where [`extract_set`] is the +/// subclass-inclusive `PyAnySet_Check`. +pub(super) fn exact_set_keys_with_hashes( + obj: &PyObject, + vm: &VirtualMachine, +) -> Option> { + let inner = obj + .downcast_ref_if_exact::(vm) + .map(|set| &set.inner) + .or_else(|| { + obj.downcast_ref_if_exact::(vm) + .map(|frozen| &frozen.inner) + })?; + Some(inner.content.keys_with_hashes()) +} + fn reduce_set(zelf: &PyObject, vm: &VirtualMachine) -> (PyTypeRef, PyTupleRef, Option) { ( zelf.class().to_owned(), From 1178841eabdbfa5f2bba776c3c6be9757fb627b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=91=ED=9A=A8?= <87641474+widehyo1@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:32:35 +0900 Subject: [PATCH 59/94] itertools: defer dropwhile predicate validation (#8504) Store the `dropwhile` predicate as a Python object and call it while advancing the iterator. This defers callable validation until the predicate is first needed, matching CPython for empty input while preserving exception propagation during iteration. Remove the now-passing `test_dropwhile` expected-failure marker. Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_itertools.py | 1 - crates/vm/src/stdlib/itertools.rs | 9 ++++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_itertools.py b/Lib/test/test_itertools.py index 585f6611a..b91e3735d 100644 --- a/Lib/test/test_itertools.py +++ b/Lib/test/test_itertools.py @@ -1260,7 +1260,6 @@ class TestBasicOps(unittest.TestCase): self.assertEqual(list(t), [1, 1, 1]) self.assertRaises(StopIteration, next, t) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_dropwhile(self): data = [1, 3, 5, 20, 2, 4, 6, 8] self.assertEqual(list(dropwhile(underten, data)), [20, 2, 4, 6, 8]) diff --git a/crates/vm/src/stdlib/itertools.rs b/crates/vm/src/stdlib/itertools.rs index 041620298..30a4d8773 100644 --- a/crates/vm/src/stdlib/itertools.rs +++ b/crates/vm/src/stdlib/itertools.rs @@ -10,7 +10,7 @@ mod decl { rc::PyRc, }, convert::ToPyObject, - function::{ArgCallable, FuncArgs, OptionalArg, OptionalOption, PosArgs}, + function::{FuncArgs, OptionalArg, OptionalOption, PosArgs}, protocol::{PyIter, PyIterReturn, PyNumber}, raise_if_stop, stdlib::sys, @@ -477,7 +477,7 @@ mod decl { #[pyclass(name = "dropwhile")] #[derive(Debug, PyPayload)] struct PyItertoolsDropwhile { - predicate: ArgCallable, + predicate: PyObjectRef, iterable: PyIter, start_flag: AtomicCell, } @@ -485,7 +485,7 @@ mod decl { #[derive(FromArgs)] struct DropwhileNewArgs { #[pyarg(positional)] - predicate: ArgCallable, + predicate: PyObjectRef, #[pyarg(positional)] iterable: PyIter, } @@ -522,8 +522,7 @@ mod decl { if !zelf.start_flag.load() { loop { let obj = raise_if_stop!(iterable.next(vm)?); - let pred = predicate.clone(); - let pred_value = pred.invoke((obj.clone(),), vm)?; + let pred_value = predicate.call((obj.clone(),), vm)?; if !pred_value.try_to_bool(vm)? { zelf.start_flag.store(true); return Ok(PyIterReturn::Return(obj)); From d5919ce2f4dcd6b34f345a30e9db32ea9a70e036 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:24:50 +0900 Subject: [PATCH 60/94] Fix future annotation block alignment (#8506) --- crates/codegen/src/compile.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index b16540762..1ca7afb8e 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -5083,6 +5083,14 @@ impl<'warnings> Compiler<'warnings> { func_range: TextRange, ) -> CompileResult { if !self.next_function_annotation_symbol_table_uses_annotations() { + // CPython creates a hidden AnnotationBlock for every function + // signature under `from __future__ import annotations`, including + // an unannotated one. It still belongs to this function: consume + // it so the next function sees its own block rather than remaining + // pinned to this unused entry. + if self.push_annotation_symbol_table() { + self.pop_annotation_symbol_table(); + } return Ok(false); } @@ -31247,6 +31255,25 @@ def f(x: T): pass ); } + #[test] + fn future_unannotated_function_does_not_hide_next_annotation_block() { + let code = compile_exec( + "\ +from __future__ import annotations +def plain(x): pass +def annotated(x: int): pass +", + ); + let annotate = find_direct_child_code(&code, "__annotate__") + .expect("second function must retain its annotation closure"); + assert!( + annotate.constants.iter().any( + |constant| matches!(constant, ConstantData::Str { value } if value.as_str() == Ok("int")) + ), + "annotation closure must belong to the annotated function" + ); + } + #[test] fn deferred_annotation_format_name_does_not_capture_helper_parameter() { let code = compile_exec( From 2a793fcdd1461001a0078cc89d34833205f4f30b Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:16:13 +0900 Subject: [PATCH 61/94] codegen: preserve symbol tables across copied finally bodies (#8507) Assisted-by: OpenAI Codex:gpt-5 --- crates/codegen/src/compile.rs | 6 +++ extra_tests/snippets/syntax_try.py | 82 ++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 1ca7afb8e..afe868dab 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -2406,7 +2406,13 @@ impl<'warnings> Compiler<'warnings> { } if let FBlockDatum::FinallyBody(ref body) = info.fb_datum { + // This is an extra copy of the finally body, emitted for the + // path that leaves the try block early. The try statement + // emits its own copies afterwards, so rewind the symbol table + // cursors and leave the nested scopes for those copies. + let symbol_table_cursors = self.current_symbol_table_cursors(); self.compile_statements(body)?; + self.set_symbol_table_cursors(symbol_table_cursors); } if preserve_tos { diff --git a/extra_tests/snippets/syntax_try.py b/extra_tests/snippets/syntax_try.py index 1f46caae3..5610cb23e 100644 --- a/extra_tests/snippets/syntax_try.py +++ b/extra_tests/snippets/syntax_try.py @@ -285,3 +285,85 @@ with assert_raises(SyntaxError): try: pass """) + + +# leaving the try block early emits an extra copy of the finally body, which +# must not consume the symbol tables of the nested scopes it contains +def return_from_try(): + log = [] + try: + return "returned" + finally: + log.append((lambda x: x * 2)(3)) + log.append({t for t in [1, 2]}) + log.append([t for t in [3]]) + log.append({k: k for k in [4]}) + + def nested(): + return 5 + + class Nested: + value = 6 + + assert log == [6, {1, 2}, [3], {4: 4}], log + assert nested() == 5 + assert Nested.value == 6 + + +assert return_from_try() == "returned" + + +def break_and_continue_from_try(): + seen = [] + for i in range(4): + try: + if i == 1: + continue + if i == 3: + break + seen.append(i) + finally: + seen.append({t for t in [i]}) + return seen + + +assert break_and_continue_from_try() == [0, {0}, {1}, 2, {2}, {3}] + + +def return_from_try_runs_finally_once(): + log = [] + + def inner(): + try: + return "value" + finally: + log.append(sorted({t for t in "ab"})) + + assert inner() == "value" + return log + + +assert return_from_try_runs_finally_once() == [["a", "b"]] + + +def generator_return_from_try(): + log = [] + + def gen(): + try: + return (yield "yielded") + finally: + log.append([t for t in "z"]) + + g = gen() + assert g.send(None) == "yielded" + try: + g.send("sent") + except StopIteration as stop: + assert stop.value == "sent", stop.value + else: + assert False, "generator did not stop" + return log + + +assert generator_return_from_try() == [["z"]] From 2e05364117954730fc4d2a3df262db0f87da9c84 Mon Sep 17 00:00:00 2001 From: Jeongseop Lim Date: Thu, 13 Aug 2026 20:27:24 +0900 Subject: [PATCH 62/94] Normalize round() result to an exact int (#8511) int.__round__ returned the receiver unchanged on the no-rounding path, so round() on an int subclass handed back an instance of that subclass: round(True) was True, not 1. CPython routes both no-op paths (ndigits is None, and ndigits >= 0) through long_long(), which reuses the reference only when the value is an exact int and otherwise copies it into a fresh one. Return zelf.__int__(vm).into_pyref(), which goes through into_exact_or() and is the same normalization the neighbouring __trunc__, __floor__ and __ceil__ already perform. Assisted-by: Claude Code:claude-opus-5 --- crates/vm/src/builtins/int.rs | 4 +++- extra_tests/snippets/builtin_round.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index 278a9cecb..c12bf2c72 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -487,7 +487,9 @@ impl PyInt { return vm.ctx.new_int(rounded); } } - zelf + // No rounding to do, but an int subclass must still be normalized to an + // exact int, the way CPython's long_long() does. + zelf.__int__(vm).into_pyref() } #[pymethod] diff --git a/extra_tests/snippets/builtin_round.py b/extra_tests/snippets/builtin_round.py index e94d97542..83725420a 100644 --- a/extra_tests/snippets/builtin_round.py +++ b/extra_tests/snippets/builtin_round.py @@ -93,3 +93,21 @@ assert math.copysign(1.0, round(0.0, 2)) == 1.0 assert round(1.0, 1000) == 1.0 assert round(1.0, -1000) == 0.0 assert round(1.7976931348623157e308, 0) == 1.7976931348623157e308 + + +# round() normalizes an int subclass to an exact int, like CPython's long_long(). +assert round(True) == 1 +assert type(round(True)) is int +assert type(round(True, 0)) is int +assert type(round(False)) is int + + +class MyInt(int): + pass + + +assert round(MyInt(5)) == 5 +assert type(round(MyInt(5))) is int +assert type(round(MyInt(5), 2)) is int +# A negative ndigits already produced a fresh exact int. +assert type(round(MyInt(15), -1)) is int From 6cfa07995095f5814c7790aea09768d95d30884e Mon Sep 17 00:00:00 2001 From: Jeongseop Lim Date: Thu, 13 Aug 2026 21:51:56 +0900 Subject: [PATCH 63/94] Reject a fifth argument to property() (#8510) PropertyArgs declared a fifth positional-or-keyword field, name, so the derived arity was 0..=5 and property(None, None, None, None, None) built a property object with the fifth argument stored in the __name__ slot. CPython's property.__init__ is Argument Clinic generated with maxpos = 4 and the keyword list {fget, fset, fdel, doc}, so it rejects both that call and property(name='x'). Drop the field. Nothing passed it: clone_property_with always supplied None and copies the name separately, and the name slot is still filled by __set_name__ and the __name__ setter. Assisted-by: Claude Code:claude-opus-5 --- crates/vm/src/builtins/property.rs | 6 +----- extra_tests/snippets/builtin_property.py | 7 +++++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/vm/src/builtins/property.rs b/crates/vm/src/builtins/property.rs index cff5a8a60..65ae48222 100644 --- a/crates/vm/src/builtins/property.rs +++ b/crates/vm/src/builtins/property.rs @@ -1,7 +1,7 @@ /*! Python `property` descriptor class. */ -use super::{PyStrRef, PyType}; +use super::PyType; use crate::common::lock::PyRwLock; use crate::function::{IntoFuncArgs, PosArgs}; use crate::{ @@ -41,8 +41,6 @@ pub struct PropertyArgs { fdel: Option, #[pyarg(any, default)] doc: Option, - #[pyarg(any, default)] - name: Option, } impl GetDescriptor for PyProperty { @@ -221,7 +219,6 @@ impl PyProperty { fset: new_setter.or_else(|| zelf.fset()), fdel: new_deleter.or_else(|| zelf.fdel()), doc, - name: None, }; // Create new property using py_new and init @@ -401,7 +398,6 @@ impl Initializer for PyProperty { *zelf.getter.write() = args.fget; *zelf.setter.write() = args.fset; *zelf.deleter.write() = args.fdel; - *zelf.name.write() = args.name.map(|a| a.as_object().to_owned()); zelf.getter_doc.store(getter_doc, Ordering::Relaxed); Ok(()) diff --git a/extra_tests/snippets/builtin_property.py b/extra_tests/snippets/builtin_property.py index de64e5262..397d41fb0 100644 --- a/extra_tests/snippets/builtin_property.py +++ b/extra_tests/snippets/builtin_property.py @@ -85,3 +85,10 @@ assert p1.__get__(None, object) is p1 p2 = property("a", doc="pdoc") # assert p2.__doc__ == 'pdoc' + + +# property() takes at most four arguments, and `name` is not one of them: +# the name slot is filled by __set_name__ and the __name__ setter instead. +assert_raises(TypeError, property, None, None, None, None, None) +assert_raises(TypeError, property, "a", "b", "c", "d", "e") +assert_raises(TypeError, property, name="x") From 7b58a8980d0403bdbb43a77f27116122ccafcecc Mon Sep 17 00:00:00 2001 From: Jeongseop Lim Date: Thu, 13 Aug 2026 23:32:19 +0900 Subject: [PATCH 64/94] Reject extra positional arguments to `float()` (#8509) * Reject extra positional arguments to float() PyFloat::slot_new's exact-float fast path tested args.args.first(), which succeeds for any number of positional arguments, so float(1.5, True) returned the first one and never reached args.bind(vm). CPython's clinic generated float_new runs _PyArg_CheckPositional("float", nargs, 0, 1) before it fetches the first argument. Add args.args.len() == 1 to the condition, as PyInt::slot_new, PyStr::slot_new and PyComplex::slot_new already do; the extra argument then falls through to args.bind(vm) and raises TypeError. Assisted-by: Claude Code:claude-opus-5 * Bind float() arguments before the fast path The exact-float fast path repeated float()'s argument count inline, which duplicates what FromArgs::arity already knows. Bind first and match on the resulting OptionalArg instead, so the positional and keyword rules stay in one place and the bound value is reused by py_new. Assisted-by: Claude Code:claude-opus-5 --- crates/vm/src/builtins/float.rs | 8 +++++--- extra_tests/snippets/builtin_float.py | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/vm/src/builtins/float.rs b/crates/vm/src/builtins/float.rs index 1c861b14f..0b7396946 100644 --- a/crates/vm/src/builtins/float.rs +++ b/crates/vm/src/builtins/float.rs @@ -176,16 +176,18 @@ impl Constructor for PyFloat { type Args = OptionalArg; fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { + // Bind before the fast path so FromArgs::arity decides how many arguments + // are acceptable, rather than a count repeated here. + let arg: Self::Args = args.bind(vm)?; + // Optimization: return exact float as-is if cls.is(vm.ctx.types.float_type) - && args.kwargs.is_empty() - && let Some(first) = args.args.first() + && let OptionalArg::Present(first) = &arg && first.class().is(vm.ctx.types.float_type) { return Ok(first.clone()); } - let arg: Self::Args = args.bind(vm)?; let payload = Self::py_new(&cls, arg, vm)?; payload.into_ref_with_type(vm, cls).map(Into::into) } diff --git a/extra_tests/snippets/builtin_float.py b/extra_tests/snippets/builtin_float.py index 1417c5ae1..c459c2d0d 100644 --- a/extra_tests/snippets/builtin_float.py +++ b/extra_tests/snippets/builtin_float.py @@ -561,3 +561,12 @@ assert str(161852602146008.12) == "161852602146008.12" assert repr(1.5) == "1.5" assert repr(0.1) == "0.1" assert repr(100.0) == "100.0" + + +# float() takes at most one positional argument; the exact-float fast path +# must not let extra ones through. +assert_raises(TypeError, float, 1.5, True) +assert_raises(TypeError, float, 1.5, 2, 3) +assert_raises(TypeError, float, "1.5", 2) +assert float(1.5) == 1.5 +assert float() == 0.0 From d5d19e0611cfe382d440bff46c2279f59ce1ccda Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:57:10 +0900 Subject: [PATCH 65/94] Fix 33 reproduced fuzzing and static-review defects (#8514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * mmap: guard move() against dest/src past the mapping size An offset larger than the mapping made `size - dest` underflow, producing an out-of-range slice index panic. Check the high side first, as write() does. Assisted-by: Claude * exceptions: keep ImportError name/path/name_from out of an empty instance dict ImportError.__reduce__ unwrapped get_arg(0), which is None when the exception was constructed with no positional argument, aborting on pickle.dumps(ImportError()). Fall back to the full args tuple there, and expose name/path/name_from as class attributes defaulting to None so a bare ImportError() reduces to (cls, ()). Assisted-by: Claude * asyncio: stop unwrapping the _current_tasks downcast in current_task() _asyncio._current_tasks is a reassignable module attribute; the three sibling functions already degrade gracefully when it is not a dict, current_task() did not. Assisted-by: Claude * asyncio: raise TypeError when FutureIter.throw()'s exception class returns a non-exception exc_type.call() goes through a Python-controlled __new__, so its result can be any object; the downcast was unwrapped. Report it as a TypeError instead. Assisted-by: Claude * sequence: use a fallible reservation for sequence repetition The guard only rejects a repeat whose per-element size crosses MAX_MEMORY_SIZE, so (1,) * (10**12) still reached Vec::with_capacity and aborted in the allocator. Reserve fallibly and surface a MemoryError. Assisted-by: Claude * collections: reject an oversized deque repetition with MemoryError deque * sys.maxsize reached the allocator and aborted with a capacity overflow. Apply the MAX_MEMORY_SIZE guard the other sequences already carry. Assisted-by: Claude * itertools: raise OverflowError for an out-of-range r argument combinations/combinations_with_replacement/permutations narrowed r with to_usize().unwrap(), so r=2**64 panicked instead of raising. Assisted-by: Claude * math: stream the generic sumprod path instead of collecting both iterables The big-int path collected each argument into a Vec before multiplying, so an unbounded iterable exhausted memory. Advance both iterators in lockstep and accumulate, reporting a length mismatch when only one is exhausted. Assisted-by: Claude * _imp: raise TypeError for a second positional argument to find_frozen withdata is keyword-only and unimplemented; passing it positionally hit an unimplemented!() and aborted. Assisted-by: Claude * _typing: check _idfunc arity before indexing args _typing._idfunc() with no argument indexed args[0] out of bounds. Assisted-by: Claude * exceptions: require a sequence for the ExceptionGroup excs argument The argument was collected before being validated, so an unbounded iterable such as itertools.count() exhausted memory. Reject a non-sequence up front. Assisted-by: Claude * mmap: treat an inverted find/rfind range as empty find(b"x", 5, 2) built a slice whose start exceeded its end and panicked. Assisted-by: Claude * collections: give deque and defaultdict a GC traverse Neither type opted into traversal, so a reference cycle through a deque or a defaultdict was never collected. Assisted-by: Claude * itertools: opt the iterator types into GC traverse cycle and its siblings hold Python references but declared no traverse, so a cycle built through one of them leaked. Assisted-by: Claude * _ctypes: mask an out-of-range int instead of panicking c_char_p(2**64) and pointer item assignment called .expect() on the narrowing conversion. Wrap the value to the target width, which is what the C implementation stores. Assisted-by: Claude * hashlib: import _hashlib when a hash module is loaded The hash object type is a static type owned by _hashlib; calling _md5.md5() without _hashlib imported hit an uninitialized static type and panicked. Assisted-by: Claude * _csv: fall back to the built-in dialect defaults when none is registered The dialect table is empty until csv.py registers 'excel', so _csv.reader([]) unwrapped a missing entry and panicked. Assisted-by: Claude * builtins: reject surrogates in compile()/eval() source instead of panicking expect_str() panics on a str containing surrogates; convert with try_as_utf8 so eval(chr(0xd800)) raises. Assisted-by: Claude * _suggestions: require a list for _generate_suggestions candidates The argument was collected before validation, so an unbounded iterable exhausted memory. Assisted-by: Claude * lzma: size the filter chain before consuming it filters= was collected into a Vec before the length check, so an unbounded iterable exhausted memory. Take the length through the sequence protocol first. Assisted-by: Claude * classmethod: opt into GC traverse staticmethod already declares traverse; classmethod did not, so a cycle through the wrapped callable leaked. Assisted-by: Claude * posix: reject unbounded iterables in posix_spawn and setgroups argv, setsigdef, setsigmask and setgroups bound an ArgIterable and collected it before validating, so an infinite generator exhausted memory. Require a list/tuple for argv, validate signals while streaming, and take setgroups through the sequence protocol. Assisted-by: Claude * _ctypes: size Array slice assignment and _argtypes_ before collecting Both eagerly collected their argument, so an unbounded iterable exhausted memory before the length check ran. Assisted-by: Claude * sys: propagate the breakpointhook warning failure warn() was unwrapped, so an unimportable $PYTHONBREAKPOINT under -W error panicked instead of raising. Assisted-by: Claude * structseq: require the sequence argument when constructing a struct sequence A no-argument construction produced an empty backing tuple, and reading any named field then indexed out of bounds. Assisted-by: Claude * Guard the hash slot dispatch against unbounded recursion `PyObject::hash` invoked the type's hash slot directly, so an element-wise `__hash__` following a deeply nested object graph recursed one native frame per level and overflowed the stack. Wrap the dispatch in `with_recursion`, matching the repr and rich-compare dispatches in the same file, so `hash(x)` on a nested tuple/GenericAlias/slice raises `RecursionError`. Assisted-by: Claude * genericalias: guard the __parameters__ walk against unbounded recursion `make_parameters_from_slice` recursed into every list/tuple argument with nothing counting the frames, so `list[L]` for a self-referential or deeply nested `L` overflowed the native stack at subscript time. Wrap the descent in `with_recursion`, which makes the walk fallible; `PyGenericAlias::new`, `from_args` and `make_parameters` now return `PyResult` and every caller propagates. Assisted-by: Claude * Fix the type confusion in PyAtomicRef's Debug impl `PyAtomicRef` stores a pointer to a `Py`, as `Deref`, `load_raw`, `swap` and `Drop` all read it, but `Debug` cast it to a bare `T` and formatted the object header as payload bytes. For `PyFunction`, whose `code: PyAtomicRef` has a pointer-chasing `Debug`, that dereferenced header words and segfaulted. Cast to `PyObject` instead, which is what `Drop` already does and which also covers the `PyAtomicRef` and `PyAtomicRef>` instantiations that have no `Py`. `_asyncio._enter_task` reached this through `{:?}` in its "Cannot enter into task" message; format the two tasks with their Python repr, which is what the message is meant to show. Assisted-by: Claude * _sre: disallow instantiating Match `Match` inherited `object.__new__`, so `M.__new__(M)` produced an instance whose `regs`/`string`/`pattern` were never filled in by a match run; the mapping subscript path read them and dereferenced garbage. The type has no public constructor, so mark it `DISALLOW_INSTANTIATION`, which makes `M.__new__(M)` raise `TypeError: cannot create 're.Match' instances`. Assisted-by: Claude * utils: return the empty repr instead of asserting a non-empty collection `collection_repr` took the first element with an `.expect()` justified by the caller's preceding non-empty check. Another thread clearing the collection between that check and the iteration made the iterator yield nothing and panicked the worker. Fall back to the caller-supplied empty form, which is the text those callers already produce for an empty collection. Assisted-by: Claude * itertools: advance cycle's index atomically `cycle.__next__` did `fetch_add(1)` and then reset the index to 0 in a separate store, so two threads replaying the saved items could both read an index past the end of `saved` and panic on the slice access. Do the advance and the wrap in one `fetch_update`. Assisted-by: Claude * _ctypes: reject a float argument to a foreign call without argtypes `conv_param`, the conversion used when `argtypes` is not set, converted its argument with `try_int`, which goes through `__int__` and so accepts a float. `libc.strlen(1.5)` therefore passed 1 where the callee expects a `char *` and the callee dereferenced it. Match `ConvParam`, which does a `PyLong_Check` and converts nothing: take the branch only for an `int` (or a subclass, so `True` still converts), and let a float fall through to "Don't know how to convert parameter". The branch below it converted a float to a C double, but `try_int` claimed every float before it could run, so it was dead; `CArgValue::Double` existed only for that branch and both go. Typed doubles are unaffected — they travel as `CArgValue::Typed` with code 'd'. Assisted-by: Claude * Report the iterator itself from PyIter's traverse `Traverse for PyIter` delegated to the inherent `PyObject::traverse` of the object it wraps, so it reported that iterator's referents instead of the iterator. The iterator's own reference to those referents was then never subtracted during the collector's reference-subtraction pass, the referents kept a non-zero gc_refs, and every object reachable from them was classified as a root. Any cycle running through a type with a `PyIter` field therefore survived collection: `map`, `filter`, `zip`, `enumerate`, `reversed` and the `itertools` iterators all leaked, while the same cycle through a `list`, `tuple` or `list_iterator` collected. Report the wrapped object, as the `PyObjectRef`, `PyRef` and `PyStackRef` impls do. `itertools.tee` still leaks: its shared buffer is a `PyRc` rather than a Python object, so the collector cannot see through it. Assisted-by: Claude * Remove the obsolete expectedFailure on test_code_module.test_unicode_error Compiling a source string containing a lone surrogate now raises UnicodeEncodeError, so the test passes. Assisted-by: Claude * itertools: reserve the combination indices fallibly `combinations` and `combinations_with_replacement` built their index vector with an infallible allocation, so an `r` that passes the ssize_t check but does not fit in memory aborted the process instead of raising MemoryError. Assisted-by: Claude * Apply the struct sequence constructor's dict argument `structseq(sequence, dict)` discarded its second argument, so the hidden fields past `n_sequence_fields` — `tm_zone`, `st_atime` and friends — were always None when constructed directly or restored from a `(sequence, dict)` pickle, and a non-dict second argument was accepted silently. Take the dict, require it to be a dict, and fill the hidden slots the sequence did not cover from it. A key that names a field the sequence already supplied, or no field at all, is now a "got duplicate or unexpected field name(s)" TypeError instead of being dropped. Both arguments are bindable by name, as `sequence` and `dict`. `os.stat_result` and `os.statvfs_result` did not accept a second argument at all; they and `time.struct_time` now share the parsing. Assisted-by: Claude * _imp: report the argument count in find_frozen's arity error Assisted-by: Claude * Add regression tests for the reproduced crashers One case per catalog entry, each asserting the behavior the fix produces: recursion guards, the memory-unsafety sites, the overflow and unbounded allocation guards, the eager-collection rejections, the unwrap sites, and the cycles the collector now breaks. Every expected value was checked against CPython 3.14. Assisted-by: Claude * Tolerate a changed-size RuntimeError in the set repr stress test The mutator and reader threads race on purpose; a "changed size during iteration" RuntimeError is a valid outcome of that race and should not fail the test. The panic it guards against is not. Assisted-by: Claude * Move the crash regression tests into per-module snippets crash_regressions.py collected every reproduced crasher in one file. Split it into the snippet for the module each case exercises, and add stdlib_gc.py, stdlib_asyncio.py, stdlib_lzma.py, stdlib_threading_set_repr.py and stdlib_threading_itertools_cycle.py for the cases with no existing home. The suite runs every snippet under the host CPython too, so the checks only RustPython raises are guarded by sys.implementation.name: the hash and __parameters__ recursion depth, and the deque repeat overflow. The float ctypes argument accepts either TypeError or ctypes.ArgumentError. Assisted-by: Claude --- Lib/test/test_code_module.py | 1 - Lib/test/test_structseq.py | 6 - crates/capi/src/genericaliasobject.rs | 2 +- crates/stdlib/src/_asyncio.rs | 54 +++++--- crates/stdlib/src/_queue.rs | 2 +- crates/stdlib/src/array.rs | 2 +- crates/stdlib/src/blake2.rs | 9 +- crates/stdlib/src/contextvars.rs | 4 +- crates/stdlib/src/csv.rs | 15 ++- crates/stdlib/src/lzma.rs | 35 ++--- crates/stdlib/src/math.rs | 27 ++-- crates/stdlib/src/md5.rs | 9 +- crates/stdlib/src/mmap.rs | 7 +- crates/stdlib/src/sha1.rs | 9 +- crates/stdlib/src/sha3.rs | 9 +- crates/stdlib/src/suggestions.rs | 20 ++- crates/vm/src/builtins/asyncgenerator.rs | 6 +- crates/vm/src/builtins/bytearray.rs | 6 +- crates/vm/src/builtins/bytes.rs | 6 +- crates/vm/src/builtins/classmethod.rs | 8 +- crates/vm/src/builtins/coroutine.rs | 6 +- crates/vm/src/builtins/dict.rs | 6 +- crates/vm/src/builtins/enumerate.rs | 6 +- crates/vm/src/builtins/generator.rs | 6 +- crates/vm/src/builtins/genericalias.rs | 28 ++-- crates/vm/src/builtins/interpolation.rs | 6 +- crates/vm/src/builtins/list.rs | 6 +- crates/vm/src/builtins/mappingproxy.rs | 6 +- crates/vm/src/builtins/memory.rs | 6 +- crates/vm/src/builtins/range.rs | 6 +- crates/vm/src/builtins/set.rs | 15 ++- crates/vm/src/builtins/slice.rs | 6 +- crates/vm/src/builtins/staticmethod.rs | 6 +- crates/vm/src/builtins/template.rs | 6 +- crates/vm/src/builtins/tuple.rs | 8 +- crates/vm/src/builtins/union.rs | 2 +- crates/vm/src/builtins/weakref.rs | 6 +- crates/vm/src/exception_group.rs | 19 +-- crates/vm/src/exceptions.rs | 31 +++-- crates/vm/src/object/ext.rs | 7 +- crates/vm/src/protocol/iter.rs | 6 +- crates/vm/src/protocol/object.rs | 8 +- crates/vm/src/sequence.rs | 7 +- crates/vm/src/stdlib/_ast/pyast.rs | 8 +- crates/vm/src/stdlib/_collections.rs | 54 +++++++- crates/vm/src/stdlib/_ctypes.rs | 1 - crates/vm/src/stdlib/_ctypes/array.rs | 16 ++- crates/vm/src/stdlib/_ctypes/base.rs | 5 +- crates/vm/src/stdlib/_ctypes/function.rs | 59 ++++----- crates/vm/src/stdlib/_ctypes/pointer.rs | 11 +- crates/vm/src/stdlib/_ctypes/simple.rs | 46 ++++--- crates/vm/src/stdlib/_functools.rs | 2 +- crates/vm/src/stdlib/_imp.rs | 14 +- crates/vm/src/stdlib/_sre.rs | 6 +- crates/vm/src/stdlib/_typing.rs | 15 ++- crates/vm/src/stdlib/builtins.rs | 7 +- crates/vm/src/stdlib/itertools.rs | 125 ++++++++++++------ crates/vm/src/stdlib/os.rs | 23 +++- crates/vm/src/stdlib/posix.rs | 84 ++++++------ crates/vm/src/stdlib/sys.rs | 3 +- crates/vm/src/stdlib/time.rs | 10 +- crates/vm/src/types/mod.rs | 4 +- crates/vm/src/types/structseq.rs | 68 +++++++++- crates/vm/src/utils.rs | 8 +- extra_tests/snippets/builtin_compile.py | 5 + extra_tests/snippets/builtin_eval.py | 7 + extra_tests/snippets/builtin_exceptions.py | 17 +++ extra_tests/snippets/builtin_exec.py | 7 + extra_tests/snippets/builtin_hash.py | 18 +++ extra_tests/snippets/builtin_list.py | 7 + extra_tests/snippets/builtin_tuple.py | 7 + .../snippets/forbidden_instantiation.py | 7 + extra_tests/snippets/stdlib_asyncio.py | 52 ++++++++ .../snippets/stdlib_collections_deque.py | 8 ++ extra_tests/snippets/stdlib_csv.py | 6 + extra_tests/snippets/stdlib_ctypes.py | 23 ++++ extra_tests/snippets/stdlib_ctypes_calls.py | 16 ++- extra_tests/snippets/stdlib_gc.py | 66 +++++++++ extra_tests/snippets/stdlib_hashlib.py | 8 ++ extra_tests/snippets/stdlib_imp.py | 7 + extra_tests/snippets/stdlib_itertools.py | 16 +++ extra_tests/snippets/stdlib_lzma.py | 22 +++ extra_tests/snippets/stdlib_math.py | 7 + extra_tests/snippets/stdlib_mmap.py | 13 ++ extra_tests/snippets/stdlib_os.py | 17 +++ extra_tests/snippets/stdlib_pwd.py | 4 + extra_tests/snippets/stdlib_sys.py | 16 +++ .../stdlib_threading_itertools_cycle.py | 26 ++++ .../snippets/stdlib_threading_set_repr.py | 44 ++++++ extra_tests/snippets/stdlib_time.py | 13 ++ extra_tests/snippets/stdlib_traceback.py | 11 ++ extra_tests/snippets/stdlib_types.py | 24 ++++ extra_tests/snippets/stdlib_typing.py | 10 ++ 93 files changed, 1157 insertions(+), 342 deletions(-) create mode 100644 extra_tests/snippets/stdlib_asyncio.py create mode 100644 extra_tests/snippets/stdlib_gc.py create mode 100644 extra_tests/snippets/stdlib_lzma.py create mode 100644 extra_tests/snippets/stdlib_threading_itertools_cycle.py create mode 100644 extra_tests/snippets/stdlib_threading_set_repr.py diff --git a/Lib/test/test_code_module.py b/Lib/test/test_code_module.py index 39d85d462..fb519878c 100644 --- a/Lib/test/test_code_module.py +++ b/Lib/test/test_code_module.py @@ -128,7 +128,6 @@ class TestInteractiveConsole(unittest.TestCase, MockSys): self.assertIsNone(self.sysmod.last_value.__traceback__) self.assertIs(self.sysmod.last_exc, self.sysmod.last_value) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 'UnicodeDecodeError: invalid utf-8 sequence of 1 bytes from index 1\n\nnow exiti [truncated]... doesn't start with 'UnicodeEncodeError: ' def test_unicode_error(self): self.infunc.side_effect = ["'\ud800'", EOFError('Finished')] self.console.interact() diff --git a/Lib/test/test_structseq.py b/Lib/test/test_structseq.py index 8ef6dd2fe..d4014a784 100644 --- a/Lib/test/test_structseq.py +++ b/Lib/test/test_structseq.py @@ -87,7 +87,6 @@ class StructSeqTest(unittest.TestCase): self.assertEqual(t.n_unnamed_fields, 0) self.assertEqual(t.n_fields, time._STRUCT_TM_ITEMS) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: Unexpected keyword argument dict def test_constructor(self): t = time.struct_time @@ -111,7 +110,6 @@ class StructSeqTest(unittest.TestCase): s = "123456789" self.assertEqual("".join(t(s)), s) - @unittest.expectedFailure # TODO: RUSTPYTHON; Wrong error message def test_constructor_with_duplicate_fields(self): t = time.struct_time @@ -125,7 +123,6 @@ class StructSeqTest(unittest.TestCase): with self.assertRaisesRegex(TypeError, error_message): t("1234567890", dict={"error": 0, "tm_zone": "some zone", "tm_mon": 1}) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: expected at most 1 arguments, got 2 def test_constructor_with_duplicate_unnamed_fields(self): assert os.stat_result.n_unnamed_fields > 0 n_visible_fields = os.stat_result.n_sequence_fields @@ -142,7 +139,6 @@ class StructSeqTest(unittest.TestCase): re.escape("got duplicate or unexpected field name(s)")): os.stat_result((*range(n_visible_fields), -1.0), {'st_atime': -1.0}) - @unittest.expectedFailure # TODO: RUSTPYTHON; Wrong error message def test_constructor_with_unknown_fields(self): t = time.struct_time @@ -185,7 +181,6 @@ class StructSeqTest(unittest.TestCase): self.assertEqual(t2.tm_year, t.tm_year) self.assertEqual(t2.tm_zone, t.tm_zone) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: expected at most 1 arguments, got 2 def test_pickling_with_unnamed_fields(self): assert os.stat_result.n_unnamed_fields > 0 @@ -220,7 +215,6 @@ class StructSeqTest(unittest.TestCase): self.assertIsNot(t3[0], t[0]) self.assertIsNot(t3.tm_year, t.tm_year) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: expected at most 1 arguments, got 2 def test_copying_with_unnamed_fields(self): assert os.stat_result.n_unnamed_fields > 0 diff --git a/crates/capi/src/genericaliasobject.rs b/crates/capi/src/genericaliasobject.rs index bcd313086..1ab443e13 100644 --- a/crates/capi/src/genericaliasobject.rs +++ b/crates/capi/src/genericaliasobject.rs @@ -10,6 +10,6 @@ pub unsafe extern "C" fn Py_GenericAlias( with_vm(|vm| { let origin = unsafe { &*origin }.to_owned(); let args = unsafe { &*args }.to_owned(); - PyGenericAlias::from_args(origin, args, vm).into_pyobject(vm) + PyGenericAlias::from_args(origin, args, vm).map(|alias| alias.into_pyobject(vm)) }) } diff --git a/crates/stdlib/src/_asyncio.rs b/crates/stdlib/src/_asyncio.rs index 3146e39b7..b311db4a3 100644 --- a/crates/stdlib/src/_asyncio.rs +++ b/crates/stdlib/src/_asyncio.rs @@ -12,8 +12,8 @@ pub(crate) mod _asyncio { vm::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{ - PyBaseException, PyBaseExceptionRef, PyDict, PyDictRef, PyGenericAlias, PyList, - PyListRef, PyModule, PySet, PyTuple, PyType, PyTypeRef, + PyBaseException, PyBaseExceptionRef, PyDict, PyGenericAlias, PyList, PyListRef, + PyModule, PySet, PyTuple, PyType, PyTypeRef, }, extend_module, function::{FuncArgs, KwArgs, OptionalArg, OptionalOption, PySetterValue}, @@ -779,7 +779,7 @@ pub(crate) mod _asyncio { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -1036,7 +1036,7 @@ pub(crate) mod _asyncio { ))); } - let exc = if exc_type.fast_isinstance(vm.ctx.types.type_type) { + let exc: PyBaseExceptionRef = if exc_type.fast_isinstance(vm.ctx.types.type_type) { // exc_type is a class let exc_class: PyTypeRef = exc_type.clone().downcast().unwrap(); // Must be a subclass of BaseException @@ -1047,12 +1047,23 @@ pub(crate) mod _asyncio { } let val = exc_val.unwrap_or_none(vm); - if vm.is_none(&val) { + let exc = if vm.is_none(&val) { exc_type.call((), vm)? } else if val.fast_isinstance(&exc_class) { val } else { exc_type.call((val,), vm)? + }; + match exc.downcast() { + Ok(exc) => exc, + Err(obj) => { + let exc_class_repr = exc_class.as_object().repr(vm)?; + vm.new_type_error(format!( + "calling {} should have returned an instance of BaseException, not {}", + exc_class_repr.as_wtf8(), + obj.class() + )) + } } } else if exc_type.fast_isinstance(vm.ctx.exceptions.base_exception_type) { // exc_type is an exception instance @@ -1063,7 +1074,7 @@ pub(crate) mod _asyncio { vm.new_type_error("instance exception may not have a separate value") ); } - exc_type + exc_type.downcast().unwrap() } else { // exc_type is neither a class nor an exception instance return Err(vm.new_type_error(format!( @@ -1075,10 +1086,11 @@ pub(crate) mod _asyncio { if let OptionalArg::Present(tb) = exc_tb && !vm.is_none(&tb) { - exc.set_attr(vm.ctx.intern_str("__traceback__"), tb, vm)?; + exc.as_object() + .set_attr(vm.ctx.intern_str("__traceback__"), tb, vm)?; } - Err(exc.downcast().unwrap()) + Err(exc) } #[pymethod] @@ -1840,7 +1852,7 @@ pub(crate) mod _asyncio { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -2405,7 +2417,9 @@ pub(crate) mod _asyncio { // Slow path: look up in the module-level dict for cross-thread queries let current_tasks = get_current_tasks_dict(vm)?; - let dict: PyDictRef = current_tasks.downcast().unwrap(); + let Ok(dict) = current_tasks.downcast::() else { + return Ok(vm.ctx.none()); + }; match dict.get_item(&*loop_obj, vm) { Ok(task) => Ok(task), @@ -2485,15 +2499,17 @@ pub(crate) mod _asyncio { #[pyfunction] fn _enter_task(loop_: PyObjectRef, task: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { // Per-thread check, matching CPython's ts->asyncio_running_task - { - let running_task = vm.asyncio_running_task.borrow(); - if running_task.is_some() { - return Err(vm.new_runtime_error(format!( - "Cannot enter into task {:?} while another task {:?} is being executed.", - task, - running_task.as_ref().unwrap() - ))); - } + let running_task = vm.asyncio_running_task.borrow().clone(); + if let Some(running_task) = running_task { + let task_repr = task.repr(vm)?; + let running_task_repr = running_task.repr(vm)?; + return Err(vm.new_runtime_error(wtf8_concat!( + "Cannot enter into task ", + task_repr.as_wtf8(), + " while another task ", + running_task_repr.as_wtf8(), + " is being executed." + ))); } *vm.asyncio_running_task.borrow_mut() = Some(task.clone()); diff --git a/crates/stdlib/src/_queue.rs b/crates/stdlib/src/_queue.rs index 6b150e4c6..1c8a4b0b2 100644 --- a/crates/stdlib/src/_queue.rs +++ b/crates/stdlib/src/_queue.rs @@ -282,7 +282,7 @@ mod _queue { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index f2a16d723..094e69066 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -1234,7 +1234,7 @@ pub mod array { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/stdlib/src/blake2.rs b/crates/stdlib/src/blake2.rs index 382aec826..835044356 100644 --- a/crates/stdlib/src/blake2.rs +++ b/crates/stdlib/src/blake2.rs @@ -5,7 +5,7 @@ pub(crate) use _blake2::module_def; #[pymodule] mod _blake2 { use crate::hashlib::_hashlib::{BlakeHashArgs, local_blake2b, local_blake2s}; - use crate::vm::{PyPayload, PyResult, VirtualMachine}; + use crate::vm::{Py, PyPayload, PyResult, VirtualMachine, builtins::PyModule}; #[pyattr(name = "_GIL_MINSIZE")] const GIL_MINSIZE: u16 = 2048; @@ -43,4 +43,11 @@ mod _blake2 { fn blake2s(args: BlakeHashArgs, vm: &VirtualMachine) -> PyResult { Ok(local_blake2s(args, vm)?.into_pyobject(vm)) } + + #[expect(clippy::unnecessary_wraps, reason = "Needs to comply with a signature")] + pub(crate) fn module_exec(vm: &VirtualMachine, module: &Py) -> PyResult<()> { + let _ = vm.import("_hashlib", 0); + __module_exec(vm, module); + Ok(()) + } } diff --git a/crates/stdlib/src/contextvars.rs b/crates/stdlib/src/contextvars.rs index 0a6e0f123..19fbcb841 100644 --- a/crates/stdlib/src/contextvars.rs +++ b/crates/stdlib/src/contextvars.rs @@ -462,7 +462,7 @@ mod _contextvars { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -562,7 +562,7 @@ mod _contextvars { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index 4271d9af6..3471f7a28 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -779,11 +779,16 @@ mod _csv { // TODO: Maybe need to update the obj from HashMap } DialectItem::Obj(o) => Ok(self.update_py_dialect(o.clone())), - DialectItem::None => { - let g = GLOBAL_HASHMAP.lock(); - let res = g.get("excel").unwrap().clone(); - Ok(self.update_py_dialect(res)) - } + DialectItem::None => Ok(self.update_py_dialect(PyDialect { + delimiter: b',', + quotechar: Some(b'"'), + escapechar: None, + doublequote: true, + skipinitialspace: false, + lineterminator: "\r\n".to_owned(), + quoting: QuoteStyle::Minimal, + strict: false, + })), } } diff --git a/crates/stdlib/src/lzma.rs b/crates/stdlib/src/lzma.rs index 0b699badd..6e8a913ab 100644 --- a/crates/stdlib/src/lzma.rs +++ b/crates/stdlib/src/lzma.rs @@ -337,40 +337,43 @@ mod _lzma { } fn parse_filter_chain_spec( - filter_specs: Vec, + filter_specs: PyObjectRef, vm: &VirtualMachine, ) -> PyResult { const LZMA_FILTERS_MAX: usize = 4; - if filter_specs.len() > LZMA_FILTERS_MAX { + let filter_specs_len = filter_specs.length(vm)?; + if filter_specs_len > LZMA_FILTERS_MAX { return Err(new_lzma_error( format!("Too many filters - liblzma supports a maximum of {LZMA_FILTERS_MAX}"), vm, )); } + let filter_specs = filter_specs.try_sequence(vm)?; let mut filters = Filters::new(); - for spec in &filter_specs { - let filter_id = get_dict_opt_u64(spec, "id", vm)? + for i in 0..filter_specs_len { + let spec = filter_specs.get_item(i as isize, vm)?; + let filter_id = get_dict_opt_u64(&spec, "id", vm)? .ok_or_else(|| vm.new_value_error("Filter specifier must have an \"id\" entry"))?; match filter_id { FILTER_LZMA1 => { - let opts = parse_filter_spec_lzma(spec, vm)?; + let opts = parse_filter_spec_lzma(&spec, vm)?; filters.lzma1(&opts); } FILTER_LZMA2 => { - let opts = parse_filter_spec_lzma(spec, vm)?; + let opts = parse_filter_spec_lzma(&spec, vm)?; filters.lzma2(&opts); } FILTER_DELTA => { - let dist = parse_filter_spec_delta(spec, vm)?; + let dist = parse_filter_spec_delta(&spec, vm)?; filters .delta_properties(&[(dist - 1) as u8]) .map_err(|e| catch_lzma_error(e, vm))?; } FILTER_X86 | FILTER_POWERPC | FILTER_IA64 | FILTER_ARM | FILTER_ARMTHUMB | FILTER_SPARC => { - let start_offset = parse_filter_spec_bcj(spec, vm)?; + let start_offset = parse_filter_spec_bcj(&spec, vm)?; add_bcj_filter(&mut filters, filter_id, start_offset) .map_err(|e| catch_lzma_error(e, vm))?; } @@ -570,7 +573,7 @@ mod _lzma { #[pyarg(any, optional)] memlimit: Option, #[pyarg(any, optional)] - filters: Option>, + filters: Option, } impl Constructor for LZMADecompressor { @@ -735,7 +738,7 @@ mod _lzma { fn init_xz( check: i32, preset: u32, - filters: Option>, + filters: Option, vm: &VirtualMachine, ) -> PyResult { let real_check = @@ -751,10 +754,11 @@ mod _lzma { fn init_alone( preset: u32, - filter_specs: Option>, + filter_specs: Option, vm: &VirtualMachine, ) -> PyResult { - if let Some(_filter_specs) = filter_specs { + if let Some(filter_specs) = filter_specs { + filter_specs.length(vm)?; // TODO: validate single LZMA1 filter and use its options let options = LzmaOptions::new_preset(preset).map_err(|_| { new_lzma_error(format!("Invalid compression preset: {preset}"), vm) @@ -768,10 +772,7 @@ mod _lzma { } } - fn init_raw( - filter_specs: Option>, - vm: &VirtualMachine, - ) -> PyResult { + fn init_raw(filter_specs: Option, vm: &VirtualMachine) -> PyResult { let filter_specs = filter_specs .ok_or_else(|| vm.new_value_error("Must specify filters for FORMAT_RAW"))?; let filters = parse_filter_chain_spec(filter_specs, vm)?; @@ -788,7 +789,7 @@ mod _lzma { #[pyarg(any, optional)] preset: Option, #[pyarg(any, optional)] - filters: Option>, + filters: Option, } impl Constructor for LZMACompressor { diff --git a/crates/stdlib/src/math.rs b/crates/stdlib/src/math.rs index 92c2a66e9..3fe1ffd3e 100644 --- a/crates/stdlib/src/math.rs +++ b/crates/stdlib/src/math.rs @@ -727,25 +727,20 @@ mod math { } // Generic Python path - let (p_i, q_i) = (p_i.unwrap(), q_i.unwrap()); - - // Collect current + remaining elements - let p_remaining: Result, _> = - core::iter::once(Ok(p_i)).chain(p_iter).collect(); - let q_remaining: Result, _> = - core::iter::once(Ok(q_i)).chain(q_iter).collect(); - let (p_vec, q_vec) = (p_remaining?, q_remaining?); - - if p_vec.len() != q_vec.len() { - return Err(vm.new_value_error("Inputs are not the same length")); - } - + let (mut p_i, mut q_i) = (p_i.unwrap(), q_i.unwrap()); let mut total = obj_total.unwrap_or_else(|| vm.ctx.new_int(0).into()); - for (p_item, q_item) in p_vec.into_iter().zip(q_vec) { - let prod = vm._mul(&p_item, &q_item)?; + loop { + let prod = vm._mul(&p_i, &q_i)?; total = vm._add(&total, &prod)?; + + let next_p = p_iter.next().transpose()?; + let next_q = q_iter.next().transpose()?; + match (next_p, next_q) { + (Some(next_p), Some(next_q)) => (p_i, q_i) = (next_p, next_q), + (None, None) => return Ok(total), + _ => return Err(vm.new_value_error("Inputs are not the same length")), + } } - return Ok(total); } Ok(obj_total.unwrap_or_else(|| vm.ctx.new_int(0).into())) diff --git a/crates/stdlib/src/md5.rs b/crates/stdlib/src/md5.rs index 2ff6cd24f..0339bf8ac 100644 --- a/crates/stdlib/src/md5.rs +++ b/crates/stdlib/src/md5.rs @@ -3,10 +3,17 @@ pub(crate) use _md5::module_def; #[pymodule] mod _md5 { use crate::hashlib::_hashlib::{HashArgs, local_md5}; - use crate::vm::{PyPayload, PyResult, VirtualMachine}; + use crate::vm::{Py, PyPayload, PyResult, VirtualMachine, builtins::PyModule}; #[pyfunction] fn md5(args: HashArgs, vm: &VirtualMachine) -> PyResult { Ok(local_md5(args, vm)?.into_pyobject(vm)) } + + #[expect(clippy::unnecessary_wraps, reason = "Needs to comply with a signature")] + pub(crate) fn module_exec(vm: &VirtualMachine, module: &Py) -> PyResult<()> { + let _ = vm.import("_hashlib", 0); + __module_exec(vm, module); + Ok(()) + } } diff --git a/crates/stdlib/src/mmap.rs b/crates/stdlib/src/mmap.rs index 312d35a4e..91d4058a7 100644 --- a/crates/stdlib/src/mmap.rs +++ b/crates/stdlib/src/mmap.rs @@ -777,7 +777,10 @@ mod mmap { let start = options .start .map_or_else(|| self.pos(), |start| start.saturated_at(size)); - let end = options.end.map_or(size, |end| end.saturated_at(size)); + let end = options + .end + .map_or(size, |end| end.saturated_at(size)) + .max(start); (start, end) } @@ -886,7 +889,7 @@ mod mmap { let dest = dest.try_to_primitive(vm).ok()?; let src = src.try_to_primitive(vm).ok()?; let cnt = cnt.try_to_primitive(vm).ok()?; - if size - dest < cnt || size - src < cnt { + if dest > size || src > size || size - dest < cnt || size - src < cnt { return None; } Some((dest, src, cnt)) diff --git a/crates/stdlib/src/sha1.rs b/crates/stdlib/src/sha1.rs index 3e3d4928c..71495435e 100644 --- a/crates/stdlib/src/sha1.rs +++ b/crates/stdlib/src/sha1.rs @@ -3,10 +3,17 @@ pub(crate) use _sha1::module_def; #[pymodule] mod _sha1 { use crate::hashlib::_hashlib::{HashArgs, local_sha1}; - use crate::vm::{PyPayload, PyResult, VirtualMachine}; + use crate::vm::{Py, PyPayload, PyResult, VirtualMachine, builtins::PyModule}; #[pyfunction] fn sha1(args: HashArgs, vm: &VirtualMachine) -> PyResult { Ok(local_sha1(args, vm)?.into_pyobject(vm)) } + + #[expect(clippy::unnecessary_wraps, reason = "Needs to comply with a signature")] + pub(crate) fn module_exec(vm: &VirtualMachine, module: &Py) -> PyResult<()> { + let _ = vm.import("_hashlib", 0); + __module_exec(vm, module); + Ok(()) + } } diff --git a/crates/stdlib/src/sha3.rs b/crates/stdlib/src/sha3.rs index 0eb2dfa84..642ed838a 100644 --- a/crates/stdlib/src/sha3.rs +++ b/crates/stdlib/src/sha3.rs @@ -6,7 +6,7 @@ mod _sha3 { HashArgs, local_sha3_224, local_sha3_256, local_sha3_384, local_sha3_512, local_shake_128, local_shake_256, }; - use crate::vm::{PyPayload, PyResult, VirtualMachine}; + use crate::vm::{Py, PyPayload, PyResult, VirtualMachine, builtins::PyModule}; #[pyfunction] fn sha3_224(args: HashArgs, vm: &VirtualMachine) -> PyResult { @@ -37,4 +37,11 @@ mod _sha3 { fn shake_256(args: HashArgs, vm: &VirtualMachine) -> PyResult { Ok(local_shake_256(args, vm)?.into_pyobject(vm)) } + + #[expect(clippy::unnecessary_wraps, reason = "Needs to comply with a signature")] + pub(crate) fn module_exec(vm: &VirtualMachine, module: &Py) -> PyResult<()> { + let _ = vm.import("_hashlib", 0); + __module_exec(vm, module); + Ok(()) + } } diff --git a/crates/stdlib/src/suggestions.rs b/crates/stdlib/src/suggestions.rs index e0667dfb5..bfde00d2b 100644 --- a/crates/stdlib/src/suggestions.rs +++ b/crates/stdlib/src/suggestions.rs @@ -2,19 +2,25 @@ pub(crate) use _suggestions::module_def; #[pymodule] mod _suggestions { - use rustpython_vm::VirtualMachine; + use rustpython_vm::{PyResult, VirtualMachine, builtins::PyList}; use crate::vm::PyObjectRef; #[pyfunction] fn _generate_suggestions( - candidates: Vec, + candidates: PyObjectRef, name: PyObjectRef, vm: &VirtualMachine, - ) -> PyObjectRef { - match crate::vm::suggestion::calculate_suggestions(candidates.iter(), &name) { - Some(suggestion) => suggestion.into(), - None => vm.ctx.none(), - } + ) -> PyResult { + let candidates = candidates + .downcast::() + .map_err(|_| vm.new_type_error("candidates must be a list"))?; + let candidates = candidates.borrow_vec(); + Ok( + match crate::vm::suggestion::calculate_suggestions(candidates.iter(), &name) { + Some(suggestion) => suggestion.into(), + None => vm.ctx.none(), + }, + ) } } diff --git a/crates/vm/src/builtins/asyncgenerator.rs b/crates/vm/src/builtins/asyncgenerator.rs index b53e59d58..7ea43f389 100644 --- a/crates/vm/src/builtins/asyncgenerator.rs +++ b/crates/vm/src/builtins/asyncgenerator.rs @@ -144,7 +144,11 @@ impl PyAsyncGen { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/bytearray.rs b/crates/vm/src/builtins/bytearray.rs index a649fe9d8..793b269d1 100644 --- a/crates/vm/src/builtins/bytearray.rs +++ b/crates/vm/src/builtins/bytearray.rs @@ -554,7 +554,11 @@ impl PyByteArray { // TODO: Uncomment when Python adds __class_getitem__ to bytearray // #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/bytes.rs b/crates/vm/src/builtins/bytes.rs index d4c30a7e9..bb514b84c 100644 --- a/crates/vm/src/builtins/bytes.rs +++ b/crates/vm/src/builtins/bytes.rs @@ -544,7 +544,11 @@ impl PyBytes { // TODO: Uncomment when Python adds __class_getitem__ to bytes // #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/classmethod.rs b/crates/vm/src/builtins/classmethod.rs index eb0e15ece..26dcd2512 100644 --- a/crates/vm/src/builtins/classmethod.rs +++ b/crates/vm/src/builtins/classmethod.rs @@ -27,7 +27,7 @@ use crate::{ /// /// Class methods are different than C++ or Java static methods. /// If you want those, see the staticmethod builtin. -#[pyclass(module = false, name = "classmethod")] +#[pyclass(module = false, name = "classmethod", traverse)] #[derive(Debug)] pub struct PyClassMethod { callable: PyMutex, @@ -187,7 +187,11 @@ impl PyClassMethod { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/coroutine.rs b/crates/vm/src/builtins/coroutine.rs index d472f1a0b..0fc50fb13 100644 --- a/crates/vm/src/builtins/coroutine.rs +++ b/crates/vm/src/builtins/coroutine.rs @@ -103,7 +103,11 @@ impl PyCoroutine { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index fbc23a0dd..1a380d74d 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -535,7 +535,11 @@ impl PyDict { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/enumerate.rs b/crates/vm/src/builtins/enumerate.rs index 96073ba76..95e144dad 100644 --- a/crates/vm/src/builtins/enumerate.rs +++ b/crates/vm/src/builtins/enumerate.rs @@ -57,7 +57,11 @@ impl Constructor for PyEnumerate { #[pyclass(with(Py, IterNext, Iterable, Constructor), flags(BASETYPE))] impl PyEnumerate { #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/generator.rs b/crates/vm/src/builtins/generator.rs index 52db3c952..b06a3a45e 100644 --- a/crates/vm/src/builtins/generator.rs +++ b/crates/vm/src/builtins/generator.rs @@ -99,7 +99,11 @@ impl PyGenerator { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/genericalias.rs b/crates/vm/src/builtins/genericalias.rs index b6f6012fd..8004bd535 100644 --- a/crates/vm/src/builtins/genericalias.rs +++ b/crates/vm/src/builtins/genericalias.rs @@ -68,7 +68,7 @@ impl Constructor for PyGenericAlias { } else { PyTuple::new_ref(vec![arguments], &vm.ctx) }; - Ok(Self::new(origin, args, false, vm)) + Self::new(origin, args, false, vm) } } @@ -92,14 +92,14 @@ impl PyGenericAlias { args: PyTupleRef, starred: bool, vm: &VirtualMachine, - ) -> Self { - let parameters = make_parameters(&args, vm); - Self { + ) -> PyResult { + let parameters = make_parameters(&args, vm)?; + Ok(Self { origin: origin.into(), args, parameters, starred, - } + }) } /// Create a GenericAlias from an origin and PyObjectRef arguments (helper for compatibility) @@ -107,7 +107,7 @@ impl PyGenericAlias { origin: impl Into, args: PyObjectRef, vm: &VirtualMachine, - ) -> Self { + ) -> PyResult { let args = if let Ok(tuple) = args.try_to_ref::(vm) { tuple.to_owned() } else { @@ -228,7 +228,7 @@ impl PyGenericAlias { vm, )?; - Ok(Self::new(zelf.origin.clone(), new_args, false, vm).into_pyobject(vm)) + Ok(Self::new(zelf.origin.clone(), new_args, false, vm)?.into_pyobject(vm)) } #[pymethod] @@ -247,7 +247,7 @@ impl PyGenericAlias { if zelf.starred { // (next, (iter(GenericAlias(origin, args)),)) let next_fn = vm.builtins.get_attr("next", vm)?; - let non_starred = Self::new(zelf.origin.clone(), zelf.args.clone(), false, vm); + let non_starred = Self::new(zelf.origin.clone(), zelf.args.clone(), false, vm)?; let iter_obj = PyGenericAliasIterator { obj: crate::common::lock::PyMutex::new(Some(non_starred.into_pyobject(vm))), } @@ -292,11 +292,11 @@ impl PyGenericAlias { } } -pub(crate) fn make_parameters(args: &Py, vm: &VirtualMachine) -> PyTupleRef { +pub(crate) fn make_parameters(args: &Py, vm: &VirtualMachine) -> PyResult { make_parameters_from_slice(args.as_slice(), vm) } -fn make_parameters_from_slice(args: &[PyObjectRef], vm: &VirtualMachine) -> PyTupleRef { +fn make_parameters_from_slice(args: &[PyObjectRef], vm: &VirtualMachine) -> PyResult { let mut parameters: Vec = Vec::with_capacity(args.len()); for arg in args { @@ -326,7 +326,9 @@ fn make_parameters_from_slice(args: &[PyObjectRef], vm: &VirtualMachine) -> PyTu let list = arg.downcast_ref::().unwrap(); list.borrow_vec().to_vec() }; - let sub = make_parameters_from_slice(&items, vm); + let sub = vm.with_recursion("while computing __parameters__", || { + make_parameters_from_slice(&items, vm) + })?; for sub_param in sub.iter() { if tuple_index(¶meters, sub_param).is_none() { parameters.push(sub_param.clone()); @@ -335,7 +337,7 @@ fn make_parameters_from_slice(args: &[PyObjectRef], vm: &VirtualMachine) -> PyTu } } - PyTuple::new_ref(parameters, &vm.ctx) + Ok(PyTuple::new_ref(parameters, &vm.ctx)) } #[inline] @@ -716,7 +718,7 @@ impl crate::types::IterNext for PyGenericAliasIterator { let alias = obj .downcast_ref::() .ok_or_else(|| vm.new_type_error("generic_alias_iterator expected GenericAlias"))?; - let starred = PyGenericAlias::new(alias.origin.clone(), alias.args.clone(), true, vm); + let starred = PyGenericAlias::new(alias.origin.clone(), alias.args.clone(), true, vm)?; Ok(PyIterReturn::Return(starred.into_pyobject(vm))) } } diff --git a/crates/vm/src/builtins/interpolation.rs b/crates/vm/src/builtins/interpolation.rs index 0ae1b3312..5d5f37746 100644 --- a/crates/vm/src/builtins/interpolation.rs +++ b/crates/vm/src/builtins/interpolation.rs @@ -144,7 +144,11 @@ impl PyInterpolation { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } diff --git a/crates/vm/src/builtins/list.rs b/crates/vm/src/builtins/list.rs index c2059e288..fe674a458 100644 --- a/crates/vm/src/builtins/list.rs +++ b/crates/vm/src/builtins/list.rs @@ -421,7 +421,11 @@ impl PyList { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/mappingproxy.rs b/crates/vm/src/builtins/mappingproxy.rs index c8b891f79..dd8c689fa 100644 --- a/crates/vm/src/builtins/mappingproxy.rs +++ b/crates/vm/src/builtins/mappingproxy.rs @@ -177,7 +177,11 @@ impl PyMappingProxy { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } diff --git a/crates/vm/src/builtins/memory.rs b/crates/vm/src/builtins/memory.rs index ee5a07128..9f8312a07 100644 --- a/crates/vm/src/builtins/memory.rs +++ b/crates/vm/src/builtins/memory.rs @@ -554,7 +554,11 @@ impl Py { )] impl PyMemoryView { #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } diff --git a/crates/vm/src/builtins/range.rs b/crates/vm/src/builtins/range.rs index 415d34fdb..5962f90e5 100644 --- a/crates/vm/src/builtins/range.rs +++ b/crates/vm/src/builtins/range.rs @@ -364,7 +364,11 @@ impl PyRange { // TODO: Uncomment when Python adds __class_getitem__ to range // #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index 6961040c7..860f86f43 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -386,7 +386,8 @@ impl PySetInner { } fn repr(&self, class_name: Option<&str>, vm: &VirtualMachine) -> PyResult { - collection_repr(class_name, "{", "}", self.elements().iter(), vm) + let empty = format!("{}()", class_name.unwrap_or("set")); + collection_repr(class_name, "{", "}", &empty, self.elements().iter(), vm) } fn add(&self, item: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { @@ -881,7 +882,11 @@ impl PySet { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -1286,7 +1291,11 @@ impl PyFrozenSet { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/slice.rs b/crates/vm/src/builtins/slice.rs index 3c5f13b38..026b976b6 100644 --- a/crates/vm/src/builtins/slice.rs +++ b/crates/vm/src/builtins/slice.rs @@ -260,7 +260,11 @@ impl PySlice { // TODO: Uncomment when Python adds __class_getitem__ to slice // #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/staticmethod.rs b/crates/vm/src/builtins/staticmethod.rs index addfe8a4e..8ae31b67b 100644 --- a/crates/vm/src/builtins/staticmethod.rs +++ b/crates/vm/src/builtins/staticmethod.rs @@ -163,7 +163,11 @@ impl PyStaticMethod { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/template.rs b/crates/vm/src/builtins/template.rs index 30812b4f1..94c4d653d 100644 --- a/crates/vm/src/builtins/template.rs +++ b/crates/vm/src/builtins/template.rs @@ -186,7 +186,11 @@ impl PyTemplate { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } diff --git a/crates/vm/src/builtins/tuple.rs b/crates/vm/src/builtins/tuple.rs index 06fa25192..7af176840 100644 --- a/crates/vm/src/builtins/tuple.rs +++ b/crates/vm/src/builtins/tuple.rs @@ -504,7 +504,11 @@ impl PyTuple { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -609,7 +613,7 @@ impl Representable for PyTuple { let s = if zelf.len() == 1 { wtf8_concat!("(", zelf.elements[0].repr(vm)?.as_wtf8(), ",)") } else { - collection_repr(None, "(", ")", zelf.elements.iter(), vm)? + collection_repr(None, "(", ")", "()", zelf.elements.iter(), vm)? }; vm.ctx.new_str(s) } else { diff --git a/crates/vm/src/builtins/union.rs b/crates/vm/src/builtins/union.rs index cb6dd0d65..c1be5c8ec 100644 --- a/crates/vm/src/builtins/union.rs +++ b/crates/vm/src/builtins/union.rs @@ -234,7 +234,7 @@ pub(crate) fn or_op(zelf: PyObjectRef, other: PyObjectRef, vm: &VirtualMachine) } fn make_parameters(args: &Py, vm: &VirtualMachine) -> PyResult { - let parameters = genericalias::make_parameters(args, vm); + let parameters = genericalias::make_parameters(args, vm)?; let result = dedup_and_flatten_args(¶meters, vm)?; Ok(result.args) } diff --git a/crates/vm/src/builtins/weakref.rs b/crates/vm/src/builtins/weakref.rs index 9e88ffaa2..e0f012f16 100644 --- a/crates/vm/src/builtins/weakref.rs +++ b/crates/vm/src/builtins/weakref.rs @@ -92,7 +92,11 @@ impl PyWeak { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/exception_group.rs b/crates/vm/src/exception_group.rs index c6d18cc65..11c13912b 100644 --- a/crates/vm/src/exception_group.rs +++ b/crates/vm/src/exception_group.rs @@ -60,7 +60,7 @@ pub(super) mod types { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } @@ -255,20 +255,11 @@ pub(super) mod types { ))); } - // Validate exceptions is a sequence (not set or None) + // Validate exceptions is a sequence let exceptions_arg = &args[1]; - - // Check for set/frozenset (not a sequence - unordered) - if exceptions_arg.fast_isinstance(vm.ctx.types.set_type) - || exceptions_arg.fast_isinstance(vm.ctx.types.frozenset_type) - { - return Err(vm.new_type_error("second argument (exceptions) must be a sequence")); - } - - // Check for None - if exceptions_arg.is(&vm.ctx.none) { - return Err(vm.new_type_error("second argument (exceptions) must be a sequence")); - } + exceptions_arg.try_sequence(vm).map_err(|_| { + vm.new_type_error("second argument (exceptions) must be a sequence") + })?; let exceptions: Vec = exceptions_arg.try_to_value(vm).map_err(|_| { vm.new_type_error("second argument (exceptions) must be a sequence") diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index 9c42df966..0a1c2cb75 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -988,6 +988,9 @@ impl ExceptionZoo { extend_exception!(PyImportError, ctx, excs.import_error, { "msg" => ctx.new_readonly_getset("msg", excs.import_error, make_arg_getter(0)), + "name" => ctx.none(), + "path" => ctx.none(), + "name_from" => ctx.none(), }); extend_exception!(PyModuleNotFoundError, ctx, excs.module_not_found_error); @@ -1908,10 +1911,11 @@ pub(super) mod types { #[pymethod] fn __reduce__(exc: PyBaseExceptionRef, vm: &VirtualMachine) -> PyTupleRef { let obj = exc.as_object().to_owned(); - let mut result: Vec = vec![ - obj.class().to_owned().into(), - vm.new_tuple((exc.get_arg(0).unwrap(),)).into(), - ]; + let args: PyObjectRef = match exc.get_arg(0) { + Some(arg) => vm.new_tuple((arg,)).into(), + None => exc.args().into(), + }; + let mut result: Vec = vec![obj.class().to_owned().into(), args]; if let Some(dict) = obj.dict().filter(|x| !x.is_empty()) { result.push(dict.into()); @@ -1938,10 +1942,21 @@ pub(super) mod types { ))); } - let dict = crate::builtins::object::object_get_dict(zelf.clone(), vm)?; - dict.set_item("name", vm.unwrap_or_none(name), vm)?; - dict.set_item("path", vm.unwrap_or_none(path), vm)?; - dict.set_item("name_from", vm.unwrap_or_none(name_from), vm)?; + if let Some(name) = name { + zelf.set_attr("name", name, vm)?; + } else if let Some(dict) = zelf.dict() { + dict.del_item("name", vm).ok(); + } + if let Some(path) = path { + zelf.set_attr("path", path, vm)?; + } else if let Some(dict) = zelf.dict() { + dict.del_item("path", vm).ok(); + } + if let Some(name_from) = name_from { + zelf.set_attr("name_from", name_from, vm)?; + } else if let Some(dict) = zelf.dict() { + dict.del_item("name_from", vm).ok(); + } PyBaseException::slot_init(zelf, args, vm) } diff --git a/crates/vm/src/object/ext.rs b/crates/vm/src/object/ext.rs index 69ee0e3c5..186fa8e8a 100644 --- a/crates/vm/src/object/ext.rs +++ b/crates/vm/src/object/ext.rs @@ -269,13 +269,16 @@ cfg_select! { _ => {} } -impl fmt::Debug for PyAtomicRef { +impl fmt::Debug for PyAtomicRef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "PyAtomicRef(")?; + // The stored pointer is a `Py` — the full object, header included — + // as `Deref`, `load_raw` and `swap` all read it. Formatting it as a + // bare payload would skip the header and print misaligned bytes. unsafe { self.inner .load(Ordering::Relaxed) - .cast::() + .cast::() .as_ref() .fmt(f) }?; diff --git a/crates/vm/src/protocol/iter.rs b/crates/vm/src/protocol/iter.rs index 2f51287b1..1aa0bcd5b 100644 --- a/crates/vm/src/protocol/iter.rs +++ b/crates/vm/src/protocol/iter.rs @@ -16,7 +16,11 @@ where unsafe impl> Traverse for PyIter { fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { - self.0.borrow().traverse(tracer_fn); + // Report the iterator itself, not its referents: an owner holding a + // `PyIter` owns the iterator object, and reporting what the iterator + // points at instead leaves the iterator's own reference unaccounted + // for, so a cycle running through it is never collected. + tracer_fn(self.0.borrow()); } } diff --git a/crates/vm/src/protocol/object.rs b/crates/vm/src/protocol/object.rs index 370074224..4974fca93 100644 --- a/crates/vm/src/protocol/object.rs +++ b/crates/vm/src/protocol/object.rs @@ -7,7 +7,7 @@ use crate::{ PyType, PyTypeRef, PyUtf8Str, int::check_int_to_str_digits, pystr::AsPyStr, }, common::{hash::PyHash, str::to_ascii}, - convert::{ToPyObject, ToPyResult}, + convert::ToPyObject, dict_inner::DictKey, function::{Either, FuncArgs, PyArithmeticValue, PySetterValue}, object::PyPayload, @@ -694,7 +694,7 @@ impl PyObject { pub fn hash(&self, vm: &VirtualMachine) -> PyResult { if let Some(hash) = self.class().slots.hash.load() { - return hash(self, vm); + return vm.with_recursion("while hashing", || hash(self, vm)); } Err(vm.new_type_error(format!("unhashable type: '{}'", self.class().name()))) @@ -741,8 +741,8 @@ impl PyObject { } else { if self.class().fast_issubclass(vm.ctx.types.type_type) { if self.is(vm.ctx.types.type_type) { - return PyGenericAlias::from_args(self.class().to_owned(), needle, vm) - .to_pyresult(vm); + let alias = PyGenericAlias::from_args(self.class().to_owned(), needle, vm)?; + return Ok(alias.to_pyobject(vm)); } if let Some(class_getitem) = diff --git a/crates/vm/src/sequence.rs b/crates/vm/src/sequence.rs index 4e6ed97f2..1e126d087 100644 --- a/crates/vm/src/sequence.rs +++ b/crates/vm/src/sequence.rs @@ -104,7 +104,12 @@ where return Err(vm.new_memory_error("")); } - let mut v = Vec::with_capacity(n * self.as_ref().len()); + let total = n + .checked_mul(self.as_ref().len()) + .ok_or_else(|| vm.new_memory_error(""))?; + let mut v = Vec::new(); + v.try_reserve_exact(total) + .map_err(|_| vm.new_memory_error(""))?; for _ in 0..n { v.extend_from_slice(self.as_ref()); } diff --git a/crates/vm/src/stdlib/_ast/pyast.rs b/crates/vm/src/stdlib/_ast/pyast.rs index eb97eec80..ebce1a788 100644 --- a/crates/vm/src/stdlib/_ast/pyast.rs +++ b/crates/vm/src/stdlib/_ast/pyast.rs @@ -1718,12 +1718,16 @@ fn populate_field_types(vm: &VirtualMachine, module: &Py) { FieldType::ListOf(name) => { let elem = resolve_node(name); let args = PyTuple::new_ref(vec![elem], &vm.ctx); - PyGenericAlias::new(list_type.clone(), args, false, vm).to_pyobject(vm) + PyGenericAlias::new(list_type.clone(), args, false, vm) + .expect("static field types are not nested, so no recursion is possible") + .to_pyobject(vm) } FieldType::ListOfBuiltin(name) => { let elem = resolve_builtin(name); let args = PyTuple::new_ref(vec![elem], &vm.ctx); - PyGenericAlias::new(list_type.clone(), args, false, vm).to_pyobject(vm) + PyGenericAlias::new(list_type.clone(), args, false, vm) + .expect("static field types are not nested, so no recursion is possible") + .to_pyobject(vm) } FieldType::Optional(name) => { let base = resolve_node(name); diff --git a/crates/vm/src/stdlib/_collections.rs b/crates/vm/src/stdlib/_collections.rs index c7cce5c73..b48c0e670 100644 --- a/crates/vm/src/stdlib/_collections.rs +++ b/crates/vm/src/stdlib/_collections.rs @@ -13,6 +13,7 @@ mod _collections { convert::ToPyObject, function::{FuncArgs, KwArgs, OptionalArg, PyComparisonValue}, iter::PyExactSizeIterator, + object::{Traverse, TraverseFn}, protocol::{PyIterReturn, PyMappingMethods, PyNumberMethods, PySequenceMethods}, recursion::ReprGuard, sequence::{MutObjectSequenceOp, OptionalRangeArgs}, @@ -22,13 +23,19 @@ mod _collections { Initializer, IterNext, Iterable, PyComparisonOp, Representable, SelfIter, }, utils::collection_repr, + vm::MAX_MEMORY_SIZE, }; use alloc::collections::VecDeque; - use core::cmp::max; + use core::{cmp::max, mem::size_of}; use crossbeam_utils::atomic::AtomicCell; #[pyattr] - #[pyclass(module = "collections", name = "deque", unhashable = true)] + #[pyclass( + module = "collections", + name = "deque", + unhashable = true, + traverse = "manual" + )] #[derive(Debug, Default, PyPayload)] struct PyDeque { deque: PyRwLock>, @@ -36,6 +43,21 @@ mod _collections { state: AtomicCell, // incremented whenever the indices move } + // SAFETY: Traverse visits each owned Python reference at most once. + unsafe impl Traverse for PyDeque { + fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { + if let Some(deque) = self.deque.try_read_recursive() { + for obj in deque.iter() { + obj.traverse(tracer_fn); + } + } + } + + fn clear(&mut self, out: &mut Vec) { + out.extend(self.deque.get_mut().drain(..)); + } + } + type PyDequeRef = PyRef; #[derive(FromArgs)] @@ -318,6 +340,10 @@ mod _collections { let deque = self.borrow_deque(); let n = vm.check_repeat_or_overflow_error(deque.len(), n)?; let mul_len = n * deque.len(); + let result_len = self.maxlen.map_or(mul_len, |maxlen| mul_len.min(maxlen)); + if n > 1 && result_len.saturating_mul(size_of::()) >= MAX_MEMORY_SIZE { + return Err(vm.new_memory_error("")); + } let iter = deque.iter().cycle().take(mul_len); let skipped = self .maxlen @@ -400,7 +426,7 @@ mod _collections { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -576,9 +602,10 @@ mod _collections { let closing_part = zelf .maxlen .map_or_else(|| "]".to_owned(), |maxlen| format!("], maxlen={maxlen}")); + let empty = format!("{class_name}([{closing_part})"); if zelf.__len__() == 0 { - return Ok(vm.ctx.new_str(format!("{class_name}([{closing_part})"))); + return Ok(vm.ctx.new_str(empty)); } if let Some(_guard) = ReprGuard::enter(vm, zelf.as_object()) { @@ -586,6 +613,7 @@ mod _collections { Some(&class_name), "[", &closing_part, + &empty, deque.iter(), vm, )?)) @@ -753,7 +781,8 @@ mod _collections { module = "collections", name = "defaultdict", base = PyDict, - unhashable = true + unhashable = true, + traverse = "manual" )] #[derive(Debug, Default)] struct PyDefaultDict { @@ -761,6 +790,21 @@ mod _collections { default_factory: PyRwLock>, } + // SAFETY: Traverse visits each owned Python reference at most once. + unsafe impl Traverse for PyDefaultDict { + fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { + self.dict.traverse(tracer_fn); + self.default_factory.traverse(tracer_fn); + } + + fn clear(&mut self, out: &mut Vec) { + Traverse::clear(&mut self.dict, out); + if let Some(factory) = self.default_factory.get_mut().take() { + out.push(factory); + } + } + } + #[pyclass( with(AsMapping, AsNumber, Constructor, Initializer, Representable), flags(BASETYPE, MAPPING, HAS_DICT) diff --git a/crates/vm/src/stdlib/_ctypes.rs b/crates/vm/src/stdlib/_ctypes.rs index adf047ec7..e4857d0ee 100644 --- a/crates/vm/src/stdlib/_ctypes.rs +++ b/crates/vm/src/stdlib/_ctypes.rs @@ -141,7 +141,6 @@ pub(crate) mod _ctypes { ffi_value_from_type_code(code.encode_utf8(&mut buf), bytes) } super::CArgValue::Int(v) => FfiValue::I32(*v), - super::CArgValue::Double(v) => FfiValue::F64(*v), super::CArgValue::Pointer(v) => FfiValue::Pointer(*v), // 'V' aggregates format via the object-address default arm below. super::CArgValue::Aggregate { .. } => FfiValue::Pointer(0), diff --git a/crates/vm/src/stdlib/_ctypes/array.rs b/crates/vm/src/stdlib/_ctypes/array.rs index a99fabc81..c65f9748c 100644 --- a/crates/vm/src/stdlib/_ctypes/array.rs +++ b/crates/vm/src/stdlib/_ctypes/array.rs @@ -511,7 +511,11 @@ impl AsMapping for PyCArray { )] impl PyCArray { #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } @@ -990,13 +994,19 @@ impl PyCArray { let (range, step, slice_len) = sat_slice.adjust_indices(length); // other_len = PySequence_Length(value); - let items: Vec = vm.extract_elements_with(&value, Ok)?; - let other_len = items.len(); + // Size the operand before consuming it so an unbounded iterable is + // rejected without being materialized. + let other_len = value + .sequence_unchecked() + .length(vm) + .map_err(|_| vm.new_value_error("Can only assign sequence of same size"))?; if other_len != slice_len { return Err(vm.new_value_error("Can only assign sequence of same size")); } + let items: Vec = vm.extract_elements_with(&value, Ok)?; + // Use SaturatedSliceIter for correct index iteration (handles negative step) let iter = SaturatedSliceIter::from_adjust_indices(range, step, slice_len); diff --git a/crates/vm/src/stdlib/_ctypes/base.rs b/crates/vm/src/stdlib/_ctypes/base.rs index 1cc84750c..e86fdbc7a 100644 --- a/crates/vm/src/stdlib/_ctypes/base.rs +++ b/crates/vm/src/stdlib/_ctypes/base.rs @@ -1939,7 +1939,7 @@ fn struct_union_paramfunc(obj: &PyObject, stg_info: &StgInfo, _vm: &VirtualMachi /// A foreign-call argument in a form the unified `call` entry point accepts: a /// simple-typed scalar (its ctypes code plus a native-endian bytes snapshot), -/// an untyped int/float, or an address. Any object whose memory an address +/// an untyped int, or an address. Any object whose memory an address /// refers to is kept alive by the enclosing `Argument`/`CArgObject`, not here. #[derive(Debug, Clone)] pub enum CArgValue { @@ -1947,8 +1947,6 @@ pub enum CArgValue { Typed { code: char, bytes: Vec }, /// Untyped Python int (ConvParam default: C int). Int(i32), - /// Untyped Python float (ConvParam default: C double). - Double(f64), /// Address-valued argument (pointer decay, byref, buffer copies, NULL = 0). Pointer(usize), /// By-value aggregate: its call layout plus a snapshot of its bytes. @@ -1985,7 +1983,6 @@ impl CArgValue { buffer: bytes, }, Self::Int(value) => CallArg::Int(*value), - Self::Double(value) => CallArg::Double(*value), Self::Pointer(value) => CallArg::Pointer(*value), Self::Aggregate { layout, bytes } => CallArg::Aggregate { layout, diff --git a/crates/vm/src/stdlib/_ctypes/function.rs b/crates/vm/src/stdlib/_ctypes/function.rs index 676ee5be8..90b41a4e6 100644 --- a/crates/vm/src/stdlib/_ctypes/function.rs +++ b/crates/vm/src/stdlib/_ctypes/function.rs @@ -9,7 +9,7 @@ use super::{ }; use crate::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, - builtins::{PyBytes, PyDict, PyStr, PyTuple, PyType, PyTypeRef}, + builtins::{PyBytes, PyDict, PyInt, PyStr, PyTuple, PyType, PyTypeRef}, class::StaticType, function::FuncArgs, protocol::{BufferDescriptor, PyBuffer, PyNumberMethods}, @@ -171,7 +171,10 @@ fn conv_param(value: &PyObject, vm: &VirtualMachine) -> PyResult { } // 10. Python int -> i32 (default integer type) - if let Ok(int_val) = value.try_int(vm) { + // PyLong_Check: only an int (or a subclass) converts. Going through + // `__int__` would accept a float and pass its truncated value where the + // callee expects a pointer. + if let Some(int_val) = value.downcast_ref::() { let val = int_val.as_bigint().to_i32().unwrap_or(0); return Ok(Argument { keep: None, @@ -179,15 +182,7 @@ fn conv_param(value: &PyObject, vm: &VirtualMachine) -> PyResult { }); } - // 11. Python float -> f64 - if let Ok(float_val) = value.try_float(vm) { - return Ok(Argument { - keep: None, - value: CArgValue::Double(float_val.to_f64()), - }); - } - - // 12. Check _as_parameter_ attribute + // 11. Check _as_parameter_ attribute if let Ok(as_param) = value.get_attr("_as_parameter_", vm) { return conv_param(&as_param, vm); } @@ -939,6 +934,23 @@ struct CallInfo { ret: RetSpec, } +fn extract_arg_types(argtypes: &PyObject, vm: &VirtualMachine) -> PyResult> { + let error = || vm.new_type_error("_argtypes_ must be a sequence of types"); + let sequence = argtypes.try_sequence(vm).map_err(|_| error())?; + let length = sequence.length(vm).map_err(|_| error())?; + let mut types = Vec::new(); + types + .try_reserve(length) + .map_err(|_| vm.new_memory_error(""))?; + + for index in 0..length { + let item = sequence.get_item(index as isize, vm).map_err(|_| error())?; + types.push(item.downcast::().map_err(|_| error())?); + } + + Ok(types) +} + /// Determine how to retrieve the return value from restype, reproducing the /// prior `ffi_return_type` + `is_pointer_return` dispatch. fn compute_ret_spec( @@ -1007,13 +1019,7 @@ fn extract_call_info(zelf: &Py, vm: &VirtualMachine) -> PyResult> = if let Some(argtypes_obj) = zelf.argtypes.read().as_ref() { if !vm.is_none(argtypes_obj) { - Some( - argtypes_obj - .try_to_value::>(vm)? - .into_iter() - .filter_map(|obj| obj.downcast::().ok()) - .collect(), - ) + Some(extract_arg_types(argtypes_obj, vm)?) } else { None // argtypes is None -> use ConvParam } @@ -1023,13 +1029,7 @@ fn extract_call_info(zelf: &Py, vm: &VirtualMachine) -> PyResult>(vm)? - .into_iter() - .filter_map(|obj| obj.downcast::().ok()) - .collect(), - ) + Some(extract_arg_types(&class_argtypes, vm)?) } else { None // No argtypes -> use ConvParam }; @@ -1944,14 +1944,7 @@ impl PyCThunk { vm: &VirtualMachine, ) -> PyResult { let arg_type_vec: Vec = match arg_types { - Some(args) if !vm.is_none(&args) => args - .try_to_value::>(vm)? - .into_iter() - .map(|item| { - item.downcast::() - .map_err(|_| vm.new_type_error("_argtypes_ must be a sequence of types")) - }) - .collect::>>()?, + Some(args) if !vm.is_none(&args) => extract_arg_types(&args, vm)?, _ => Vec::new(), }; diff --git a/crates/vm/src/stdlib/_ctypes/pointer.rs b/crates/vm/src/stdlib/_ctypes/pointer.rs index f522e6dfb..a401fde6f 100644 --- a/crates/vm/src/stdlib/_ctypes/pointer.rs +++ b/crates/vm/src/stdlib/_ctypes/pointer.rs @@ -668,7 +668,7 @@ impl PyCPointer { let ptr_val = if vm.is_none(value) { 0usize } else if let Ok(int_val) = value.try_index(vm) { - int_val.as_bigint().to_usize().unwrap_or(0) + super::simple::bigint_to_i128_wrapping(int_val.as_bigint()) as usize } else { return Err(vm.new_type_error("bytes/string or integer address expected")); }; @@ -684,12 +684,13 @@ impl PyCPointer { // Use write_unaligned for safety on strict-alignment architectures if let Ok(int_val) = value.try_int(vm) { let i = int_val.as_bigint(); + let wrapped = super::simple::bigint_to_i128_wrapping(i); let bytes; let write_value = match size { - 1 => AddressWriteValue::U8(i.to_u8().expect("int too large")), - 2 => AddressWriteValue::I16(i.to_i16().expect("int too large")), - 4 => AddressWriteValue::I32(i.to_i32().expect("int too large")), - 8 => AddressWriteValue::I64(i.to_i64().expect("int too large")), + 1 => AddressWriteValue::U8(wrapped as u8), + 2 => AddressWriteValue::I16(wrapped as i16), + 4 => AddressWriteValue::I32(wrapped as i32), + 8 => AddressWriteValue::I64(wrapped as i64), _ => { bytes = i.to_signed_bytes_le(); AddressWriteValue::Bytes(&bytes) diff --git a/crates/vm/src/stdlib/_ctypes/simple.rs b/crates/vm/src/stdlib/_ctypes/simple.rs index c947e5601..5577fb8d2 100644 --- a/crates/vm/src/stdlib/_ctypes/simple.rs +++ b/crates/vm/src/stdlib/_ctypes/simple.rs @@ -72,6 +72,17 @@ fn new_simple_type( Ok(PyCSimple(PyCData::from_bytes(zeroed_bytes(size), None))) } +pub(super) fn bigint_to_i128_wrapping(value: &malachite_bigint::BigInt) -> i128 { + let bytes = value.to_signed_bytes_le(); + let fill = bytes + .last() + .map_or(0, |byte| if *byte & 0x80 == 0 { 0 } else { u8::MAX }); + let mut wrapped = [fill; 16]; + let len = bytes.len().min(wrapped.len()); + wrapped[..len].copy_from_slice(&bytes[..len]); + i128::from_le_bytes(wrapped) +} + fn set_primitive(_type_: &str, value: &PyObject, vm: &VirtualMachine) -> PyResult { match _type_ { "c" => { @@ -756,7 +767,7 @@ fn value_to_bytes_endian( "b" => { // c_byte - signed char (1 byte) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -764,7 +775,7 @@ fn value_to_bytes_endian( "B" => { // c_ubyte - unsigned char (1 byte) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -772,7 +783,7 @@ fn value_to_bytes_endian( "h" => { // c_short (2 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -780,7 +791,7 @@ fn value_to_bytes_endian( "H" => { // c_ushort (2 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -788,7 +799,7 @@ fn value_to_bytes_endian( "i" => { // c_int (4 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -796,7 +807,7 @@ fn value_to_bytes_endian( "I" => { // c_uint (4 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -804,7 +815,7 @@ fn value_to_bytes_endian( "l" => { // c_long (platform dependent) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -812,7 +823,7 @@ fn value_to_bytes_endian( "L" => { // c_ulong (platform dependent) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -820,7 +831,7 @@ fn value_to_bytes_endian( "q" => { // c_longlong (8 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -828,7 +839,7 @@ fn value_to_bytes_endian( "Q" => { // c_ulonglong (8 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -889,10 +900,7 @@ fn value_to_bytes_endian( "P" => { // c_void_p - pointer type (platform pointer size) if let Ok(int_val) = value.try_index(vm) { - let v = int_val - .as_bigint() - .to_usize() - .expect("int too large for pointer"); + let v = bigint_to_i128_wrapping(int_val.as_bigint()) as usize; SimpleStorageValue::Pointer(v) } else { SimpleStorageValue::Zero @@ -902,10 +910,7 @@ fn value_to_bytes_endian( // c_char_p - pointer to char (stores pointer value from int) // PyBytes case is handled in slot_new/set_value with make_z_buffer() if let Ok(int_val) = value.try_index(vm) { - let v = int_val - .as_bigint() - .to_usize() - .expect("int too large for pointer"); + let v = bigint_to_i128_wrapping(int_val.as_bigint()) as usize; SimpleStorageValue::Pointer(v) } else { SimpleStorageValue::Zero @@ -915,10 +920,7 @@ fn value_to_bytes_endian( // c_wchar_p - pointer to wchar_t (stores pointer value from int) // PyStr case is handled in slot_new/set_value with make_wchar_buffer() if let Ok(int_val) = value.try_index(vm) { - let v = int_val - .as_bigint() - .to_usize() - .expect("int too large for pointer"); + let v = bigint_to_i128_wrapping(int_val.as_bigint()) as usize; SimpleStorageValue::Pointer(v) } else { SimpleStorageValue::Zero diff --git a/crates/vm/src/stdlib/_functools.rs b/crates/vm/src/stdlib/_functools.rs index 94b9565e7..9b49e5645 100644 --- a/crates/vm/src/stdlib/_functools.rs +++ b/crates/vm/src/stdlib/_functools.rs @@ -302,7 +302,7 @@ mod _functools { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/stdlib/_imp.rs b/crates/vm/src/stdlib/_imp.rs index 322eaedd7..fa979fcad 100644 --- a/crates/vm/src/stdlib/_imp.rs +++ b/crates/vm/src/stdlib/_imp.rs @@ -179,7 +179,7 @@ mod _imp { PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{PyBytesRef, PyCode, PyMemoryView, PyModule, PyStrRef, PyUtf8StrRef}, convert::TryFromBorrowedObject, - function::OptionalArg, + function::{FuncArgs, OptionalArg}, import, version, }; @@ -320,14 +320,16 @@ mod _imp { #[allow(clippy::type_complexity)] #[pyfunction] fn find_frozen( - name: PyUtf8StrRef, - withdata: OptionalArg, + args: FuncArgs, vm: &VirtualMachine, ) -> PyResult>, bool, Option)>> { - if withdata.into_option().is_some() { - // this is keyword-only argument in CPython - unimplemented!(); + if args.args.len() > 1 { + return Err(vm.new_type_error(format!( + "find_frozen() takes exactly 1 positional argument ({} given)", + args.args.len() + ))); } + let (name,): (PyUtf8StrRef,) = args.bind(vm)?; let name_str = name.as_str(); let info = match super::find_frozen(name_str, vm) { diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index 03382549b..18b4ffde8 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -498,7 +498,7 @@ mod _sre { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -597,7 +597,7 @@ mod _sre { regs: Vec<(isize, isize)>, } - #[pyclass(with(AsMapping, Representable))] + #[pyclass(with(AsMapping, Representable), flags(DISALLOW_INSTANTIATION))] impl Match { pub(crate) fn new(state: &mut State, pattern: PyRef, string: PyObjectRef) -> Self { let string_position = state.cursor.position; @@ -844,7 +844,7 @@ mod _sre { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/stdlib/_typing.rs b/crates/vm/src/stdlib/_typing.rs index 0214c3cc5..0b19d8e3c 100644 --- a/crates/vm/src/stdlib/_typing.rs +++ b/crates/vm/src/stdlib/_typing.rs @@ -39,8 +39,17 @@ pub(crate) mod decl { }; #[pyfunction] - pub(crate) fn _idfunc(args: FuncArgs, _vm: &VirtualMachine) -> PyObjectRef { - args.args[0].clone() + pub(crate) fn _idfunc(args: FuncArgs, vm: &VirtualMachine) -> PyResult { + if !args.kwargs.is_empty() { + return Err(vm.new_type_error("_typing._idfunc() takes no keyword arguments")); + } + if args.args.len() != 1 { + return Err(vm.new_type_error(format!( + "_typing._idfunc() takes exactly one argument ({} given)", + args.args.len() + ))); + } + Ok(args.args[0].clone()) } #[pyfunction(name = "override")] @@ -288,7 +297,7 @@ pub(crate) mod decl { PyTuple::new_ref(vec![args], &vm.ctx) }; let origin: PyObjectRef = zelf.as_object().to_owned(); - Ok(PyGenericAlias::new(origin, args_tuple, false, vm).into_pyobject(vm)) + Ok(PyGenericAlias::new(origin, args_tuple, false, vm)?.into_pyobject(vm)) } #[pymethod] diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 35f404f0f..95feea656 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -341,6 +341,7 @@ mod builtins { }; match &source { ArgStrOrBytesLike::Str(source) => { + let source = source.try_as_utf8(vm)?.as_str(); if source.as_bytes().contains(&0) { return Err(vm.new_exception_msg( vm.ctx.exceptions.syntax_error.to_owned(), @@ -548,13 +549,14 @@ mod builtins { Either::A(either) => { let source = match &either { ArgStrOrBytesLike::Str(source) => { + let source = source.try_as_utf8(vm)?.as_str(); if source.as_bytes().contains(&0) { return Err(vm.new_exception_msg( vm.ctx.exceptions.syntax_error.to_owned(), "source code string cannot contain null bytes".into(), )); } - let source = source.expect_str().trim_start_matches([' ', '\t']); + let source = source.trim_start_matches([' ', '\t']); audit_compile_source(vm, source.as_bytes(), "")?; source.to_owned() } @@ -597,6 +599,7 @@ mod builtins { } let source = match &either { ArgStrOrBytesLike::Str(source) => { + let source = source.try_as_utf8(vm)?.as_str(); if source.as_bytes().contains(&0) { return Err(vm.new_exception_msg( vm.ctx.exceptions.syntax_error.to_owned(), @@ -604,7 +607,7 @@ mod builtins { )); } audit_compile_source(vm, source.as_bytes(), "")?; - source.expect_str().to_owned() + source.to_owned() } ArgStrOrBytesLike::Buf(source) => { let source: &[u8] = &source.borrow_buf(); diff --git a/crates/vm/src/stdlib/itertools.rs b/crates/vm/src/stdlib/itertools.rs index 30a4d8773..e633404e8 100644 --- a/crates/vm/src/stdlib/itertools.rs +++ b/crates/vm/src/stdlib/itertools.rs @@ -26,7 +26,7 @@ mod decl { use num_traits::{Signed, ToPrimitive}; #[pyattr] - #[pyclass(name = "chain")] + #[pyclass(name = "chain", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsChain { source: PyRwLock>, @@ -64,7 +64,7 @@ mod decl { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -119,7 +119,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "compress")] + #[pyclass(name = "compress", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsCompress { data: PyIter, @@ -166,7 +166,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "count")] + #[pyclass(name = "count", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsCount { cur: PyRwLock, @@ -237,11 +237,12 @@ mod decl { } #[pyattr] - #[pyclass(name = "cycle")] + #[pyclass(name = "cycle", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsCycle { iter: PyIter, saved: PyRwLock>, + #[pytraverse(skip)] index: AtomicCell, } @@ -273,11 +274,15 @@ mod decl { return Ok(PyIterReturn::StopIteration(None)); } - let last_index = zelf.index.fetch_add(1); - - if last_index >= saved.len() - 1 { - zelf.index.store(0); - } + // Advance and wrap in a single atomic step. A separate + // fetch_add followed by a reset lets a second thread observe + // an index past the end of `saved`. + let last_index = match zelf.index.fetch_update(|index| { + let next = index + 1; + Some(if next < saved.len() { next } else { 0 }) + }) { + Ok(index) | Err(index) => index, + }; saved[last_index].clone() }; @@ -287,10 +292,11 @@ mod decl { } #[pyattr] - #[pyclass(name = "repeat")] + #[pyclass(name = "repeat", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsRepeat { object: PyObjectRef, + #[pytraverse(skip)] times: Option>, } @@ -365,7 +371,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "starmap")] + #[pyclass(name = "starmap", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsStarmap { function: PyObjectRef, @@ -412,11 +418,12 @@ mod decl { } #[pyattr] - #[pyclass(name = "takewhile")] + #[pyclass(name = "takewhile", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsTakewhile { predicate: PyObjectRef, iterable: PyIter, + #[pytraverse(skip)] stop_flag: AtomicCell, } @@ -474,11 +481,12 @@ mod decl { } #[pyattr] - #[pyclass(name = "dropwhile")] + #[pyclass(name = "dropwhile", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsDropwhile { predicate: PyObjectRef, iterable: PyIter, + #[pytraverse(skip)] start_flag: AtomicCell, } @@ -533,11 +541,13 @@ mod decl { } } - #[derive(Default)] + #[derive(Default, Traverse)] struct GroupByState { current_value: Option, current_key: Option, + #[pytraverse(skip)] next_group: bool, + #[pytraverse(skip)] grouper: Option>, } @@ -561,7 +571,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "groupby")] + #[pyclass(name = "groupby", traverse)] #[derive(PyPayload)] struct PyItertoolsGroupBy { iterable: PyIter, @@ -661,7 +671,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "_grouper")] + #[pyclass(name = "_grouper", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsGrouper { groupby: PyRef, @@ -703,13 +713,17 @@ mod decl { } #[pyattr] - #[pyclass(name = "islice")] + #[pyclass(name = "islice", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsIslice { iterable: PyIter, + #[pytraverse(skip)] cur: AtomicCell, + #[pytraverse(skip)] next: AtomicCell, + #[pytraverse(skip)] stop: Option, + #[pytraverse(skip)] step: usize, } @@ -828,7 +842,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "filterfalse")] + #[pyclass(name = "filterfalse", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsFilterFalse { predicate: PyObjectRef, @@ -887,7 +901,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "accumulate")] + #[pyclass(name = "accumulate", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsAccumulate { iterable: PyIter, @@ -1053,7 +1067,7 @@ mod decl { #[pymethod] fn __copy__(&self) -> Self { Self { - tee_data: PyRc::clone(&self.tee_data), + tee_data: self.tee_data.clone(), index: AtomicCell::new(self.index.load()), } } @@ -1068,12 +1082,15 @@ mod decl { } #[pyattr] - #[pyclass(name = "product")] + #[pyclass(name = "product", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsProduct { pools: Vec>, + #[pytraverse(skip)] idxs: PyRwLock>, + #[pytraverse(skip)] cur: AtomicCell, + #[pytraverse(skip)] stop: AtomicCell, } @@ -1169,13 +1186,16 @@ mod decl { } #[pyattr] - #[pyclass(name = "combinations")] + #[pyclass(name = "combinations", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsCombinations { pool: Vec, + #[pytraverse(skip)] indices: PyRwLock>, result: PyRwLock>>, + #[pytraverse(skip)] r: AtomicCell, + #[pytraverse(skip)] exhausted: AtomicCell, } @@ -1201,13 +1221,21 @@ mod decl { if r.is_negative() { return Err(vm.new_value_error("r must be non-negative")); } - let r = r.to_usize().unwrap(); + let r = r.to_isize().ok_or_else(|| { + vm.new_overflow_error("Python int too large to convert to C ssize_t") + })? as usize; let n = pool.len(); + let mut indices = Vec::new(); + indices + .try_reserve_exact(r) + .map_err(|_| vm.new_memory_error(""))?; + indices.extend(0..r); + Ok(Self { pool, - indices: PyRwLock::new((0..r).collect()), + indices: PyRwLock::new(indices), result: PyRwLock::new(None), r: AtomicCell::new(r), exhausted: AtomicCell::new(r > n), @@ -1280,12 +1308,15 @@ mod decl { } #[pyattr] - #[pyclass(name = "combinations_with_replacement")] + #[pyclass(name = "combinations_with_replacement", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsCombinationsWithReplacement { pool: Vec, + #[pytraverse(skip)] indices: PyRwLock>, + #[pytraverse(skip)] r: AtomicCell, + #[pytraverse(skip)] exhausted: AtomicCell, } @@ -1302,13 +1333,21 @@ mod decl { if r.is_negative() { return Err(vm.new_value_error("r must be non-negative")); } - let r = r.to_usize().unwrap(); + let r = r.to_isize().ok_or_else(|| { + vm.new_overflow_error("Python int too large to convert to C ssize_t") + })? as usize; let n = pool.len(); + let mut indices = Vec::new(); + indices + .try_reserve_exact(r) + .map_err(|_| vm.new_memory_error(""))?; + indices.resize(r, 0); + Ok(Self { pool, - indices: PyRwLock::new(vec![0; r]), + indices: PyRwLock::new(indices), r: AtomicCell::new(r), exhausted: AtomicCell::new(n == 0 && r > 0), }) @@ -1366,15 +1405,20 @@ mod decl { } #[pyattr] - #[pyclass(name = "permutations")] + #[pyclass(name = "permutations", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsPermutations { - pool: Vec, // Collected input iterable - indices: PyRwLock>, // One index per element in pool - cycles: PyRwLock>, // One rollover counter per element in the result + pool: Vec, // Collected input iterable + #[pytraverse(skip)] + indices: PyRwLock>, // One index per element in pool + #[pytraverse(skip)] + cycles: PyRwLock>, // One rollover counter per element in the result + #[pytraverse(skip)] result: PyRwLock>>, // Indexes of the most recently returned result - r: AtomicCell, // Size of result tuple - exhausted: AtomicCell, // Set when the iterator is exhausted + #[pytraverse(skip)] + r: AtomicCell, // Size of result tuple + #[pytraverse(skip)] + exhausted: AtomicCell, // Set when the iterator is exhausted } #[derive(FromArgs)] @@ -1408,7 +1452,9 @@ mod decl { if val.is_negative() { return Err(vm.new_value_error("r must be non-negative")); } - val.to_usize().unwrap() + val.to_isize().ok_or_else(|| { + vm.new_overflow_error("Python int too large to convert to C ssize_t") + })? as usize } None => n, }; @@ -1524,7 +1570,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "zip_longest")] + #[pyclass(name = "zip_longest", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsZipLongest { iterators: Vec, @@ -1562,7 +1608,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "pairwise")] + #[pyclass(name = "pairwise", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsPairwise { iterator: PyIter, @@ -1611,12 +1657,15 @@ mod decl { } #[pyattr] - #[pyclass(name = "batched")] + #[pyclass(name = "batched", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsBatched { + #[pytraverse(skip)] exhausted: AtomicCell, iterable: PyIter, + #[pytraverse(skip)] n: AtomicCell, + #[pytraverse(skip)] strict: AtomicCell, } diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index a934c6d81..9156c9fc0 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -206,7 +206,10 @@ pub(super) mod _os { ospath::{OsPath, OsPathOrFd, OutputMode, PathConverter}, protocol::PyIterReturn, recursion::ReprGuard, - types::{Destructor, IterNext, Iterable, PyStructSequence, Representable, SelfIter}, + types::{ + Destructor, IterNext, Iterable, PyStructSequence, PyStructSequenceData, Representable, + SelfIter, + }, vm::VirtualMachine, }; #[cfg(not(windows))] @@ -883,7 +886,7 @@ pub(super) mod _os { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } @@ -1314,8 +1317,12 @@ pub(super) mod _os { impl PyStatResult { #[pyslot] fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { - let seq: PyObjectRef = args.bind(vm)?; - let result = crate::types::struct_sequence_new(cls.clone(), seq, vm)?; + let result = crate::types::struct_sequence_new( + cls.clone(), + args.bind(vm)?, + StatResultData::OPTIONAL_FIELD_NAMES, + vm, + )?; let tuple = result.downcast_ref::().unwrap(); let mut items: Vec = tuple.to_vec(); @@ -1964,8 +1971,12 @@ pub(super) mod _os { impl PyStatvfsResult { #[pyslot] fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { - let seq: PyObjectRef = args.bind(vm)?; - crate::types::struct_sequence_new(cls, seq, vm) + crate::types::struct_sequence_new( + cls, + args.bind(vm)?, + StatvfsResultData::OPTIONAL_FIELD_NAMES, + vm, + ) } } diff --git a/crates/vm/src/stdlib/posix.rs b/crates/vm/src/stdlib/posix.rs index c16da1ee7..6b91950e9 100644 --- a/crates/vm/src/stdlib/posix.rs +++ b/crates/vm/src/stdlib/posix.rs @@ -1333,14 +1333,13 @@ pub mod module { // cfg from nix #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "redox")))] #[pyfunction] - fn setgroups( - group_ids: crate::function::ArgIterable, - vm: &VirtualMachine, - ) -> PyResult<()> { - let gids = group_ids - .iter(vm)? - .map(|gid| gid.map(|gid| gid.0)) - .collect::, _>>()?; + fn setgroups(group_ids: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + group_ids + .try_sequence(vm) + .map_err(|_| vm.new_type_error("setgroups argument must be a sequence"))?; + let gids = vm.extract_elements_with(&group_ids, |gid| { + RawGid::try_from_object(vm, gid).map(|gid| gid.0) + })?; rustpython_host_env::posix::setgroups_raw(&gids).map_err(|err| err.into_pyexception(vm)) } @@ -1400,7 +1399,7 @@ pub mod module { #[pyarg(positional)] path: OsPath, #[pyarg(positional)] - args: crate::function::ArgIterable, + args: PyObjectRef, #[pyarg(positional)] env: Option, #[pyarg(named, default)] @@ -1439,6 +1438,19 @@ pub mod module { .into_cstring(vm) .map_err(|_| vm.new_value_error("path should not have nul bytes"))?; + let function_name = if spawnp { + "posix_spawnp" + } else { + "posix_spawn" + }; + if !self.args.fast_isinstance(vm.ctx.types.list_type) + && !self.args.fast_isinstance(vm.ctx.types.tuple_type) + { + return Err( + vm.new_type_error(format!("{function_name}: argv must be a tuple or list")) + ); + } + let mut file_actions = Vec::new(); if let Some(it) = self.file_actions { for action in it.iter(vm)? { @@ -1478,20 +1490,21 @@ pub mod module { } } - let setsigdef = self - .setsigdef - .map(|sigs| { - let sigs = sigs.iter(vm)?.collect::>>()?; - for &sig in &sigs { - if !rustpython_host_env::posix::validate_posix_spawn_signal(sig) { - return Err( - vm.new_value_error(format!("signal number {sig} out of range")) - ); - } + let collect_signals = |sigs: crate::function::ArgIterable| { + let mut collected = Vec::new(); + for sig in sigs.iter(vm)? { + let sig = sig?; + if !rustpython_host_env::posix::validate_posix_spawn_signal(sig) { + return Err(vm.new_value_error(format!("signal number {sig} out of range"))); } - Ok(sigs) - }) - .transpose()?; + if !collected.contains(&sig) { + collected.push(sig); + } + } + Ok(collected) + }; + + let setsigdef = self.setsigdef.map(&collect_signals).transpose()?; if let Some(_scheduler) = self.scheduler { // TODO: Implement scheduler parameter handling @@ -1507,29 +1520,12 @@ pub mod module { )); } - let setsigmask = self - .setsigmask - .map(|sigs| { - let sigs = sigs.iter(vm)?.collect::>>()?; - for &sig in &sigs { - if !rustpython_host_env::posix::validate_posix_spawn_signal(sig) { - return Err( - vm.new_value_error(format!("signal number {sig} out of range")) - ); - } - } - Ok(sigs) - }) - .transpose()?; + let setsigmask = self.setsigmask.map(collect_signals).transpose()?; - let args: Vec = self - .args - .iter(vm)? - .map(|res| { - CString::new(res?.into_bytes()) - .map_err(|_| vm.new_value_error("path should not have nul bytes")) - }) - .collect::>()?; + let args = vm.extract_elements_with(&self.args, |arg| { + CString::new(OsPath::try_from_object(vm, arg)?.into_bytes()) + .map_err(|_| vm.new_value_error("path should not have nul bytes")) + })?; let env = if let Some(env_dict) = self.env { envp_from_dict(env_dict, vm)? } else { diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index c7cc2fd29..66257806e 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -888,8 +888,7 @@ pub mod sys { format!("Ignoring unimportable $PYTHONBREAKPOINT: \"{env_var}\"",), 0, vm, - ) - .unwrap(); + )?; Ok(vm.ctx.none()) }; diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index 3d777c24b..a5daa9cd2 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -18,7 +18,7 @@ mod decl { AsObject, Py, PyObjectRef, PyResult, VirtualMachine, builtins::{PyStrRef, PyTypeRef}, function::{Either, FuncArgs, OptionalArg}, - types::{PyStructSequence, struct_sequence_new}, + types::{PyStructSequence, PyStructSequenceData, struct_sequence_new}, }; #[cfg(any(unix, windows))] use crate::{ @@ -811,8 +811,12 @@ mod decl { impl PyStructTime { #[pyslot] fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { - let (seq, _dict): (PyObjectRef, OptionalArg) = args.bind(vm)?; - struct_sequence_new(cls, seq, vm) + struct_sequence_new( + cls, + args.bind(vm)?, + StructTimeData::OPTIONAL_FIELD_NAMES, + vm, + ) } } diff --git a/crates/vm/src/types/mod.rs b/crates/vm/src/types/mod.rs index b17a73754..11c3a4dc5 100644 --- a/crates/vm/src/types/mod.rs +++ b/crates/vm/src/types/mod.rs @@ -5,5 +5,7 @@ mod zoo; pub use slot::*; pub use slot_defs::{SLOT_DEFS, SlotAccessor, SlotDef}; -pub use structseq::{PyStructSequence, PyStructSequenceData, struct_sequence_new}; +pub use structseq::{ + PyStructSequence, PyStructSequenceData, StructSequenceNewArgs, struct_sequence_new, +}; pub(crate) use zoo::TypeZoo; diff --git a/crates/vm/src/types/structseq.rs b/crates/vm/src/types/structseq.rs index 703cc79c1..7f8099e7e 100644 --- a/crates/vm/src/types/structseq.rs +++ b/crates/vm/src/types/structseq.rs @@ -2,9 +2,11 @@ use crate::common::lock::LazyLock; use crate::common::wtf8::Wtf8; use crate::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, atomic_func, - builtins::{PyBaseExceptionRef, PyStr, PyStrRef, PyTuple, PyTupleRef, PyType, PyTypeRef}, + builtins::{ + PyBaseExceptionRef, PyDict, PyStr, PyStrRef, PyTuple, PyTupleRef, PyType, PyTypeRef, + }, class::{PyClassImpl, StaticType}, - function::{Either, FuncArgs, PyComparisonValue, PyMethodDef, PyMethodFlags}, + function::{Either, FuncArgs, OptionalArg, PyComparisonValue, PyMethodDef, PyMethodFlags}, iter::PyExactSizeIterator, protocol::{PyMappingMethods, PySequenceMethods}, sliceable::{SequenceIndex, SliceableSequenceOp}, @@ -21,12 +23,35 @@ const DEFAULT_STRUCTSEQ_REDUCE: PyMethodDef = PyMethodDef::new_const( None, ); +/// The arguments every struct sequence constructor takes. +#[derive(FromArgs)] +pub struct StructSequenceNewArgs { + #[pyarg(any)] + pub sequence: PyObjectRef, + #[pyarg(any, optional)] + pub dict: OptionalArg, +} + /// Create a new struct sequence instance from a sequence. /// +/// `dict` supplies the hidden fields — the ones past `n_sequence_fields`, named +/// by `hidden_field_names` in order — that the sequence itself did not cover. It +/// may not name a field the sequence already supplied, nor one that does not +/// exist. +/// /// The class must have `n_sequence_fields` and `n_fields` attributes set /// (done automatically by `PyStructSequence::extend_pyclass`). -pub fn struct_sequence_new(cls: PyTypeRef, seq: PyObjectRef, vm: &VirtualMachine) -> PyResult { +pub fn struct_sequence_new( + cls: PyTypeRef, + args: StructSequenceNewArgs, + hidden_field_names: &[&str], + vm: &VirtualMachine, +) -> PyResult { // = structseq_new + let StructSequenceNewArgs { + sequence: seq, + dict, + } = args; #[cold] fn length_error( @@ -60,6 +85,16 @@ pub fn struct_sequence_new(cls: PyTypeRef, seq: PyObjectRef, vm: &VirtualMachine .ok_or_else(|| vm.new_type_error("missing n_fields attribute"))? .try_into_value(vm)?; + let dict = match dict { + OptionalArg::Missing => None, + OptionalArg::Present(dict) => Some(dict.downcast::().map_err(|_| { + vm.new_type_error(format!( + "{}() takes a dict as second arg, if any", + cls.slot_name() + )) + })?), + }; + let seq: Vec = seq.try_into_value(vm)?; let len = seq.len(); @@ -67,10 +102,30 @@ pub fn struct_sequence_new(cls: PyTypeRef, seq: PyObjectRef, vm: &VirtualMachine return Err(length_error(&cls.slot_name(), min_len, max_len, len, vm)); } - // Copy items and pad with None + // Copy items and pad the hidden fields the sequence did not cover with None. let mut items = seq; items.resize_with(max_len, || vm.ctx.none()); + // Fill those padded slots from `dict`. Every key has to land in one of them: + // a key naming a field the sequence already supplied, or no field at all, + // would otherwise be silently dropped. + if let Some(dict) = dict.filter(|dict| !dict.is_empty()) { + let mut found = 0; + let names = hidden_field_names.get(len - min_len..).unwrap_or(&[]); + for (item, name) in items[len..].iter_mut().zip(names) { + if let Some(value) = dict.get_item_opt(*name, vm)? { + *item = value; + found += 1; + } + } + if found != dict.__len__() { + return Err(vm.new_type_error(format!( + "{}() got duplicate or unexpected field name(s)", + cls.slot_name() + ))); + } + } + PyTuple::new_unchecked(items.into_boxed_slice()) .into_ref_with_type(vm, cls) .map(Into::into) @@ -193,6 +248,11 @@ pub trait PyStructSequence: StaticType + PyClassImpl + Sized + 'static { /// The Data struct that provides field definitions. type Data: PyStructSequenceData; + #[pyslot] + fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { + struct_sequence_new(cls, args.bind(vm)?, Self::Data::OPTIONAL_FIELD_NAMES, vm) + } + /// Convert a Data struct into a PyStructSequence instance. fn from_data(data: Self::Data, vm: &VirtualMachine) -> PyTupleRef { let tuple = diff --git a/crates/vm/src/utils.rs b/crates/vm/src/utils.rs index 80402480c..8a28a32f6 100644 --- a/crates/vm/src/utils.rs +++ b/crates/vm/src/utils.rs @@ -33,6 +33,7 @@ pub(crate) fn collection_repr<'a, I>( class_name: Option<&str>, prefix: &str, suffix: &str, + empty: &str, iter: I, vm: &VirtualMachine, ) -> PyResult @@ -47,10 +48,9 @@ where repr.push_str(prefix); { let mut parts_iter = iter.map(|o| o.repr(vm)); - let first = parts_iter - .next() - .transpose()? - .expect("this is not called for empty collection"); + let Some(first) = parts_iter.next().transpose()? else { + return Ok(Wtf8Buf::from(empty)); + }; repr.push_wtf8(first.as_wtf8()); for part in parts_iter { repr.push_str(", "); diff --git a/extra_tests/snippets/builtin_compile.py b/extra_tests/snippets/builtin_compile.py index 49295bf26..73247e50d 100644 --- a/extra_tests/snippets/builtin_compile.py +++ b/extra_tests/snippets/builtin_compile.py @@ -145,3 +145,8 @@ except _IncompleteInputError as exc: assert exc.args[0] == "incomplete input", repr(exc) else: raise AssertionError("expected _IncompleteInputError") + +# The source is encoded before it is parsed, so a lone surrogate has to be +# reported rather than assumed away. +with assert_raises(UnicodeEncodeError): + compile(chr(0xD800), "", "eval") diff --git a/extra_tests/snippets/builtin_eval.py b/extra_tests/snippets/builtin_eval.py index 2f2405c8d..1648a1a27 100644 --- a/extra_tests/snippets/builtin_eval.py +++ b/extra_tests/snippets/builtin_eval.py @@ -1,3 +1,5 @@ +from testutils import assert_raises + assert 3 == eval("1+2") code = compile("5+3", "x.py", "eval") @@ -75,3 +77,8 @@ try: assert False, "eval with code containing free variables should fail" except NameError as e: pass + +# The source is encoded before it is parsed, so a lone surrogate has to be +# reported rather than assumed away. +with assert_raises(UnicodeEncodeError): + eval(chr(0xD800)) diff --git a/extra_tests/snippets/builtin_exceptions.py b/extra_tests/snippets/builtin_exceptions.py index 8879e130b..080294a3c 100644 --- a/extra_tests/snippets/builtin_exceptions.py +++ b/extra_tests/snippets/builtin_exceptions.py @@ -1,4 +1,5 @@ import builtins +import itertools import pickle import platform import sys @@ -393,3 +394,19 @@ except* ValueError as err: assert err.exceptions[0].args == ("x",) else: assert False, "except* handler did not run" + +# The exceptions argument is a sequence, so an arbitrary iterable must be +# rejected rather than drained. +try: + ExceptionGroup("m", itertools.count()) +except TypeError: + pass +else: + assert False, "ExceptionGroup accepted an unbounded iterable" + +# ImportError.__reduce__ has to cope with the exception carrying no args. +assert pickle.loads(pickle.dumps(ImportError())).args == () +restored = pickle.loads(pickle.dumps(ImportError("m", name="n", path="p"))) +assert restored.args == ("m",) +assert restored.name == "n" +assert restored.path == "p" diff --git a/extra_tests/snippets/builtin_exec.py b/extra_tests/snippets/builtin_exec.py index 2eae90e91..cfb88c15d 100644 --- a/extra_tests/snippets/builtin_exec.py +++ b/extra_tests/snippets/builtin_exec.py @@ -1,3 +1,5 @@ +from testutils import assert_raises + exec("def square(x):\n return x * x\n") assert 16 == square(4) # noqa: F821 @@ -71,3 +73,8 @@ def f(): f() + +# The source is encoded before it is parsed, so a lone surrogate has to be +# reported rather than assumed away. +with assert_raises(UnicodeEncodeError): + exec(chr(0xD800)) diff --git a/extra_tests/snippets/builtin_hash.py b/extra_tests/snippets/builtin_hash.py index 9b2c83887..b3128cecc 100644 --- a/extra_tests/snippets/builtin_hash.py +++ b/extra_tests/snippets/builtin_hash.py @@ -1,3 +1,5 @@ +import sys + from testutils import assert_raises @@ -28,3 +30,19 @@ with assert_raises(TypeError): with assert_raises(TypeError): hash([]) + +# Hashing a deeply nested tuple must not run off the native stack: the hash +# slot dispatch is what recurses, so that is where the depth is checked. + +if sys.implementation.name == "rustpython": + # CPython, which also runs this snippet, survives this depth unguarded. + deep_tuple = () + for _ in range(sys.getrecursionlimit() * 2): + deep_tuple = (deep_tuple,) + with assert_raises(RecursionError): + hash(deep_tuple) + # a dict key and a set member are hashed on insertion, same dispatch + with assert_raises(RecursionError): + {deep_tuple: 1} + with assert_raises(RecursionError): + {deep_tuple} diff --git a/extra_tests/snippets/builtin_list.py b/extra_tests/snippets/builtin_list.py index d62cae03b..44492092b 100644 --- a/extra_tests/snippets/builtin_list.py +++ b/extra_tests/snippets/builtin_list.py @@ -1,3 +1,5 @@ +import sys + from testutils import assert_raises x = [1, 2, 3] @@ -923,3 +925,8 @@ class poc: list1 = rewrite_list_eq([poc()]) list1.remove(list1) assert list1 == [] + +# The repeat count is multiplied by the element size; a count that overflows +# that product must raise instead of wrapping into a short allocation. +with assert_raises(MemoryError): + [1] * sys.maxsize diff --git a/extra_tests/snippets/builtin_tuple.py b/extra_tests/snippets/builtin_tuple.py index fc2f8d5bb..a679d2a99 100644 --- a/extra_tests/snippets/builtin_tuple.py +++ b/extra_tests/snippets/builtin_tuple.py @@ -1,3 +1,5 @@ +import sys + from testutils import assert_raises assert (1, 2) == (1, 2) @@ -93,3 +95,8 @@ assert (float("inf"), float("inf")) <= (float("inf"), float("inf")) assert (float("inf"), float("inf")) >= (float("inf"), float("inf")) assert not (float("inf"), float("inf")) < (float("inf"), float("inf")) assert not (float("inf"), float("inf")) > (float("inf"), float("inf")) + +# The repeat count is multiplied by the element size; a count that overflows +# that product must raise instead of wrapping into a short allocation. +with assert_raises(MemoryError): + (1,) * sys.maxsize diff --git a/extra_tests/snippets/forbidden_instantiation.py b/extra_tests/snippets/forbidden_instantiation.py index 50b6f58f0..50a0e2cf6 100644 --- a/extra_tests/snippets/forbidden_instantiation.py +++ b/extra_tests/snippets/forbidden_instantiation.py @@ -1,3 +1,4 @@ +import re from types import ( AsyncGeneratorType, BuiltinFunctionType, @@ -62,3 +63,9 @@ for typ in reviter_types: for typ in internal_types: with assert_raises(TypeError): typ() + +# a match object carries state that only the matcher can fill in +with assert_raises(TypeError): + re.Match() +with assert_raises(TypeError): + re.Match.__new__(re.Match) diff --git a/extra_tests/snippets/stdlib_asyncio.py b/extra_tests/snippets/stdlib_asyncio.py new file mode 100644 index 000000000..7f03aeb43 --- /dev/null +++ b/extra_tests/snippets/stdlib_asyncio.py @@ -0,0 +1,52 @@ +"""The private _asyncio accessors, reached directly instead of through a loop. + +CPython's _asyncio rejects every call below with "loop ... is not the running +loop" before it gets anywhere, and does not expose _current_tasks at all, so +these only run where they are reachable. +""" + +import sys + +from testutils import assert_raises + +if sys.implementation.name != "rustpython": + sys.exit(0) + +import _asyncio + + +def _task(): + pass + + +# The "already entered" message formats both tasks; a plain function used to be +# formatted as the wrong type there. +_asyncio._enter_task(0, _task) +with assert_raises(RuntimeError) as cm: + _asyncio._enter_task(0, _task) +assert "Cannot enter into task" in str(cm.exception), cm.exception +assert " node.__dict__ -> wrapper -> container -> node + +so the only path back to `node` runs through a field of the wrapper. A type +that reports nothing while being traversed, or reports the objects it iterates +instead of the iterator it holds, leaves its own reference unaccounted for: the +cycle is then classified as reachable and `node` is never freed. +""" + +import gc +import itertools +import weakref +from collections import defaultdict, deque + + +class Node: + pass + + +def collects(wrap): + """Report whether the collector breaks the cycle built around wrap().""" + + def build(): + container = [] + node = Node() + container.append(node) + node.held = wrap(container) + return weakref.ref(node) + + gc.collect() + ref = build() + gc.collect() + return ref() is None + + +# containers keeping their items in a field of their own +assert collects(deque) +assert collects(lambda c: defaultdict(int, {"k": c})) +assert collects(lambda c: classmethod(lambda cls: c)) + +# iterators: the wrapper holds an iterator, and that iterator holds the +# container +assert collects(iter) +assert collects(lambda c: map(str, c)) +assert collects(lambda c: filter(None, c)) +assert collects(lambda c: zip(c)) +assert collects(enumerate) +assert collects(reversed) +assert collects(itertools.chain) +assert collects(itertools.cycle) +assert collects(lambda c: itertools.islice(c, 5)) +assert collects(itertools.groupby) +assert collects(itertools.accumulate) +assert collects(lambda c: itertools.starmap(str, c)) +assert collects(lambda c: itertools.takewhile(bool, c)) +assert collects(lambda c: itertools.dropwhile(bool, c)) +assert collects(lambda c: itertools.filterfalse(None, c)) +assert collects(lambda c: itertools.compress(c, [1])) +assert collects(lambda c: itertools.product(c)) +assert collects(lambda c: itertools.combinations(c, 1)) + +print("ok") diff --git a/extra_tests/snippets/stdlib_hashlib.py b/extra_tests/snippets/stdlib_hashlib.py index c5feb709e..a463941b2 100644 --- a/extra_tests/snippets/stdlib_hashlib.py +++ b/extra_tests/snippets/stdlib_hashlib.py @@ -1,3 +1,5 @@ +import _md5 +import _sha1 import hashlib # print(hashlib.md5) @@ -48,3 +50,9 @@ print(h.hexdigest()) assert ( h.hexdigest() == "25738bfe4cc104131e1b45bece4dfd4e7e1d6f0dffda1211e996e9d5d3b66e81" ) + +# The single-algorithm modules set up their own types rather than relying on +# hashlib having done it. + +assert _md5.md5(b"").hexdigest() == "d41d8cd98f00b204e9800998ecf8427e" +assert _sha1.sha1(b"").hexdigest() == "da39a3ee5e6b4b0d3255bfef95601890afd80709" diff --git a/extra_tests/snippets/stdlib_imp.py b/extra_tests/snippets/stdlib_imp.py index 835b50d61..64f1a0ad6 100644 --- a/extra_tests/snippets/stdlib_imp.py +++ b/extra_tests/snippets/stdlib_imp.py @@ -1,6 +1,8 @@ import _imp import time as import_time +from testutils import assert_raises + assert _imp.is_builtin("time") == True assert _imp.is_builtin("os") == False assert _imp.is_builtin("not existing module") == False @@ -29,3 +31,8 @@ _imp.get_frozen_object("__hello__") hello = _imp.init_frozen("__hello__") assert hello.initialized == True + +# withdata is keyword-only +with assert_raises(TypeError): + _imp.find_frozen("x", True) +assert _imp.find_frozen("_this_module_does_not_exist_") is None diff --git a/extra_tests/snippets/stdlib_itertools.py b/extra_tests/snippets/stdlib_itertools.py index ce7a49471..029d0d422 100644 --- a/extra_tests/snippets/stdlib_itertools.py +++ b/extra_tests/snippets/stdlib_itertools.py @@ -524,3 +524,19 @@ assert next(it) == (1, 3) assert next(it) == (2, None) with assert_raises(StopIteration): next(it) + +# r is an arbitrary Python int: one too large for an index must raise +# OverflowError, and a representable one that cannot be allocated must raise +# MemoryError. +for factory in ( + itertools.combinations, + itertools.combinations_with_replacement, + itertools.permutations, +): + with assert_raises(OverflowError): + factory(range(5), 2**64) + +with assert_raises(MemoryError): + itertools.combinations(range(5), 2**44) +with assert_raises(MemoryError): + itertools.combinations_with_replacement(range(5), 2**44) diff --git a/extra_tests/snippets/stdlib_lzma.py b/extra_tests/snippets/stdlib_lzma.py new file mode 100644 index 000000000..5ebce3c7f --- /dev/null +++ b/extra_tests/snippets/stdlib_lzma.py @@ -0,0 +1,22 @@ +import itertools +import lzma + +from testutils import assert_raises + +# A raw-format compressor needs the filter chain's length before it can build +# it, so a filter argument that is not a sequence has to be rejected instead of +# being drained. +with assert_raises(TypeError): + lzma.LZMACompressor( + format=lzma.FORMAT_RAW, + filters=({"id": lzma.FILTER_LZMA2} for _ in itertools.count()), + ) + +compressor = lzma.LZMACompressor( + format=lzma.FORMAT_RAW, filters=[{"id": lzma.FILTER_LZMA2}] +) +compressed = compressor.compress(b"data") + compressor.flush() +decompressor = lzma.LZMADecompressor( + format=lzma.FORMAT_RAW, filters=[{"id": lzma.FILTER_LZMA2}] +) +assert decompressor.decompress(compressed) == b"data" diff --git a/extra_tests/snippets/stdlib_math.py b/extra_tests/snippets/stdlib_math.py index a6bb0099c..bc8673797 100644 --- a/extra_tests/snippets/stdlib_math.py +++ b/extra_tests/snippets/stdlib_math.py @@ -1,3 +1,4 @@ +import itertools import math from testutils import assert_raises, skip_if_unsupported @@ -311,3 +312,9 @@ assert math.fmod(0.0, 3.0) == 0.0 assert math.fmod(0.0, NINF) == 0.0 assert math.gamma(1) == 1.0 + +# sumprod compares the two lengths as it goes; it must not drain either +# argument first. +assert_raises(ValueError, lambda: math.sumprod(itertools.count(), [1, 2, 3])) +assert_raises(ValueError, lambda: math.sumprod([1, 2, 3], itertools.count())) +assert math.sumprod(iter([1, 2, 3]), iter([4, 5, 6])) == 32 diff --git a/extra_tests/snippets/stdlib_mmap.py b/extra_tests/snippets/stdlib_mmap.py index 3a2b139a3..2dee29e6b 100644 --- a/extra_tests/snippets/stdlib_mmap.py +++ b/extra_tests/snippets/stdlib_mmap.py @@ -1,6 +1,19 @@ import mmap +from testutils import assert_raises + mapped = mmap.mmap(-1, 1) assert mapped.seekable() mapped.close() assert mapped.seekable() + +mapped = mmap.mmap(-1, 10) +# an inverted range finds nothing rather than being subtracted into a huge one +assert mapped.find(b"x", 5, 2) == -1 +assert mapped.rfind(b"x", 5, 2) == -1 +# both offsets are bounds-checked before anything is copied +with assert_raises(ValueError): + mapped.move(20, 0, 1) +with assert_raises(ValueError): + mapped.move(0, 20, 1) +mapped.close() diff --git a/extra_tests/snippets/stdlib_os.py b/extra_tests/snippets/stdlib_os.py index d00924e10..a1f40ef4c 100644 --- a/extra_tests/snippets/stdlib_os.py +++ b/extra_tests/snippets/stdlib_os.py @@ -1,3 +1,4 @@ +import itertools import os import stat import sys @@ -528,3 +529,19 @@ assert os.access("nonexistent_file_12345", os.F_OK) is False assert os.access("nonexistent_file_12345", os.W_OK) is False assert os.access("README.md", os.F_OK) is True assert os.access("README.md", os.R_OK) is True + +# argv and the group list are sequences; an arbitrary iterable must be rejected +# rather than drained. +if hasattr(os, "posix_spawn"): + with assert_raises(TypeError): + os.posix_spawn("/bin/true", map(str, itertools.count()), os.environ) +if hasattr(os, "setgroups"): + with assert_raises(TypeError): + os.setgroups(itertools.count()) + +# The optional second argument fills the fields past the visible ones, and the +# getters must not index past what __new__ stored. +assert os.stat_result(tuple(range(10))).st_atime == 7 +assert os.stat_result(tuple(range(10)), {"st_atime": 1.5}).st_atime == 1.5 +with assert_raises(TypeError): + os.stat_result(tuple(range(10)), ["st_atime"]) diff --git a/extra_tests/snippets/stdlib_pwd.py b/extra_tests/snippets/stdlib_pwd.py index c3aeb7c87..6229f631c 100644 --- a/extra_tests/snippets/stdlib_pwd.py +++ b/extra_tests/snippets/stdlib_pwd.py @@ -12,3 +12,7 @@ with assert_raises(KeyError): fake_name = "fake_user" while pwd.getpwnam(fake_name): fake_name += "1" + +# The field getters must not index a struct sequence that __new__ never filled. +with assert_raises(TypeError): + pwd.struct_passwd() diff --git a/extra_tests/snippets/stdlib_sys.py b/extra_tests/snippets/stdlib_sys.py index 155fc905a..9dba301fb 100644 --- a/extra_tests/snippets/stdlib_sys.py +++ b/extra_tests/snippets/stdlib_sys.py @@ -1,6 +1,7 @@ import os import subprocess import sys +import warnings from testutils import assert_raises @@ -158,3 +159,18 @@ def test_getframemodulename(): test_getframemodulename.__module__ = "awesome_module" assert test_getframemodulename() == "awesome_module" + +# An unimportable $PYTHONBREAKPOINT warns, and the hook has to survive that +# warning being turned into an exception. +saved_breakpoint_env = os.environ.get("PYTHONBREAKPOINT") +os.environ["PYTHONBREAKPOINT"] = "nonexistent_xyz.foo" +try: + with warnings.catch_warnings(): + warnings.simplefilter("error") + with assert_raises(RuntimeWarning): + sys.breakpointhook() +finally: + if saved_breakpoint_env is None: + del os.environ["PYTHONBREAKPOINT"] + else: + os.environ["PYTHONBREAKPOINT"] = saved_breakpoint_env diff --git a/extra_tests/snippets/stdlib_threading_itertools_cycle.py b/extra_tests/snippets/stdlib_threading_itertools_cycle.py new file mode 100644 index 000000000..b50a31b24 --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_itertools_cycle.py @@ -0,0 +1,26 @@ +"""Stress itertools.cycle from several threads at once. + +cycle() advances its index and wraps it back to zero when it reaches the end of +the saved items. Doing that in two separate steps lets another thread observe +the index past the end and read out of bounds, so the update has to be a single +atomic step. +""" + +import itertools +import threading + +shared_cycle = itertools.cycle([1, 2, 3]) + + +def spin(): + for _ in range(20000): + next(shared_cycle) + + +threads = [threading.Thread(target=spin) for _ in range(4)] +for t in threads: + t.start() +for t in threads: + t.join() + +print("ok") diff --git a/extra_tests/snippets/stdlib_threading_set_repr.py b/extra_tests/snippets/stdlib_threading_set_repr.py new file mode 100644 index 000000000..e2ce2d943 --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_set_repr.py @@ -0,0 +1,44 @@ +"""Stress set repr against concurrent mutation. + +repr() checks that the set is non-empty and then reads its first element. The +two steps are separate, so another thread can empty the set in between; the +read has to cope with that rather than trusting the earlier check. + +Threads that observe a mutation mid-iteration raise RuntimeError, which is a +legitimate outcome here; a regression shows up as a crash instead. +""" + +import threading + +shared_set = {1, 2, 3, 4, 5} +stop = False + + +def mutate(): + while not stop: + try: + shared_set.clear() + shared_set.update({1, 2, 3}) + except RuntimeError: # changed size during iteration + pass + + +def read(): + for _ in range(20000): + try: + repr(shared_set) + except RuntimeError: # changed size during iteration + pass + + +mutators = [threading.Thread(target=mutate) for _ in range(2)] +readers = [threading.Thread(target=read) for _ in range(2)] +for t in mutators + readers: + t.start() +for t in readers: + t.join() +stop = True +for t in mutators: + t.join() + +print("ok") diff --git a/extra_tests/snippets/stdlib_time.py b/extra_tests/snippets/stdlib_time.py index 68ceab895..b74d5bbc6 100644 --- a/extra_tests/snippets/stdlib_time.py +++ b/extra_tests/snippets/stdlib_time.py @@ -82,3 +82,16 @@ perf_elapsed = time.perf_counter() - perf_start assert monotonic_elapsed >= 0.01 assert perf_elapsed >= 0.01 + +# The optional second argument fills the fields that are not part of the +# sequence. +fields = (2024, 1, 2, 3, 4, 5, 6, 7, 0) +assert time.struct_time(fields).tm_zone is None +assert time.struct_time(fields, {"tm_zone": "UTC"}).tm_zone == "UTC" +assert time.struct_time(fields, {"tm_gmtoff": 60}).tm_gmtoff == 60 +try: + time.struct_time(fields, ["tm_zone", "UTC"]) +except TypeError: + pass +else: + assert False, "struct_time accepted a non-dict second argument" diff --git a/extra_tests/snippets/stdlib_traceback.py b/extra_tests/snippets/stdlib_traceback.py index c2cc5773d..b1b11a755 100644 --- a/extra_tests/snippets/stdlib_traceback.py +++ b/extra_tests/snippets/stdlib_traceback.py @@ -1,5 +1,9 @@ +import itertools import traceback +import _suggestions +from testutils import assert_raises + try: 1 / 0 except ZeroDivisionError as ex: @@ -25,3 +29,10 @@ try: except ZeroDivisionError as ex2: tb = traceback.extract_tb(ex2.__traceback__) assert len(tb) == 1 + +# The candidate list backing "Did you mean" suggestions is a list; an arbitrary +# iterable must be rejected rather than drained. + +with assert_raises(TypeError): + _suggestions._generate_suggestions(itertools.count(), "x") +assert _suggestions._generate_suggestions(["value"], "valu") == "value" diff --git a/extra_tests/snippets/stdlib_types.py b/extra_tests/snippets/stdlib_types.py index cdecf12dd..335069811 100644 --- a/extra_tests/snippets/stdlib_types.py +++ b/extra_tests/snippets/stdlib_types.py @@ -1,5 +1,6 @@ import _ast import platform +import sys import types from testutils import assert_raises @@ -34,3 +35,26 @@ def _run_missing_type_params_regression(): _run_missing_type_params_regression() + +if sys.implementation.name == "rustpython": + # __parameters__ is computed when the alias is built, and the walk descends + # into every list and tuple argument, so a self-referential or deeply + # nested argument must be caught. CPython, which also runs this snippet, + # does not walk into plain lists at all. + self_referential = [] + self_referential.append(self_referential) + with assert_raises(RecursionError): + list[self_referential] + + nested = [0] + for _ in range(sys.getrecursionlimit() * 2): + nested = [nested] + with assert_raises(RecursionError): + list[nested] + + # hashing an alias walks the same shape + deep_alias = int + for _ in range(sys.getrecursionlimit() * 2): + deep_alias = list[deep_alias] + with assert_raises(RecursionError): + hash(deep_alias) diff --git a/extra_tests/snippets/stdlib_typing.py b/extra_tests/snippets/stdlib_typing.py index 073489458..98d368c02 100644 --- a/extra_tests/snippets/stdlib_typing.py +++ b/extra_tests/snippets/stdlib_typing.py @@ -1,6 +1,9 @@ from collections.abc import Awaitable, Callable from typing import TypeVar +import _typing +from testutils import assert_raises + T = TypeVar("T") @@ -35,3 +38,10 @@ class ClassWithUnionParams: def method(self, value: Union[int, float]) -> Union[str, bytes]: return str(value) + + +# _idfunc takes exactly one argument, checked before the argument is read. + +assert _typing._idfunc(1) == 1 +with assert_raises(TypeError): + _typing._idfunc() From 7108ff0f699f8b89db243fd93c40125c463b834c Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:30:51 +0900 Subject: [PATCH 66/94] marshal: round-trip code constants through the runtime bag (#8516) * Keep abnormal marshal loop test aligned with CPython Assisted-by: OpenAI Codex:gpt-5.5 * marshal: retain runtime code constants while loading Decode code fields through the runtime MarshalBag so co_consts values that do not fit the compiler constant enum remain available to VM code wrappers while the compiler table receives shape placeholders. Assisted-by: OpenAI Codex:GPT-5.4 * marshal: write and read co_consts through the runtime bag serialize_code gains a serialize_code_with variant that writes each co_consts entry through a caller-supplied writer; serialize_code keeps the BorrowedConstant writer as its default. The VM writer passes its own write_object_depth, so a code constant that Literal holds but BorrowedConstant cannot describe reaches the stream instead of panicking in borrow_obj_constant, and a constant shared with the enclosing object takes an entry in the writer's reference table. PyMarshalBag implements constant_ref_from_value, bytes_from_value, str_from_value and tuple_elements_from_value, which deserialize_code_value_inner requires to decode code fields through the runtime bag. Assisted-by: Claude --- Lib/test/test_marshal.py | 14 +- crates/compiler-core/src/marshal.rs | 224 +++++++++++++++++++++++-- crates/vm/src/stdlib/marshal.rs | 30 +++- extra_tests/snippets/stdlib_marshal.py | 21 +++ 4 files changed, 267 insertions(+), 22 deletions(-) diff --git a/Lib/test/test_marshal.py b/Lib/test/test_marshal.py index 1f04a7f69..7d2bedf77 100644 --- a/Lib/test/test_marshal.py +++ b/Lib/test/test_marshal.py @@ -410,13 +410,13 @@ class BugsTestCase(unittest.TestCase): self.assertIsInstance(a[0], dict) self.assertIs(a[0][None], a) - # Direct self-reference which cannot be created in Python. CPython - # leaves this disabled because its reference counting cannot collect - # the resulting cycle; RustPython's tracing collector can. - data = b'\xa8\x01\x00\x00\x00r\x00\x00\x00\x00' # (,) - a = marshal.loads(data) - self.assertIsInstance(a, tuple) - self.assertIs(a[0], a) + # Direct self-reference which cannot be created in Python. + # This creates a reference loop which cannot be collected. + if False: + data = b'\xa8\x01\x00\x00\x00r\x00\x00\x00\x00' # (,) + a = marshal.loads(data) + self.assertIsInstance(a, tuple) + self.assertIs(a[0], a) # Direct self-references which cannot be created in Python # because of unhashability. diff --git a/crates/compiler-core/src/marshal.rs b/crates/compiler-core/src/marshal.rs index dd5d4f2cd..46e004794 100644 --- a/crates/compiler-core/src/marshal.rs +++ b/crates/compiler-core/src/marshal.rs @@ -555,6 +555,18 @@ pub trait MarshalBag: Copy { code: CodeObject<::Constant>, ) -> Self::Value; + /// Construct a runtime code object while retaining the exact values read + /// from ``co_consts``. Compiler bags ignore this second channel; runtime + /// bags use it for marshalable values (lists, dicts, sets, recursive + /// containers) that their compiler constant representation cannot hold. + fn make_code_with_constants( + &self, + code: CodeObject<::Constant>, + _constants: Vec, + ) -> Self::Value { + self.make_code(code) + } + fn make_stop_iter(&self) -> Result; fn make_list(&self, it: impl Iterator) -> Result; @@ -630,6 +642,30 @@ pub trait MarshalBag: Copy { ) -> Option<::Constant> { None } + + /// Convert a runtime constant to the compiler-side shape stored in + /// ``CodeObject``. Runtime implementations may return a semantically + /// unused placeholder when the exact value is carried by + /// `make_code_with_constants` instead. + fn code_constant_from_value( + &self, + value: &Self::Value, + ) -> Result<::Constant> { + self.constant_ref_from_value(value) + .ok_or(MarshalError::BadType) + } + + fn bytes_from_value(&self, _value: &Self::Value) -> Option> { + None + } + + fn str_from_value(&self, _value: &Self::Value) -> Option { + None + } + + fn tuple_elements_from_value(&self, _value: &Self::Value) -> Option> { + None + } } impl MarshalBag for Bag { @@ -731,6 +767,27 @@ impl MarshalBag for Bag { ) -> Option<::Constant> { Some(value.clone()) } + + fn bytes_from_value(&self, value: &Self::Value) -> Option> { + match value.borrow_constant() { + BorrowedConstant::Bytes { value } => Some(value.to_vec()), + _ => None, + } + } + + fn str_from_value(&self, value: &Self::Value) -> Option { + match value.borrow_constant() { + BorrowedConstant::Str { value } => Some(value.to_string_lossy().into_owned()), + _ => None, + } + } + + fn tuple_elements_from_value(&self, value: &Self::Value) -> Option> { + match value.borrow_constant() { + BorrowedConstant::Tuple { elements } => Some(elements.to_vec()), + _ => None, + } + } } pub const MAX_MARSHAL_STACK_DEPTH: usize = 2000; @@ -789,20 +846,8 @@ fn deserialize_value_after_header( }; let typ = Type::try_from(type_code)?; - // CPython's r_object() uses one global ref table: TYPE_CODE reserves its - // slot before reading code fields, and those fields may use later TYPE_REF - // indexes. Keep the same indexes even when Bag::Value and Constant differ. let value = if matches!(typ, Type::Code) { - let mut inner_refs: Vec::Constant>> = refs - .iter() - .map(|value| { - value - .as_ref() - .and_then(|value| bag.constant_ref_from_value(value)) - }) - .collect(); - let code = deserialize_code_inner(rdr, bag.constant_bag(), depth - 1, &mut inner_refs)?; - bag.make_code(code) + deserialize_code_value_inner(rdr, bag, depth - 1, refs)? } else { deserialize_value_typed(rdr, bag, depth, refs, typ, slot)? }; @@ -813,6 +858,137 @@ fn deserialize_value_after_header( Ok(value) } +/// Decode a code object through the runtime bag. CPython's marshal reader +/// keeps one reference table for the code fields and `co_consts`; using +/// `Bag::Value` here preserves that index space and lets runtime-only +/// constants survive alongside the compiler representation. +fn deserialize_code_value_inner( + rdr: &mut R, + bag: Bag, + depth: usize, + refs: &mut Vec>, +) -> Result { + if depth == 0 { + return Err(MarshalError::InvalidBytecode); + } + let arg_count = rdr.read_u32()?; + let posonlyarg_count = rdr.read_u32()?; + let kwonlyarg_count = rdr.read_u32()?; + let max_stackdepth = rdr.read_u32()?; + let flags = CodeFlags::from_bits_truncate(rdr.read_u32()?); + let child_depth = depth - 1; + + let code_value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + let code_bytes = bag + .bytes_from_value(&code_value) + .ok_or(MarshalError::BadType)?; + + let consts_value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + let constant_values = bag + .tuple_elements_from_value(&consts_value) + .ok_or(MarshalError::BadType)?; + let constants = constant_values + .iter() + .map(|value| bag.code_constant_from_value(value)) + .collect::>>()? + .into_iter() + .collect(); + + let read_strings = + |rdr: &mut R, refs: &mut Vec>| -> Result> { + let tuple = deserialize_value_depth(rdr, bag, child_depth, refs)?; + bag.tuple_elements_from_value(&tuple) + .ok_or(MarshalError::BadType)? + .iter() + .map(|value| bag.str_from_value(value).ok_or(MarshalError::BadType)) + .collect() + }; + let names_raw = read_strings(rdr, refs)?; + let localsplusnames = read_strings(rdr, refs)?; + + let kinds_value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + let localspluskinds = bag + .bytes_from_value(&kinds_value) + .ok_or(MarshalError::BadType)?; + + let read_string = + |rdr: &mut R, refs: &mut Vec>| -> Result { + let value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + bag.str_from_value(&value).ok_or(MarshalError::BadType) + }; + let source_path_raw = read_string(rdr, refs)?; + let obj_name_raw = read_string(rdr, refs)?; + let qualname_raw = read_string(rdr, refs)?; + + let first_line_raw = rdr.read_u32()? as i32; + let first_line_number = if first_line_raw > 0 { + OneIndexed::new(first_line_raw as usize) + } else { + None + }; + let linetable_value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + let linetable = bag + .bytes_from_value(&linetable_value) + .ok_or(MarshalError::BadType)? + .into_boxed_slice(); + let exceptiontable_value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + let exceptiontable = bag + .bytes_from_value(&exceptiontable_value) + .ok_or(MarshalError::BadType)? + .into_boxed_slice(); + + let lp = split_localplus( + &localsplusnames + .iter() + .map(|s| s.as_str()) + .collect::>(), + &localspluskinds, + arg_count, + kwonlyarg_count, + flags, + )?; + let instructions = CodeUnits::try_from(code_bytes.as_slice())?; + let locations = linetable_to_locations(&linetable, first_line_raw, instructions.len()); + let constant_bag = bag.constant_bag(); + let code = CodeObject { + instructions, + locations, + flags, + posonlyarg_count, + arg_count, + kwonlyarg_count, + source_path: constant_bag.make_name(&source_path_raw), + first_line_number, + max_stackdepth, + obj_name: constant_bag.make_name(&obj_name_raw), + qualname: constant_bag.make_name(&qualname_raw), + constants, + names: names_raw + .iter() + .map(|name| constant_bag.make_name(name)) + .collect(), + varnames: lp + .varnames + .iter() + .map(|name| constant_bag.make_name(name)) + .collect(), + cellvars: lp + .cellvars + .iter() + .map(|name| constant_bag.make_name(name)) + .collect(), + freevars: lp + .freevars + .iter() + .map(|name| constant_bag.make_name(name)) + .collect(), + localspluskinds: localspluskinds.into_boxed_slice(), + linetable, + exceptiontable, + }; + Ok(bag.make_code_with_constants(code, constant_values)) +} + fn deserialize_value_typed( rdr: &mut R, bag: Bag, @@ -1222,6 +1398,25 @@ pub fn serialize_value( /// Split varnames/cellvars/freevars are reassembled into /// co_localsplusnames/co_localspluskinds. pub fn serialize_code(buf: &mut W, code: &CodeObject) { + serialize_code_with(buf, code, |buf, constant| { + serialize_value(buf, constant.borrow_constant().into()).unwrap_or_else(|x| match x {}); + Ok::<(), core::convert::Infallible>(()) + }) + .unwrap_or_else(|x| match x {}) +} + +/// Serialize a code object, writing each `co_consts` entry through +/// `write_constant`. +/// +/// A runtime caller passes its own object writer so that values its constant +/// representation carries but `BorrowedConstant` cannot describe — lists, +/// dicts, sets — reach the stream, and so a constant shared with the enclosing +/// object keeps its entry in that writer's reference table. +pub fn serialize_code_with( + buf: &mut W, + code: &CodeObject, + mut write_constant: impl FnMut(&mut W, &C) -> core::result::Result<(), E>, +) -> core::result::Result<(), E> { // 1–5: scalar fields buf.write_u32(code.arg_count); buf.write_u32(code.posonlyarg_count); @@ -1238,7 +1433,7 @@ pub fn serialize_code(buf: &mut W, code: &CodeObject) buf.write_u8(Type::Tuple as u8); write_len(buf, code.constants.len()); for constant in &*code.constants { - serialize_value(buf, constant.borrow_constant().into()).unwrap_or_else(|x| match x {}) + write_constant(buf, constant)?; } // 8: co_names (tuple of strings) @@ -1281,6 +1476,7 @@ pub fn serialize_code(buf: &mut W, code: &CodeObject) // 16: co_exceptiontable buf.write_u8(Type::Bytes as u8); write_vec(buf, &code.exceptiontable); + Ok(()) } fn write_marshal_str(buf: &mut W, s: &str) { diff --git a/crates/vm/src/stdlib/marshal.rs b/crates/vm/src/stdlib/marshal.rs index a4665d0f5..3c2ab5817 100644 --- a/crates/vm/src/stdlib/marshal.rs +++ b/crates/vm/src/stdlib/marshal.rs @@ -324,7 +324,14 @@ mod decl { } } else if let Some(co) = obj.downcast_ref::() { buf.write_u8(b'c'); - marshal::serialize_code(buf, &co.code); + // `Literal` holds the exact object a constant was built from, so + // route `co_consts` back through the object writer: it reaches the + // values `BorrowedConstant` cannot describe and shares the one + // reference table the reader indexes against. + marshal::serialize_code_with(buf, &co.code, |buf, constant| { + let constant = PyObjectRef::from(constant.clone()); + write_object_depth(buf, &constant, refs, version, vm, depth - 1) + })?; } else if let Some(sl) = obj.downcast_ref::() { if version < 5 { return Err(vm.new_value_error("unmarshallable object")); @@ -570,6 +577,27 @@ mod decl { fn constant_bag(self) -> Self::ConstantBag { PyVmBag(self.vm) } + /// `Literal` wraps any object, so a decoded `co_consts` entry is + /// already its own compiler-side constant — no placeholder is needed + /// and `make_code_with_constants` keeps the default. + fn constant_ref_from_value(&self, value: &Self::Value) -> Option { + Some(Literal::from(value.clone())) + } + fn bytes_from_value(&self, value: &Self::Value) -> Option> { + value + .downcast_ref::() + .map(|bytes| bytes.as_bytes().to_vec()) + } + fn str_from_value(&self, value: &Self::Value) -> Option { + value + .downcast_ref::() + .map(|str| str.to_string_lossy().into_owned()) + } + fn tuple_elements_from_value(&self, value: &Self::Value) -> Option> { + value + .downcast_ref::() + .map(|tuple| tuple.as_slice().to_vec()) + } } fn deserialize_value( diff --git a/extra_tests/snippets/stdlib_marshal.py b/extra_tests/snippets/stdlib_marshal.py index db843ff65..8881d3e0a 100644 --- a/extra_tests/snippets/stdlib_marshal.py +++ b/extra_tests/snippets/stdlib_marshal.py @@ -74,6 +74,27 @@ class MarshalTests(unittest.TestCase): assert eval(loaded) == eval(orig) + def test_roundtrip_non_constant_co_consts(self): + # `code.replace` accepts any marshalable object, including values the + # compiler constant representation cannot describe. + orig = compile("1 + 1", "", "eval").replace( + co_consts=([1, 2], {"a": 3}, {4, 5}, 6) + ) + + loaded = marshal.loads(marshal.dumps(orig)) + + self.assertEqual(loaded.co_consts, ([1, 2], {"a": 3}, {4, 5}, 6)) + + def test_roundtrip_shared_co_const(self): + # A constant shared with the enclosing object is written once and both + # readers resolve the same reference. + shared = ["shared"] + orig = compile("1 + 1", "", "eval").replace(co_consts=(shared,)) + + loaded_code, loaded_shared = marshal.loads(marshal.dumps((orig, shared))) + + self.assertIs(loaded_code.co_consts[0], loaded_shared) + if __name__ == "__main__": unittest.main() From 1d6c6aece0392c39c101ad9f58fca1c937fe1d2f Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:58:42 +0900 Subject: [PATCH 67/94] marshal: refuse a back-reference to an object still being written (#8519) `w_ref` marks a code or slice entry incomplete until `w_complete`, because the reader rebuilds both from their fields and a `TYPE_REF` issued while those fields are still on the wire names an object that does not exist yet. `WriterRefTable` carries that marker and `write_object_depth` raises `cannot marshal recursion objects` instead of emitting the reference. `test_reference_loop_code`, `test_unmarshallable` and `test_reference_loop_slice` lose their RustPython markers; `test_marshal` is 75 run, 16 skipped. Assisted-by: Claude --- Lib/test/test_marshal.py | 3 -- crates/vm/src/stdlib/marshal.rs | 68 ++++++++++++++++++++++++--------- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/Lib/test/test_marshal.py b/Lib/test/test_marshal.py index 7d2bedf77..4e5311cd0 100644 --- a/Lib/test/test_marshal.py +++ b/Lib/test/test_marshal.py @@ -353,7 +353,6 @@ class BugsTestCase(unittest.TestCase): self.assertIsInstance(b[0], list) self.assertIs(b[0][0], b) - @unittest.skip("TODO: RUSTPYTHON; unexpected payload for constant python value") def test_reference_loop_code(self): def f(): return 1234.5 @@ -367,7 +366,6 @@ class BugsTestCase(unittest.TestCase): for v in range(marshal.version + 1): self.assertRaises(ValueError, marshal.dumps, code, v) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ValueError not raised by dumps def test_reference_loop_slice(self): a = slice([], None) a.start.append(a) @@ -541,7 +539,6 @@ class BugsTestCase(unittest.TestCase): _, dump_1, _ = assert_python_ok(*args, PYTHONHASHSEED="1") self.assertEqual(dump_0, dump_1) - @unittest.skip("TODO: RUSTPYTHON; unexpected payload for constant python value") def test_unmarshallable(self): # Check no crash after encountering unmarshallable objects. # See https://github.com/python/cpython/issues/106287. diff --git a/crates/vm/src/stdlib/marshal.rs b/crates/vm/src/stdlib/marshal.rs index 3c2ab5817..38891200b 100644 --- a/crates/vm/src/stdlib/marshal.rs +++ b/crates/vm/src/stdlib/marshal.rs @@ -131,8 +131,15 @@ mod decl { Ok(PyBytes::from(buf)) } + struct WriterRefEntry { + idx: u32, + /// Set between `reserve` and `complete` for the object kinds whose + /// immutable representation cannot be rebuilt from a back-reference. + incomplete: bool, + } + struct WriterRefTable { - map: std::collections::HashMap, + map: std::collections::HashMap, next_idx: u32, } @@ -143,23 +150,35 @@ mod decl { next_idx: 0, } } - fn try_ref(&mut self, buf: &mut Vec, obj: &PyObjectRef) -> bool { + /// `w_ref`: write a back-reference to an object already in the table. + /// Reaching an entry that is still being written is a recursion the + /// reader could not rebuild, so it is an error rather than a `TYPE_REF`. + fn try_ref(&mut self, buf: &mut Vec, obj: &PyObjectRef) -> Result { use marshal::Write; - let id = obj.get_id(); - if let Some(&idx) = self.map.get(&id) { - buf.write_u8(b'r'); - buf.write_u32(idx); - true - } else { - false + let Some(entry) = self.map.get(&obj.get_id()) else { + return Ok(false); + }; + if entry.incomplete { + return Err(()); } + buf.write_u8(b'r'); + buf.write_u32(entry.idx); + Ok(true) } - fn reserve(&mut self, obj: &PyObjectRef) -> u32 { + fn reserve(&mut self, obj: &PyObjectRef, incomplete: bool) -> u32 { let idx = self.next_idx; - self.map.insert(obj.get_id(), idx); + self.map + .insert(obj.get_id(), WriterRefEntry { idx, incomplete }); self.next_idx += 1; idx } + /// `w_complete`: the object's contents are on the stream, so a later + /// occurrence may reference it. + fn complete(&mut self, obj: &PyObjectRef) { + if let Some(entry) = self.map.get_mut(&obj.get_id()) { + entry.incomplete = false; + } + } } fn write_object( @@ -199,16 +218,28 @@ mod decl { || obj.downcast_ref::().is_some(); // FLAG_REF: check if already written, otherwise reserve slot - if !is_singleton - && let Some(rt) = refs.as_mut() - && rt.try_ref(buf, obj) - { - return Ok(()); + if !is_singleton && let Some(rt) = refs.as_mut() { + match rt.try_ref(buf, obj) { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(()) => { + return Err(vm.new_value_error(format!( + "cannot marshal recursion {} objects", + obj.class().name() + ))); + } + } } let type_pos = buf.len(); let use_ref = refs.is_some() && !is_singleton; + // A code or slice entry stays incomplete until its contents are + // written: the reader rebuilds both from their fields, so a + // back-reference issued while those fields are still being emitted + // would name an object that does not exist yet. + let requires_completion = obj.downcast_ref::().is_some() + || obj.downcast_ref::().is_some(); if use_ref { - refs.as_mut().unwrap().reserve(obj); + refs.as_mut().unwrap().reserve(obj, requires_completion); } if vm.is_none(obj) { @@ -366,6 +397,9 @@ mod decl { if use_ref { buf[type_pos] |= marshal::FLAG_REF; + if requires_completion { + refs.as_mut().unwrap().complete(obj); + } } Ok(()) } From 00461a094c2fc3ea15d955a134449ea505b7744f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=9A=A8=EC=A2=85?= Date: Fri, 14 Aug 2026 13:00:57 +0900 Subject: [PATCH 68/94] Preserve lone surrogates across WASM conversions (#8508) Assisted-by: Codex:GPT-5 --- crates/vm/src/py_serde.rs | 5 +- crates/wasm/src/convert.rs | 94 ++++++++++++++++++++++++++++--------- crates/wasm/src/vm_class.rs | 15 +++--- 3 files changed, 86 insertions(+), 28 deletions(-) diff --git a/crates/vm/src/py_serde.rs b/crates/vm/src/py_serde.rs index 50ea4422b..0e8b70781 100644 --- a/crates/vm/src/py_serde.rs +++ b/crates/vm/src/py_serde.rs @@ -63,7 +63,10 @@ impl serde::Serialize for PyObjectSerializer<'_> { seq.end() }; if let Some(s) = self.pyobject.downcast_ref::() { - serializer.serialize_str(s.as_ref()) + serializer.serialize_str( + s.to_str() + .ok_or_else(|| serde::ser::Error::custom("str contains surrogates"))?, + ) } else if self.pyobject.fast_isinstance(self.vm.ctx.types.float_type) { serializer.serialize_f64(float::get_value(self.pyobject)) } else if self.pyobject.fast_isinstance(self.vm.ctx.types.bool_type) { diff --git a/crates/wasm/src/convert.rs b/crates/wasm/src/convert.rs index 3e07b27d4..9349c7279 100644 --- a/crates/wasm/src/convert.rs +++ b/crates/wasm/src/convert.rs @@ -2,10 +2,13 @@ use crate::js_module; use crate::vm_class::{WASMVirtualMachine, stored_vm_from_wasm}; -use js_sys::{Array, ArrayBuffer, Object, Promise, Reflect, SyntaxError, Uint8Array}; +use js_sys::{ + Array, ArrayBuffer, JsString, Map, Object, Promise, Reflect, SyntaxError, Uint8Array, +}; +use rustpython_common::wtf8::{Wtf8, Wtf8Buf}; use rustpython_vm::{ AsObject, Py, PyObjectRef, PyPayload, PyResult, TryFromBorrowedObject, VirtualMachine, - builtins::{PyBaseException, PyBaseExceptionRef}, + builtins::{PyBaseException, PyBaseExceptionRef, PyDict, PyList, PyStr, PyTuple}, compiler::{CompileError, ParseError, parser::LexicalErrorType, parser::ParseErrorType}, exceptions, function::{ArgBytesLike, FuncArgs}, @@ -13,6 +16,26 @@ use rustpython_vm::{ }; use wasm_bindgen::{JsCast, closure::Closure, prelude::*}; +pub(crate) fn js_string_to_wtf8(value: &JsString) -> Wtf8Buf { + Wtf8Buf::from_wide(&value.iter().collect::>()) +} + +fn wtf8_to_js_string(value: &Wtf8) -> JsString { + const CHUNK_SIZE: usize = 8192; + + if let Ok(value) = value.as_str() { + return value.into(); + } + + value + .encode_wide() + .collect::>() + .chunks(CHUNK_SIZE) + .map(JsString::from_char_code) + .collect::() + .join("") +} + #[wasm_bindgen(inline_js = r" export class PyError extends Error { constructor(info) { @@ -119,12 +142,9 @@ pub fn py_to_js(vm: &VirtualMachine, py_obj: PyObjectRef) -> JsValue { if let Some(ref kwargs) = kwargs { for pair in object_entries(kwargs) { let (key, val) = pair?; - py_func_args.kwargs.insert( - // JS strings coming in are UTF-16; go through Rust `String` - // (kwargs keys are now WTF-8, so convert String -> Wtf8Buf). - String::from(js_sys::JsString::from(key)).into(), - js_to_py(vm, val), - ); + py_func_args + .kwargs + .insert(js_string_to_wtf8(&key.into()), js_to_py(vm, val)); } } let result = py_obj.call(py_func_args, vm); @@ -151,17 +171,44 @@ pub fn py_to_js(vm: &VirtualMachine, py_obj: PyObjectRef) -> JsValue { } if let Ok(bytes) = ArgBytesLike::try_from_borrowed_object(vm, &py_obj) { - bytes.with_ref(|bytes| unsafe { + return bytes.with_ref(|bytes| unsafe { // `Uint8Array::view` is an `unsafe fn` because it provides // a direct view into the WASM linear memory; if you were to allocate // something with Rust that view would probably become invalid. It's safe // because we then copy the array using `Uint8Array::slice`. let view = Uint8Array::view(bytes); view.slice(0, bytes.len() as u32).into() - }) + }); + } + py_serde_to_js(vm, &py_obj).unwrap_or(JsValue::UNDEFINED) +} + +fn py_serde_to_js( + vm: &VirtualMachine, + py_obj: &PyObjectRef, +) -> Result { + if let Some(value) = py_obj.downcast_ref::() { + Ok(wtf8_to_js_string(value.as_wtf8()).into()) + } else if let Some(value) = py_obj.downcast_ref::() { + let array = Array::new(); + for item in value.borrow_vec().iter() { + array.push(&py_serde_to_js(vm, item)?); + } + Ok(array.into()) + } else if let Some(value) = py_obj.downcast_ref::() { + let array = Array::new(); + for item in value { + array.push(&py_serde_to_js(vm, item)?); + } + Ok(array.into()) + } else if let Some(value) = py_obj.downcast_ref::() { + let map = Map::new(); + for (key, value) in value { + map.set(&py_serde_to_js(vm, &key)?, &py_serde_to_js(vm, &value)?); + } + Ok(map.into()) } else { - py_serde::serialize(vm, &py_obj, &serde_wasm_bindgen::Serializer::new()) - .unwrap_or(JsValue::UNDEFINED) + py_serde::serialize(vm, py_obj, &serde_wasm_bindgen::Serializer::new()) } } @@ -199,6 +246,15 @@ pub fn js_to_py(vm: &VirtualMachine, js_val: JsValue) -> PyObjectRef { .map(|val| js_to_py(vm, val.expect("Iteration over array failed"))) .collect(); vm.ctx.new_list(elems).into() + } else if let Some(map) = js_val.dyn_ref::() { + let dict = vm.ctx.new_dict(); + for entry in map.entries() { + let entry = Array::from(&entry.expect("Iteration over map failed")); + let key = js_to_py(vm, entry.get(0)); + dict.set_item(&*key, js_to_py(vm, entry.get(1)), vm) + .unwrap(); + } + dict.into() } else if ArrayBuffer::is_view(&js_val) || js_val.is_instance_of::() { // unchecked_ref because if it's not an ArrayBuffer it could either be a TypedArray // or a DataView, but they all have a `buffer` property @@ -216,12 +272,8 @@ pub fn js_to_py(vm: &VirtualMachine, js_val: JsValue) -> PyObjectRef { for pair in object_entries(&Object::from(js_val)) { let (key, val) = pair.expect("iteration over object to not fail"); let py_val = js_to_py(vm, val); - dict.set_item( - String::from(js_sys::JsString::from(key)).as_str(), - py_val, - vm, - ) - .unwrap(); + dict.set_item(&*js_string_to_wtf8(&key.into()), py_val, vm) + .unwrap(); } dict.into() } @@ -232,9 +284,7 @@ pub fn js_to_py(vm: &VirtualMachine, js_val: JsValue) -> PyObjectRef { move |args: FuncArgs, vm: &VirtualMachine| -> PyResult { let this = Object::new(); for (k, v) in args.kwargs { - // WTF-8 -> JS string: lone surrogates in the key become U+FFFD - // (wasm-bindgen only accepts Rust `String`); acceptable at this boundary. - Reflect::set(&this, &k.to_string().into(), &py_to_js(vm, v)) + Reflect::set(&this, &wtf8_to_js_string(&k).into(), &py_to_js(vm, v)) .expect("property to be settable"); } let js_args = args @@ -253,6 +303,8 @@ pub fn js_to_py(vm: &VirtualMachine, js_val: JsValue) -> PyObjectRef { } else if js_val.is_undefined() { // Because `JSON.stringify(undefined)` returns undefined vm.ctx.none() + } else if js_val.is_string() { + vm.ctx.new_str(js_string_to_wtf8(&js_val.into())).into() } else { py_serde::deserialize(vm, serde_wasm_bindgen::Deserializer::from(js_val)) .unwrap_or_else(|_| vm.ctx.none()) diff --git a/crates/wasm/src/vm_class.rs b/crates/wasm/src/vm_class.rs index 5e09af0ee..80f3ece13 100644 --- a/crates/wasm/src/vm_class.rs +++ b/crates/wasm/src/vm_class.rs @@ -325,9 +325,12 @@ impl WASMVirtualMachine { if let Some(imports) = imports { for entry in convert::object_entries(&imports) { let (key, value) = entry?; - let key: String = Object::from(key).to_string().into(); attrs - .set_item(key.as_str(), convert::js_to_py(vm, value), vm) + .set_item( + &*convert::js_string_to_wtf8(&key.into()), + convert::js_to_py(vm, value), + vm, + ) .into_js(vm)?; } } @@ -356,10 +359,10 @@ impl WASMVirtualMachine { let py_module = vm.new_module(&name, vm.ctx.new_dict(), None); for entry in convert::object_entries(&module) { let (key, value) = entry?; - let key = Object::from(key).to_string(); - extend_module!(vm, &py_module, { - String::from(key) => convert::js_to_py(vm, value), - }); + let key = vm.ctx.new_str(convert::js_string_to_wtf8(&key.into())); + py_module + .set_attr(&key, convert::js_to_py(vm, value), vm) + .into_js(vm)?; } let sys_modules = vm.sys_module.get_attr("modules", vm).into_js(vm)?; From 259ce8ce66475bac3f6e7948f78fc66c3b0095b9 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:05:30 +0900 Subject: [PATCH 69/94] Fix the itertools.tee leak and race, the contextvars and generator races, _asyncio's InvalidStateError lookup, and _imp's frozen data (#8518) * _imp: read get_frozen_object's data as a whole marshal value The data argument went through deserialize_code(), which reads a code body without the type byte in front of it, so nothing marshal.dumps() produces was accepted. Read it with marshal.loads() and require a code object back. Assisted-by: Claude * _imp: bind find_frozen's arguments with FromArgs The arity check was done by hand on a FuncArgs. Take the arguments through a FromArgs struct instead, which makes withdata keyword-only, and fill in the data it asks for: the frozen encoding is not marshal, so the code is re-serialized into what get_frozen_object() reads back. Assisted-by: Claude * itertools: give tee's shared buffer a Python type The buffer was a PyRc, which is not a Python object, so the collector could not walk into it and any cycle running through a tee was uncollectable. Make it the _tee_dataobject type with a traverse, held by PyRef, and split the rest along the same lines: tee() is a function, _tee is the iterator type it builds, and _tee takes a single iterable rather than returning a tuple from __new__. _tee is weak-referenceable, tee() rejects a negative n with ValueError and reserves its tuple fallibly. test_itertools.test_tee passes now; its expectedFailure marker is removed. Assisted-by: Claude * contextvars: hold the shared context state under locks The variable map was a RefCell, the enter flag, the context index, the token's used flag and the variable hash were Cells, and each carried an unsafe impl Sync. A Context or ContextVar shared between threads overlapped their borrows and panicked. The map and the per-variable cache are now PyMutex, the flags and the index are atomics, entering a context is a compare_exchange, and the three unsafe impl Sync are gone. The cache also stopped being read through AtomicCell::as_ptr, which raced a concurrent store on a value holding a PyObjectRef. Values displaced from the map or the cache are dropped after the lock is released: __del__ can come straight back into the same context, and the locks are not reentrant. Assisted-by: Claude * generator: read the frame state under the running claim send(), send_none(), throw() and close() read `closed` and `frame.lasti()` before `running` was compare_exchanged, so the frame they went on to resume could be one another thread had already advanced. A resume that decided from `lasti() == 0` that the generator had not started pushes no value onto the value stack, and the code after the yield pops one, which underflows the stack. The compare_exchange now hands back a guard, taken before those reads and released after maybe_close(), so the generator is retired while it is still claimed. Assisted-by: Claude * itertools: claim a tee's position and its buffer as one step `_tee::next` read `index` and moved it on afterwards, and `_tee_dataobject::get_item` released `running` before the value it fetched from the source was cached. Two callers on one `_tee` then read the same index, hand out the same value twice and advance past a value that was never cached, which leaves `index` past `values.len()` and indexes the buffer out of bounds. Two callers at the same index on separate tees each fetch a value from the source, and one of the two is dropped without reaching a caller. Both claims now cover the read and the update. Assisted-by: Claude * _asyncio: require InvalidStateError to be a type new_invalid_state_error() called whatever `asyncio.exceptions.InvalidStateError` names and unwrapped the downcast of the result, so a future asked for a result it does not have panicked once that attribute was rebound to something that is not an exception: asyncio.InvalidStateError = lambda *args: 42 _asyncio.Future(loop=object()).result() The type is looked up the way get_cancelled_error_type() looks its own up, and raised with new_exception_msg; a lookup that does not produce an exception type falls back to RuntimeError, as the other arms already did. Assisted-by: Claude --- Lib/test/test_itertools.py | 1 - crates/stdlib/src/_asyncio.rs | 22 +-- crates/stdlib/src/contextvars.rs | 149 +++++++++--------- crates/vm/src/coroutine.rs | 111 ++++++++----- crates/vm/src/stdlib/_imp.rs | 51 +++--- crates/vm/src/stdlib/itertools.rs | 122 ++++++++------ extra_tests/snippets/stdlib_asyncio.py | 23 +++ extra_tests/snippets/stdlib_gc.py | 2 + extra_tests/snippets/stdlib_imp.py | 6 + .../snippets/stdlib_threading_contextvars.py | 72 +++++++++ .../snippets/stdlib_threading_generator.py | 95 +++++++++++ .../stdlib_threading_itertools_tee.py | 57 +++++++ 12 files changed, 521 insertions(+), 190 deletions(-) create mode 100644 extra_tests/snippets/stdlib_threading_contextvars.py create mode 100644 extra_tests/snippets/stdlib_threading_generator.py create mode 100644 extra_tests/snippets/stdlib_threading_itertools_tee.py diff --git a/Lib/test/test_itertools.py b/Lib/test/test_itertools.py index b91e3735d..c1695690b 100644 --- a/Lib/test/test_itertools.py +++ b/Lib/test/test_itertools.py @@ -1270,7 +1270,6 @@ class TestBasicOps(unittest.TestCase): self.assertRaises(TypeError, next, dropwhile(10, [(4,5)])) self.assertRaises(ValueError, next, dropwhile(errfunc, [(4,5)])) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_tee(self): n = 200 diff --git a/crates/stdlib/src/_asyncio.rs b/crates/stdlib/src/_asyncio.rs index b311db4a3..9ad75fb8d 100644 --- a/crates/stdlib/src/_asyncio.rs +++ b/crates/stdlib/src/_asyncio.rs @@ -2745,16 +2745,20 @@ pub(crate) mod _asyncio { } } + fn get_invalid_state_error_type(vm: &VirtualMachine) -> PyResult { + let module = vm.import("asyncio.exceptions", 0)?; + let exc_type = vm + .get_attribute_opt(module, vm.ctx.intern_str("InvalidStateError"))? + .ok_or_else(|| vm.new_attribute_error("InvalidStateError not found"))?; + exc_type + .downcast() + .map_err(|_| vm.new_type_error("InvalidStateError is not a type")) + } + fn new_invalid_state_error(vm: &VirtualMachine, msg: &str) -> PyBaseExceptionRef { - match vm.import("asyncio.exceptions", 0) { - Ok(module) => { - match vm.get_attribute_opt(module, vm.ctx.intern_str("InvalidStateError")) { - Ok(Some(exc_type)) => match exc_type.call((msg,), vm) { - Ok(exc) => exc.downcast().unwrap(), - Err(_) => vm.new_runtime_error(msg.to_string()), - }, - _ => vm.new_runtime_error(msg.to_string()), - } + match get_invalid_state_error_type(vm) { + Ok(invalid_state_error) => { + vm.new_exception_msg(invalid_state_error, msg.to_string().into()) } Err(_) => vm.new_runtime_error(msg.to_string()), } diff --git a/crates/stdlib/src/contextvars.rs b/crates/stdlib/src/contextvars.rs index 19fbcb841..e3823f6ac 100644 --- a/crates/stdlib/src/contextvars.rs +++ b/crates/stdlib/src/contextvars.rs @@ -15,16 +15,16 @@ mod _contextvars { AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, atomic_func, builtins::{PyGenericAlias, PyList, PyStrRef, PyType, PyTypeRef}, class::StaticType, - common::{hash::PyHash, lock::LazyLock, wtf8::Wtf8Buf}, + common::{ + hash::PyHash, + lock::{LazyLock, PyMutex}, + wtf8::Wtf8Buf, + }, function::{ArgCallable, FuncArgs, OptionalArg}, protocol::{PyMappingMethods, PySequenceMethods}, types::{AsMapping, AsSequence, Constructor, Hashable, Iterable, Representable}, }; - use core::{ - cell::{Cell, RefCell, UnsafeCell}, - sync::atomic::Ordering, - }; - use crossbeam_utils::atomic::AtomicCell; + use core::sync::atomic::{AtomicBool, AtomicI64, AtomicUsize, Ordering}; use indexmap::IndexMap; // TODO: Real hamt implementation @@ -33,7 +33,7 @@ mod _contextvars { #[pyclass(no_attr, name = "Hamt", module = "contextvars")] #[derive(Debug, PyPayload)] pub(crate) struct HamtObject { - hamt: RefCell, + hamt: PyMutex, } #[pyclass] @@ -42,23 +42,19 @@ mod _contextvars { impl Default for HamtObject { fn default() -> Self { Self { - hamt: RefCell::new(Hamt::default()), + hamt: PyMutex::new(Hamt::default()), } } } - unsafe impl Sync for HamtObject {} - #[derive(Debug)] struct ContextInner { - idx: Cell, + idx: AtomicUsize, vars: PyRef, // PyObject *ctx_weakreflist; - entered: Cell, + entered: AtomicBool, } - unsafe impl Sync for ContextInner {} - #[pyattr] #[pyclass(name = "Context")] #[derive(Debug, PyPayload)] @@ -71,23 +67,30 @@ mod _contextvars { fn empty(vm: &VirtualMachine) -> Self { Self { inner: ContextInner { - idx: Cell::new(usize::MAX), + idx: AtomicUsize::new(usize::MAX), vars: HamtObject::default().into_ref(&vm.ctx), - entered: Cell::new(false), + entered: AtomicBool::new(false), }, } } - fn borrow_vars(&self) -> impl core::ops::Deref + '_ { - self.inner.vars.hamt.borrow() + fn borrow_vars(&self) -> impl core::ops::DerefMut + '_ { + self.inner.vars.hamt.lock() } fn borrow_vars_mut(&self) -> impl core::ops::DerefMut + '_ { - self.inner.vars.hamt.borrow_mut() + self.inner.vars.hamt.lock() } fn enter(zelf: &Py, vm: &VirtualMachine) -> PyResult<()> { - if zelf.inner.entered.get() { + // A context is entered by one thread at a time, so the check and the + // claim have to be a single step. + if zelf + .inner + .entered + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { return Err(vm.new_runtime_error(format!( "cannot enter context: {} is already entered", zelf.as_object().repr(vm)? @@ -95,16 +98,15 @@ mod _contextvars { } super::CONTEXTS.with_borrow_mut(|ctxs| { - zelf.inner.idx.set(ctxs.len()); + zelf.inner.idx.store(ctxs.len(), Ordering::Relaxed); ctxs.push(zelf.to_owned()); }); - zelf.inner.entered.set(true); Ok(()) } fn exit(zelf: &Py, vm: &VirtualMachine) -> PyResult<()> { - if !zelf.inner.entered.get() { + if !zelf.inner.entered.load(Ordering::Acquire) { return Err(vm.new_runtime_error(format!( "cannot exit context: {} is not entered", zelf.as_object().repr(vm)? @@ -120,7 +122,7 @@ mod _contextvars { ) }) })?; - zelf.inner.entered.set(false); + zelf.inner.entered.store(false, Ordering::Release); Ok(()) } @@ -131,8 +133,8 @@ mod _contextvars { ctx.clone() } else { let ctx = Self::empty(vm); - ctx.inner.idx.set(0); - ctx.inner.entered.set(true); + ctx.inner.idx.store(0, Ordering::Relaxed); + ctx.inner.entered.store(true, Ordering::Release); let ctx = ctx.into_ref(&vm.ctx); ctxs.push(ctx); ctxs[0].clone() @@ -170,13 +172,13 @@ mod _contextvars { fn copy(&self, vm: &VirtualMachine) -> Self { // Deep copy the vars - clone the underlying Hamt data, not just the PyRef let vars_copy = HamtObject { - hamt: RefCell::new(self.inner.vars.hamt.borrow().clone()), + hamt: PyMutex::new(self.inner.vars.hamt.lock().clone()), }; Self { inner: ContextInner { - idx: Cell::new(usize::MAX), + idx: AtomicUsize::new(usize::MAX), vars: vars_copy.into_ref(&vm.ctx), - entered: Cell::new(false), + entered: AtomicBool::new(false), }, } } @@ -186,11 +188,8 @@ mod _contextvars { var: PyRef, vm: &VirtualMachine, ) -> PyResult { - let vars = self.borrow_vars(); - let item = vars - .get(&*var) - .ok_or_else(|| vm.new_key_error(var.into()))?; - Ok(item.to_owned()) + let item = self.borrow_vars().get(&*var).map(|item| item.to_owned()); + item.ok_or_else(|| vm.new_key_error(var.into())) } fn __len__(&self) -> usize { @@ -290,11 +289,11 @@ mod _contextvars { name: String, default: Option, #[pytraverse(skip)] - cached: AtomicCell>, + cached: PyMutex>, #[pytraverse(skip)] - cached_id: core::sync::atomic::AtomicUsize, // cached_tsid in CPython + cached_id: AtomicUsize, // cached_tsid in CPython #[pytraverse(skip)] - hash: UnsafeCell, + hash: AtomicI64, } impl core::fmt::Debug for ContextVar { @@ -303,8 +302,6 @@ mod _contextvars { } } - unsafe impl Sync for ContextVar {} - impl PartialEq for ContextVar { fn eq(&self, other: &Self) -> bool { core::ptr::eq(self, other) @@ -320,12 +317,15 @@ mod _contextvars { impl ContextVar { fn delete(zelf: &Py, vm: &VirtualMachine) -> PyResult<()> { - zelf.cached.store(None); + let cached = zelf.cached.lock().take(); + drop(cached); let ctx = PyContext::current(vm); - let mut vars = ctx.borrow_vars_mut(); - if vars.swap_remove(zelf).is_none() { + let removed = ctx.borrow_vars_mut().swap_remove(zelf); + let existed = removed.is_some(); + drop(removed); + if !existed { // TODO: // PyErr_SetObject(PyExc_LookupError, (PyObject *)var); return Err(vm.new_lookup_error(zelf.as_object().repr(vm)?.as_wtf8().to_owned())); @@ -338,16 +338,17 @@ mod _contextvars { fn set_inner(zelf: &Py, value: PyObjectRef, vm: &VirtualMachine) { let ctx = PyContext::current(vm); - let mut vars = ctx.borrow_vars_mut(); - vars.insert(zelf.to_owned(), value.clone()); + let replaced = ctx.borrow_vars_mut().insert(zelf.to_owned(), value.clone()); + drop(replaced); zelf.cached_id.store(ctx.get_id(), Ordering::SeqCst); let cache = ContextVarCache { object: value, - idx: ctx.inner.idx.get(), + idx: ctx.inner.idx.load(Ordering::Relaxed), }; - zelf.cached.store(Some(cache)); + let replaced = zelf.cached.lock().replace(cache); + drop(replaced); } fn generate_hash(zelf: &Py, vm: &VirtualMachine) -> PyHash { @@ -370,28 +371,32 @@ mod _contextvars { default: OptionalArg, vm: &VirtualMachine, ) -> PyResult> { - let found = super::CONTEXTS.with_borrow(|ctxs| { - let ctx = ctxs.last()?; - let cached_ptr = zelf.cached.as_ptr(); - debug_assert!(!cached_ptr.is_null()); - if let Some(cached) = unsafe { &*cached_ptr } + // The replaced cache entry comes back out so that dropping it, which + // can run a __del__ that calls back in, happens with no lock held. + let (found, replaced) = super::CONTEXTS.with_borrow(|ctxs| { + let Some(ctx) = ctxs.last() else { + return (None, None); + }; + let mut cached = zelf.cached.lock(); + if let Some(cached) = &*cached && zelf.cached_id.load(Ordering::SeqCst) == ctx.get_id() && cached.idx + 1 == ctxs.len() { - return Some(cached.object.clone()); + return (Some(cached.object.clone()), None); } - let vars = ctx.borrow_vars(); - let obj = vars.get(zelf)?; + let Some(obj) = ctx.borrow_vars().get(zelf).map(|obj| obj.to_owned()) else { + return (None, None); + }; zelf.cached_id.store(ctx.get_id(), Ordering::SeqCst); - // TODO: ensure cached is not changed - let _removed = zelf.cached.swap(Some(ContextVarCache { + let replaced = cached.replace(ContextVarCache { object: obj.clone(), idx: ctxs.len() - 1, - })); + }); - Some(obj.clone()) + (Some(obj), replaced) }); + drop(replaced); let value = if let Some(value) = found { value @@ -425,7 +430,7 @@ mod _contextvars { #[pymethod] fn reset(zelf: &Py, token: PyRef, vm: &VirtualMachine) -> PyResult<()> { - if token.used.get() { + if token.used.load(Ordering::Acquire) { return Err(vm.new_runtime_error(format!( "{} has already been used once", token.as_object().repr(vm)? @@ -447,7 +452,7 @@ mod _contextvars { ))); } - token.used.set(true); + token.used.store(true, Ordering::Release); if let Some(old_value) = &token.old_value { Self::set_inner(zelf, old_value.clone(), vm); @@ -484,15 +489,13 @@ mod _contextvars { name: args.name.to_string(), default: args.default.into_option(), cached_id: 0.into(), - cached: AtomicCell::new(None), - hash: UnsafeCell::new(0), + cached: PyMutex::new(None), + hash: AtomicI64::new(0), }; let py_var = var.into_ref_with_type(vm, cls)?; - unsafe { - // SAFETY: py_var is not exposed to python memory model yet - *py_var.hash.get() = Self::generate_hash(&py_var, vm) - }; + let hash = Self::generate_hash(&py_var, vm); + py_var.hash.store(hash, Ordering::Relaxed); Ok(py_var.into()) } @@ -504,14 +507,14 @@ mod _contextvars { impl core::hash::Hash for ContextVar { #[inline] fn hash(&self, state: &mut H) { - unsafe { *self.hash.get() }.hash(state) + self.hash.load(Ordering::Relaxed).hash(state) } } impl Hashable for ContextVar { #[inline] fn hash(zelf: &Py, _vm: &VirtualMachine) -> PyResult { - Ok(unsafe { *zelf.hash.get() }) + Ok(zelf.hash.load(Ordering::Relaxed)) } } @@ -537,11 +540,9 @@ mod _contextvars { ctx: PyRef, // tok_ctx in CPython var: PyRef, // tok_var in CPython old_value: Option, // tok_oldval in CPython - used: Cell, + used: AtomicBool, } - unsafe impl Sync for ContextToken {} - #[pyclass(with(Constructor, Representable))] impl ContextToken { #[pygetset] @@ -598,7 +599,11 @@ mod _contextvars { impl Representable for ContextToken { #[inline] fn repr_wtf8(zelf: &Py, vm: &VirtualMachine) -> PyResult { - let used = if zelf.used.get() { " used" } else { "" }; + let used = if zelf.used.load(Ordering::Acquire) { + " used" + } else { + "" + }; let var = Representable::repr_wtf8(&zelf.var, vm)?; let ptr = zelf.as_object().get_id() as *const u8; let mut result = Wtf8Buf::from(format!("( &self, - jen: &PyObject, + _claim: &RunningGuard<'_>, vm: &VirtualMachine, func: F, - ) -> (PyResult, bool) + ) -> PyResult where F: FnOnce(&Py) -> PyResult, { - if self.running.compare_exchange(false, true).is_err() { - return ( - Err(vm.new_value_error(format!("{} already executing", gen_name(jen, vm)))), - false, - ); - } - - // SAFETY: running.compare_exchange guarantees exclusive access + // SAFETY: the claim guarantees exclusive access let gen_exc = unsafe { self.exception.swap(None) }; let exception_ptr = &self.exception as *const PyAtomicRef>; - let result = vm.resume_gen_frame(&self.frame, gen_exc, |f| { + vm.resume_gen_frame(&self.frame, gen_exc, |f| { let result = func(f); - // SAFETY: exclusive access guaranteed by running flag + // SAFETY: exclusive access guaranteed by the claim let _old = unsafe { (*exception_ptr).swap(vm.current_exception()) }; result - }); - - self.running.store(false); - (result, true) + }) } fn finalize_send_result( &self, result: PyResult, - entered_frame: bool, jen: &PyObject, vm: &VirtualMachine, ) -> PyResult { - self.maybe_close(&result, entered_frame); match result { Ok(exec_res) => Ok(exec_res.into_iter_return(vm)), Err(e) => { @@ -177,16 +191,20 @@ impl Coro { if self.closed.load() { return Ok(PyIterReturn::StopIteration(None)); } - if self.running.load() { - return Err(vm.new_value_error(format!("{} already executing", gen_name(jen, vm)))); + let claim = self.claim(jen, vm)?; + // The generator can have run to its end in the meantime. + if self.closed.load() { + return Ok(PyIterReturn::StopIteration(None)); } let value = if self.frame.lasti() > 0 { Some(vm.ctx.none()) } else { None }; - let (result, entered_frame) = self.run_with_context(jen, vm, |f| f.resume(value, vm)); - self.finalize_send_result(result, entered_frame, jen, vm) + let result = self.run_claimed(&claim, vm, |f| f.resume(value, vm)); + self.maybe_close(&result, &claim); + drop(claim); + self.finalize_send_result(result, jen, vm) } pub fn send( @@ -198,8 +216,10 @@ impl Coro { if self.closed.load() { return Ok(PyIterReturn::StopIteration(None)); } - if self.running.load() { - return Err(vm.new_value_error(format!("{} already executing", gen_name(jen, vm)))); + let claim = self.claim(jen, vm)?; + // The generator can have run to its end in the meantime. + if self.closed.load() { + return Ok(PyIterReturn::StopIteration(None)); } let value = if self.frame.lasti() > 0 { Some(value) @@ -211,8 +231,10 @@ impl Coro { } else { None }; - let (result, entered_frame) = self.run_with_context(jen, vm, |f| f.resume(value, vm)); - self.finalize_send_result(result, entered_frame, jen, vm) + let result = self.run_claimed(&claim, vm, |f| f.resume(value, vm)); + self.maybe_close(&result, &claim); + drop(claim); + self.finalize_send_result(result, jen, vm) } pub fn throw( @@ -237,13 +259,25 @@ impl Coro { // Validate exception type before entering generator context. // Invalid types propagate to caller without closing the generator. crate::exceptions::ExceptionCtor::try_from_object(vm, exc_type.clone())?; - let (result, entered_frame) = - self.run_with_context(jen, vm, |f| f.gen_throw(vm, exc_type, exc_val, exc_tb)); - self.maybe_close(&result, entered_frame); + let claim = self.claim(jen, vm)?; + // The generator can have run to its end in the meantime. Normalizing + // runs the exception's constructor, so let the claim go first. + if self.closed.load() { + drop(claim); + return Err(vm.normalize_exception(exc_type, exc_val, exc_tb)?); + } + let result = self.run_claimed(&claim, vm, |f| f.gen_throw(vm, exc_type, exc_val, exc_tb)); + self.maybe_close(&result, &claim); + drop(claim); Ok(result?.into_iter_return(vm)) } pub fn close(&self, jen: &PyObject, vm: &VirtualMachine) -> PyResult { + if self.closed.load() { + return Ok(vm.ctx.none()); + } + let claim = self.claim(jen, vm)?; + // The generator can have run to its end in the meantime. if self.closed.load() { return Ok(vm.ctx.none()); } @@ -252,7 +286,7 @@ impl Coro { self.closed.store(true); return Ok(vm.ctx.none()); } - let (result, entered_frame) = self.run_with_context(jen, vm, |f| { + let result = self.run_claimed(&claim, vm, |f| { f.gen_throw( vm, vm.ctx.exceptions.generator_exit.to_owned().into(), @@ -260,16 +294,11 @@ impl Coro { vm.ctx.none(), ) }); - if !entered_frame { - return match result { - Err(err) => Err(err), - Ok(_) => unreachable!("run_with_context preflight returned without an error"), - }; - } self.closed.store(true); // Release frame locals and stack to free references held by the // closed generator, matching gen_send_ex2 with close_on_completion. self.clear_frame_locals_on_close(); + drop(claim); match result { Ok(ExecutionResult::Yield(_)) => { Err(vm.new_runtime_error(format!("{} ignored GeneratorExit", gen_name(jen, vm)))) diff --git a/crates/vm/src/stdlib/_imp.rs b/crates/vm/src/stdlib/_imp.rs index fa979fcad..50f0b0be8 100644 --- a/crates/vm/src/stdlib/_imp.rs +++ b/crates/vm/src/stdlib/_imp.rs @@ -179,7 +179,7 @@ mod _imp { PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{PyBytesRef, PyCode, PyMemoryView, PyModule, PyStrRef, PyUtf8StrRef}, convert::TryFromBorrowedObject, - function::{FuncArgs, OptionalArg}, + function::OptionalArg, import, version, }; @@ -264,21 +264,20 @@ mod _imp { if let OptionalArg::Present(data) = data && !vm.is_none(&data) { - let buf = crate::protocol::PyBuffer::try_from_borrowed_object(vm, &data)?; - let contiguous = buf.as_contiguous().ok_or_else(|| { - vm.new_buffer_error("get_frozen_object() requires a contiguous buffer") - })?; let invalid_err = || { vm.new_import_error( format!("Frozen object named '{}' is invalid", name.as_str()), name.clone().into_wtf8(), ) }; - let bag = crate::builtins::code::PyVmBag(vm); - let code = - rustpython_compiler_core::marshal::deserialize_code(&mut &contiguous[..], bag) - .map_err(|_| invalid_err())?; - return Ok(PyCode::new_ref_with_bag(vm, code)); + // A non-buffer is a TypeError, not invalid frozen data. + crate::protocol::PyBuffer::try_from_borrowed_object(vm, &data)?; + // The data is a marshalled code object: a whole marshal value, which + // deserialize_code() does not read — it takes the code body alone, + // without the type byte the writer puts in front of it. + let loads = vm.import("marshal", 0)?.get_attr("loads", vm)?; + let code = loads.call((data,), vm).map_err(|_| invalid_err())?; + return code.downcast::().map_err(|_| invalid_err()); } import::make_frozen(vm, name.as_str()) } @@ -317,19 +316,21 @@ mod _imp { .collect() } + #[derive(FromArgs)] + struct FindFrozenArgs { + #[pyarg(positional)] + name: PyUtf8StrRef, + #[pyarg(named, default = false)] + withdata: bool, + } + #[allow(clippy::type_complexity)] #[pyfunction] fn find_frozen( - args: FuncArgs, + args: FindFrozenArgs, vm: &VirtualMachine, ) -> PyResult>, bool, Option)>> { - if args.args.len() > 1 { - return Err(vm.new_type_error(format!( - "find_frozen() takes exactly 1 positional argument ({} given)", - args.args.len() - ))); - } - let (name,): (PyUtf8StrRef,) = args.bind(vm)?; + let FindFrozenArgs { name, withdata } = args; let name_str = name.as_str(); let info = match super::find_frozen(name_str, vm) { @@ -340,6 +341,18 @@ mod _imp { Err(e) => return Err(e.to_pyexception(name_str, vm)), }; + // The data is what get_frozen_object() takes back, i.e. marshalled code. + // Frozen modules are stored in their own encoding, so it has to be + // re-serialized rather than handed out as a view of the stored bytes. + let data = if withdata { + let code = PyCode::new_ref_from_frozen(vm, info.code); + let dumps = vm.import("marshal", 0)?.get_attr("dumps", vm)?; + let bytes = dumps.call((code,), vm)?; + Some(PyMemoryView::from_object(&bytes, vm)?.into_ref(&vm.ctx)) + } else { + None + }; + // When origname is empty (e.g. __hello_only__), return None. // Otherwise return the resolved alias name. let origname_str = super::resolve_frozen_alias(name_str); @@ -348,7 +361,7 @@ mod _imp { } else { Some(vm.ctx.new_utf8_str(origname_str).into()) }; - Ok(Some((None, info.package, origname))) + Ok(Some((data, info.package, origname))) } #[pyfunction] diff --git a/crates/vm/src/stdlib/itertools.rs b/crates/vm/src/stdlib/itertools.rs index e633404e8..6eb268d94 100644 --- a/crates/vm/src/stdlib/itertools.rs +++ b/crates/vm/src/stdlib/itertools.rs @@ -4,11 +4,10 @@ pub(crate) use decl::module_def; mod decl { use crate::{ AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, PyWeakRef, VirtualMachine, - builtins::{PyGenericAlias, PyInt, PyIntRef, PyList, PyTuple, PyType, PyTypeRef, int}, - common::{ - lock::{PyMutex, PyRwLock, PyRwLockWriteGuard}, - rc::PyRc, + builtins::{ + PyGenericAlias, PyInt, PyIntRef, PyList, PyTuple, PyTupleRef, PyType, PyTypeRef, int, }, + common::lock::{PyMutex, PyRwLock, PyRwLockWriteGuard}, convert::ToPyObject, function::{FuncArgs, OptionalArg, OptionalOption, PosArgs}, protocol::{PyIter, PyIterReturn, PyNumber}, @@ -962,20 +961,25 @@ mod decl { } } - #[derive(Debug)] + #[pyattr] + #[pyclass(name = "_tee_dataobject", traverse)] + #[derive(Debug, PyPayload)] struct PyItertoolsTeeData { iterable: PyIter, values: PyMutex>, + #[pytraverse(skip)] running: AtomicBool, } + #[pyclass(flags(DISALLOW_INSTANTIATION))] impl PyItertoolsTeeData { - fn new(iterable: PyIter, _vm: &VirtualMachine) -> PyRc { - PyRc::new(Self { + fn new(iterable: PyIter, vm: &VirtualMachine) -> PyRef { + Self { iterable, values: PyMutex::new(vec![]), running: AtomicBool::new(false), - }) + } + .into_ref(&vm.ctx) } fn get_item(&self, vm: &VirtualMachine, index: usize) -> PyResult { @@ -988,13 +992,15 @@ mod decl { return Ok(PyIterReturn::Return(values[index].clone())); } } - // Prevent concurrent/reentrant calls to iterable.next() + // Prevent concurrent/reentrant calls to iterable.next(). The claim + // covers caching the value as well: released any earlier, a second + // tee at the same index fetches a value of its own and one of the + // two is dropped without ever reaching a caller. if self.running.swap(true, Ordering::Acquire) { return Err(vm.new_runtime_error("cannot re-enter the tee iterator")); } - let result = self.iterable.next(vm); - self.running.store(false, Ordering::Release); - let obj = raise_if_stop!(result?); + scopeguard::defer! { self.running.store(false, Ordering::Release) } + let obj = raise_if_stop!(self.iterable.next(vm)?); let Some(mut values) = self.values.try_lock() else { return Err(vm.new_runtime_error("cannot re-enter the tee iterator")); }; @@ -1006,59 +1012,44 @@ mod decl { } #[pyattr] - #[pyclass(name = "tee")] + #[pyclass(name = "_tee", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsTee { - tee_data: PyRc, + tee_data: PyRef, + #[pytraverse(skip)] index: AtomicCell, - } - - #[derive(FromArgs)] - struct TeeNewArgs { - #[pyarg(positional)] - iterable: PyIter, - #[pyarg(positional, optional)] - n: OptionalArg, + #[pytraverse(skip)] + advancing: AtomicBool, } impl Constructor for PyItertoolsTee { - type Args = TeeNewArgs; + type Args = PyIter; - // TODO: make tee() a function, rename this class to itertools._tee and make - // teedata a python class - fn slot_new(_cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { - let TeeNewArgs { iterable, n } = args.bind(vm)?; - let n = n.unwrap_or(2); - - let copyable = if iterable.class().has_attr(identifier!(vm, __copy__)) { - vm.call_special_method(iterable.as_object(), identifier!(vm, __copy__), ())? - } else { - Self::from_iter(iterable, vm)? - }; - - let mut tee_vec: Vec = Vec::with_capacity(n); - for _ in 0..n { - tee_vec.push(vm.call_special_method(©able, identifier!(vm, __copy__), ())?); + fn py_new(_cls: &Py, iterator: Self::Args, vm: &VirtualMachine) -> PyResult { + // An iterator that is already a tee shares its buffer rather than + // getting one of its own. + if let Some(tee) = iterator.as_object().downcast_ref::() { + return Ok(tee.__copy__()); } - - Ok(PyTuple::new_ref(tee_vec, &vm.ctx).into()) - } - - fn py_new(_cls: &Py, _args: Self::Args, _vm: &VirtualMachine) -> PyResult { - unimplemented!("use slot_new") + Ok(Self { + tee_data: PyItertoolsTeeData::new(iterator, vm), + index: AtomicCell::new(0), + advancing: AtomicBool::new(false), + }) } } - #[pyclass(with(IterNext, Iterable, Constructor))] + #[pyclass(with(IterNext, Iterable, Constructor), flags(HAS_WEAKREF))] impl PyItertoolsTee { fn from_iter(iterator: PyIter, vm: &VirtualMachine) -> PyResult { let class = Self::class(&vm.ctx); - if iterator.class().is(Self::class(&vm.ctx)) { + if iterator.class().is(class) { return vm.call_special_method(&iterator, identifier!(vm, __copy__), ()); } Ok(Self { tee_data: PyItertoolsTeeData::new(iterator, vm), index: AtomicCell::new(0), + advancing: AtomicBool::new(false), } .into_ref_with_type(vm, class.to_owned())? .into()) @@ -1069,14 +1060,49 @@ mod decl { Self { tee_data: self.tee_data.clone(), index: AtomicCell::new(self.index.load()), + advancing: AtomicBool::new(false), } } } + + #[pyfunction] + fn tee(iterable: PyIter, n: OptionalArg, vm: &VirtualMachine) -> PyResult { + let n = n.unwrap_or(2); + if n < 0 { + return Err(vm.new_value_error("n must be >= 0")); + } + let n = n as usize; + + // Only an iterator that cannot copy itself needs a tee to buffer it. + let copyable = if iterable.class().has_attr(identifier!(vm, __copy__)) { + iterable.into() + } else { + PyItertoolsTee::from_iter(iterable, vm)? + }; + + let mut tee_vec: Vec = Vec::new(); + tee_vec + .try_reserve_exact(n) + .map_err(|_| vm.new_memory_error(""))?; + for _ in 0..n { + tee_vec.push(vm.call_special_method(©able, identifier!(vm, __copy__), ())?); + } + + Ok(PyTuple::new_ref(tee_vec, &vm.ctx)) + } impl SelfIter for PyItertoolsTee {} impl IterNext for PyItertoolsTee { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - let value = raise_if_stop!(zelf.tee_data.get_item(vm, zelf.index.load())?); - zelf.index.fetch_add(1); + // Reading the index and moving it on is one step: two callers that + // read the same index hand out the same value twice and leave the + // buffer to be filled out of order. + if zelf.advancing.swap(true, Ordering::Acquire) { + return Err(vm.new_runtime_error("cannot re-enter the tee iterator")); + } + scopeguard::defer! { zelf.advancing.store(false, Ordering::Release) } + let index = zelf.index.load(); + let value = raise_if_stop!(zelf.tee_data.get_item(vm, index)?); + zelf.index.store(index + 1); Ok(PyIterReturn::Return(value)) } } diff --git a/extra_tests/snippets/stdlib_asyncio.py b/extra_tests/snippets/stdlib_asyncio.py index 7f03aeb43..d54f84564 100644 --- a/extra_tests/snippets/stdlib_asyncio.py +++ b/extra_tests/snippets/stdlib_asyncio.py @@ -49,4 +49,27 @@ future = _asyncio.Future(loop=object()) with assert_raises(TypeError): future.__await__().throw(BadException) +# InvalidStateError is looked up in asyncio.exceptions every time a pending +# future is asked for its result, so what is found there need not be an +# exception type at all. +import asyncio # noqa: E402 +import asyncio.exceptions # noqa: E402 + +pending = _asyncio.Future(loop=object()) +with assert_raises(asyncio.exceptions.InvalidStateError): + pending.result() + +saved_invalid_state_error = asyncio.exceptions.InvalidStateError +try: + for replacement in (lambda *args: 42, None): + asyncio.InvalidStateError = replacement + asyncio.exceptions.InvalidStateError = replacement + with assert_raises(RuntimeError): + pending.result() + with assert_raises(RuntimeError): + pending.exception() +finally: + asyncio.InvalidStateError = saved_invalid_state_error + asyncio.exceptions.InvalidStateError = saved_invalid_state_error + print("ok") diff --git a/extra_tests/snippets/stdlib_gc.py b/extra_tests/snippets/stdlib_gc.py index 134b1b9f4..6c3169bee 100644 --- a/extra_tests/snippets/stdlib_gc.py +++ b/extra_tests/snippets/stdlib_gc.py @@ -62,5 +62,7 @@ assert collects(lambda c: itertools.filterfalse(None, c)) assert collects(lambda c: itertools.compress(c, [1])) assert collects(lambda c: itertools.product(c)) assert collects(lambda c: itertools.combinations(c, 1)) +# tee holds its buffer through a second object, which has to be walked too +assert collects(lambda c: itertools.tee(c)[0]) print("ok") diff --git a/extra_tests/snippets/stdlib_imp.py b/extra_tests/snippets/stdlib_imp.py index 64f1a0ad6..9fd5f8a36 100644 --- a/extra_tests/snippets/stdlib_imp.py +++ b/extra_tests/snippets/stdlib_imp.py @@ -36,3 +36,9 @@ assert hello.initialized == True with assert_raises(TypeError): _imp.find_frozen("x", True) assert _imp.find_frozen("_this_module_does_not_exist_") is None + +# and it hands back the marshalled code that get_frozen_object() takes +data, ispkg, origname = _imp.find_frozen("__hello__", withdata=True) +assert ispkg is False +assert origname == "__hello__" +assert _imp.get_frozen_object("__hello__", data).co_name == "" diff --git a/extra_tests/snippets/stdlib_threading_contextvars.py b/extra_tests/snippets/stdlib_threading_contextvars.py new file mode 100644 index 000000000..7947ef74f --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_contextvars.py @@ -0,0 +1,72 @@ +"""Stress contextvars from several threads at once. + +A Context holds the variable map, and both the map and the per-variable cache +are shared between every thread that touches the Context. Reading and writing +them has to be done under a lock rather than a cell borrow. + +Dropping a value that a set() or reset() displaced can run a __del__ that comes +straight back into the same Context, so the displaced value has to be released +after the lock is, not while it is held. +""" + +import contextvars +import threading + +ROUNDS = 2000 + +var = contextvars.ContextVar("v", default=0) +shared = contextvars.Context() +errors = [] + + +class Reentrant: + """__del__ runs while the variable that held this value is being replaced.""" + + def __del__(self): + try: + var.get() + except Exception: # a different context, or no value: not what is tested + pass + + +def churn(): + try: + for i in range(ROUNDS): + token = var.set(Reentrant()) + var.get() + var.reset(token) + var.set(i) + var.get() + contextvars.copy_context() + except Exception as exc: # noqa: BLE001 + errors.append(exc) + + +def run_in_shared(): + for i in range(ROUNDS): + try: + shared.run(var.set, i) + except RuntimeError: + # the Context is already entered by another thread + pass + + +threads = [threading.Thread(target=churn) for _ in range(4)] +threads += [threading.Thread(target=run_in_shared) for _ in range(4)] +for t in threads: + t.start() +for t in threads: + t.join() + +assert not errors, errors + +# the map itself still behaves +ctx = contextvars.copy_context() +ctx.run(var.set, 42) +assert ctx[var] == 42 +assert var in ctx +assert list(ctx) == [var] +assert ctx.get(var) == 42 +assert len(ctx) == 1 + +print("ok") diff --git a/extra_tests/snippets/stdlib_threading_generator.py b/extra_tests/snippets/stdlib_threading_generator.py new file mode 100644 index 000000000..e606ef11c --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_generator.py @@ -0,0 +1,95 @@ +"""Resume one generator from several threads at once. + +A generator is resumed by one thread at a time, and whether the sent value is +pushed onto the frame's value stack depends on whether the generator has +already started. Deciding that before the generator is claimed reads a frame +another thread can advance in the meantime, and resuming it then leaves the +stack short of what the code after the yield expects. + +Every yielded value still has to reach exactly one caller: threads that lose +the race get a ValueError instead of a value. +""" + +import threading + +WORKERS = 4 +ROUNDS = 400 + + +def counter(): + yield 1 + yield 2 + yield 3 + + +gens = [counter() for _ in range(ROUNDS)] +received = [[] for _ in range(ROUNDS)] +start = threading.Barrier(WORKERS) +errors = [] + + +def worker(): + try: + for index, gen in enumerate(gens): + start.wait() + for _ in range(3): + try: + received[index].append(next(gen)) + except StopIteration: + break + except ValueError: + # another thread is running this generator + pass + except Exception as exc: # noqa: BLE001 + errors.append(exc) + # the other workers are waiting at the barrier for this one + start.abort() + + +threads = [threading.Thread(target=worker) for _ in range(WORKERS)] +for t in threads: + t.start() +for t in threads: + t.join() + +assert not errors, errors +for got in received: + # no value handed out twice, and none skipped + assert sorted(got) == list(range(1, len(got) + 1)), got + + +# a generator that is closed while it is being resumed stays consistent +def loop(): + while True: + yield 1 + + +shared = loop() +closed = threading.Barrier(2) + + +def resumer(): + closed.wait() + for _ in range(ROUNDS): + try: + next(shared) + except (StopIteration, ValueError): + pass + + +def closer(): + closed.wait() + try: + shared.close() + except ValueError: + # the generator was running + pass + + +pair = [threading.Thread(target=resumer), threading.Thread(target=closer)] +for t in pair: + t.start() +for t in pair: + t.join() + +print("ok") diff --git a/extra_tests/snippets/stdlib_threading_itertools_tee.py b/extra_tests/snippets/stdlib_threading_itertools_tee.py new file mode 100644 index 000000000..e13e20731 --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_itertools_tee.py @@ -0,0 +1,57 @@ +"""Advance the iterators of one tee() from several threads at once. + +Every tee iterator reads its position, asks the shared buffer for that item and +then moves the position on. Reading and moving it on has to be one step, and +the buffer has to stay claimed until the value it fetched from the source is +cached: otherwise two callers work on the same index, a fetched value is +dropped, and the buffer is left to be filled out of order. + +A caller that loses the race gets a RuntimeError, never a value another caller +has already been handed. +""" + +import itertools +import threading + +ROUNDS = 200 +WORKERS = 4 + +errors = [] + + +def drain(iterator, out): + for _ in range(ROUNDS): + try: + out.append(next(iterator)) + except StopIteration: + break + except RuntimeError: + # another thread is advancing this tee + pass + except Exception as exc: # noqa: BLE001 + errors.append(exc) + break + + +for _ in range(10): + first, second = itertools.tee(iter(range(ROUNDS * WORKERS))) + taken = [[] for _ in range(WORKERS)] + threads = [ + threading.Thread(target=drain, args=(first if i % 2 else second, taken[i])) + for i in range(WORKERS) + ] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, errors + for got in taken: + # one iterator hands out ascending values, each of them once + assert got == sorted(set(got)), got + for side in (taken[1], taken[3]), (taken[0], taken[2]): + # the two threads sharing an iterator split its values between them + shared = side[0] + side[1] + assert len(shared) == len(set(shared)), shared + +print("ok") From 234ad2db31aca9eca50cc9c89b59718eac9fef34 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:07:02 +0900 Subject: [PATCH 70/94] _sre: drive an all-ASCII str subject over its bytes (#8520) * sre_engine: document that StrDrive positions are character indices StrDrive carried no documentation, so nothing recorded that a cursor's position is a character index rather than a byte offset. Every implementation satisfies it -- skip(n) advances position by exactly n in all three -- and the engine depends on it: _count bounds a repeat with position + max_count and reports the repeat length as a difference of positions, ASSERT compares position against a lookbehind width, and search_info recovers a match start as position - (len - 1). A drive over a variable-width encoding that stored byte offsets would leave those type-correct and wrong, and would index a lookbehind out of bounds. Write the invariant down on StringCursor and on each trait method. Also walk the group's own cursor in GROUPREF instead of counting to the group's width, so the loop bound is the thing being stepped. Generated machine code is unchanged. Assisted-by: Claude * _sre: drive an all-ASCII str subject over its bytes with_sre_str handed every str subject to the &Wtf8 drive, which answers count() by counting every code point and create_cursor(n) by stepping over the first n. Both run once per Request, so a scan that restarts at successive positions walked the subject again on every call: finditer over an ASCII subject of n tokens was quadratic in n. PyStr already records whether it is ASCII -- StrKind is decided when the string is built -- and for ASCII a character index is a byte index, so the &[u8] drive's cursor arithmetic already applies: count() is the byte length and create_cursor() is a pointer offset. Add AsciiStr, which delegates every StrDrive method to that impl and differs only in slice(), which reslices the span and returns str rather than bytes. Matching is unaffected: StrDrive carries no unicode semantics, because the engine keys every unicode decision on the compiled pattern's opcode rather than on the subject type. Also bind the subject once in with_sre_str, so callers passing a temporary (`&x.clone()`) build it once rather than per arm. finditer over an ASCII subject, n tokens, this machine: n before after 5000 41.60ms 1.37ms 10000 217.30ms 2.68ms 20000 1206.28ms 5.17ms 40000 5016.65ms 10.31ms Per-doubling x5.22/x5.55/x4.16 becomes x1.95/x1.93/x1.99. Collecting m.group(0) for every match goes 5061.02ms -> 17.80ms at n=40000. test.test_re is unchanged at 166 tests, OK (skipped=14, expected failures=6), and a 2731-line differential over the is_ascii() boundary -- findall, finditer spans and groups, sub, split, match, search, fullmatch across subjects that are empty, ASCII, non-ASCII, or mixed -- is byte-identical to CPython 3.14.6. Introducing an off-by-one in AsciiStr::slice moves 1224 of those lines, so the comparison reaches the new code. Assisted-by: Claude --- crates/sre_engine/src/engine.rs | 6 +- crates/sre_engine/src/string.rs | 35 +++++++++++ crates/vm/src/stdlib/_sre.rs | 102 ++++++++++++++++++++++++++++++-- 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/crates/sre_engine/src/engine.rs b/crates/sre_engine/src/engine.rs index 690801e0d..c2a6ac819 100644 --- a/crates/sre_engine/src/engine.rs +++ b/crates/sre_engine/src/engine.rs @@ -581,7 +581,11 @@ fn _match(req: &Request<'_, S>, state: &mut State, mut ctx: MatchCo ..ctx }; - for _ in group_start..group_end { + // Walk the group itself rather than counting to its + // width: `g_ctx` is already stepping over exactly + // the characters being compared, so its own cursor + // is the loop bound. + while g_ctx.cursor.position < group_end { #[allow(clippy::redundant_closure_call)] if ctx.at_end(req) || $f(ctx.peek_char::()) != $f(g_ctx.peek_char::()) diff --git a/crates/sre_engine/src/string.rs b/crates/sre_engine/src/string.rs index ca7303a2a..5cc1b04b9 100644 --- a/crates/sre_engine/src/string.rs +++ b/crates/sre_engine/src/string.rs @@ -1,5 +1,12 @@ use rustpython_wtf8::Wtf8; +/// A position in the subject, paired with the byte pointer it resolves to. +/// +/// `position` is a **character index**, never a byte offset. The engine does +/// arithmetic on it directly — it subtracts two positions to get a character +/// count, adds a repeat count to get a bound, and compares one against a +/// lookbehind width — so the unit is part of the [`StrDrive`] contract rather +/// than a detail each implementation may pick. #[derive(Debug, Clone, Copy)] pub struct StringCursor { pub(crate) ptr: *const u8, @@ -15,15 +22,43 @@ impl Default for StringCursor { } } +/// Random access over the subject being matched. +/// +/// An implementation chooses how a character is spelled in memory — one byte +/// for `&[u8]`, one code point for `&str` and `&Wtf8` — but **not** how +/// positions are counted. Every position this trait produces or consumes is a +/// character index: `count` is the subject's length in characters, and +/// `skip(n)` advances a cursor's `position` by exactly `n`. +/// +/// That is load-bearing, not incidental. The engine reads position arithmetic +/// as character arithmetic in several places — `_count` bounds a repeat with +/// `position + max_count` and reports the repeat's length as a difference of +/// positions, `ASSERT` tests `position < back` against a lookbehind width, and +/// `search_info` recovers a match start as `position - (len - 1)`. A drive +/// that stored byte offsets here would leave all of those type-correct and +/// silently wrong, and would index a lookbehind out of bounds. +/// +/// So a drive over a variable-width encoding pays for the mapping: `count` +/// and `create_cursor` have to resolve character indices, and cannot simply +/// hand back byte lengths and byte offsets. pub trait StrDrive: Copy { + /// The subject's length, in characters. fn count(&self) -> usize; + /// A cursor at character index `n`. fn create_cursor(&self, n: usize) -> StringCursor; + /// Move `cursor` to character index `n`, from wherever it is now. fn adjust_cursor(&self, cursor: &mut StringCursor, n: usize); + /// Consume one character, returning it; `position` grows by one. fn advance(cursor: &mut StringCursor) -> u32; + /// The character at `cursor`, without moving it. fn peek(cursor: &StringCursor) -> u32; + /// Skip `n` characters, so `position` grows by exactly `n`. fn skip(cursor: &mut StringCursor, n: usize); + /// Step back over one character, returning it; `position` shrinks by one. fn back_advance(cursor: &mut StringCursor) -> u32; + /// The character before `cursor`, without moving it. fn back_peek(cursor: &StringCursor) -> u32; + /// Step back `n` characters, so `position` shrinks by exactly `n`. fn back_skip(cursor: &mut StringCursor, n: usize); } diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index 18b4ffde8..6fe6b4347 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -22,7 +22,7 @@ mod _sre { use itertools::Itertools; use num_traits::ToPrimitive; use rustpython_sre_engine::{ - Request, SearchIter, SreFlag, State, StrDrive, + Request, SearchIter, SreFlag, State, StrDrive, StringCursor, string::{lower_ascii, lower_unicode}, }; @@ -83,6 +83,72 @@ mod _sre { } } + /// An all-ASCII `str` subject, driven over its bytes. + /// + /// For ASCII a character index *is* a byte index, so `&[u8]`'s cursor + /// arithmetic is already the right arithmetic: `count` is the byte length + /// and `create_cursor` is a pointer offset. The `&Wtf8` drive has to count + /// code points from the start of the subject to answer either, once per + /// `Request`, which makes a scan that restarts at successive positions -- + /// `finditer`, or `re` module functions called in a loop -- walk the + /// subject again on every call. + /// + /// Matching is unaffected: `StrDrive` carries no unicode semantics of its + /// own, because the engine keys every unicode decision on the compiled + /// pattern's opcode rather than on the subject type. Only `slice` differs + /// from the `&[u8]` impl, to hand back `str` instead of `bytes`. + #[derive(Clone, Copy)] + struct AsciiStr<'a>(&'a [u8]); + + impl StrDrive for AsciiStr<'_> { + fn count(&self) -> usize { + <&[u8] as StrDrive>::count(&self.0) + } + + fn create_cursor(&self, n: usize) -> StringCursor { + <&[u8] as StrDrive>::create_cursor(&self.0, n) + } + + fn adjust_cursor(&self, cursor: &mut StringCursor, n: usize) { + <&[u8] as StrDrive>::adjust_cursor(&self.0, cursor, n) + } + + fn advance(cursor: &mut StringCursor) -> u32 { + <&[u8] as StrDrive>::advance(cursor) + } + + fn peek(cursor: &StringCursor) -> u32 { + <&[u8] as StrDrive>::peek(cursor) + } + + fn skip(cursor: &mut StringCursor, n: usize) { + <&[u8] as StrDrive>::skip(cursor, n) + } + + fn back_advance(cursor: &mut StringCursor) -> u32 { + <&[u8] as StrDrive>::back_advance(cursor) + } + + fn back_peek(cursor: &StringCursor) -> u32 { + <&[u8] as StrDrive>::back_peek(cursor) + } + + fn back_skip(cursor: &mut StringCursor, n: usize) { + <&[u8] as StrDrive>::back_skip(cursor, n) + } + } + + impl SreStr for AsciiStr<'_> { + fn slice(&self, start: usize, end: usize, vm: &VirtualMachine) -> PyObjectRef { + let end = end.min(self.0.len()); + let start = start.min(end); + // The subject is ASCII, so any span of it is valid UTF-8 and the + // span is a reslice rather than a walk from the subject's start. + let s = str::from_utf8(&self.0[start..end]).expect("ascii subject"); + vm.ctx.new_str(s).into() + } + } + #[pyfunction] fn compile( pattern: PyObjectRef, @@ -200,13 +266,18 @@ mod _sre { } macro_rules! with_sre_str { - ($pattern:expr, $string:expr, $vm:expr, $f:expr) => { + ($pattern:expr, $string:expr, $vm:expr, $f:expr) => {{ + // Bind once: the branches only borrow the subject, and callers pass + // a temporary (`&x.clone()`) that would otherwise be rebuilt per arm. + let subject = $string; if $pattern.isbytes { - Pattern::with_bytes($string, $vm, $f) + Pattern::with_bytes(subject, $vm, $f) + } else if Pattern::is_ascii_str(subject) { + Pattern::with_ascii_str(subject, $vm, $f) } else { - Pattern::with_str($string, $vm, $f) + Pattern::with_str(subject, $vm, $f) } - }; + }}; } #[pyclass(with(Hashable, Comparable, Representable), flags(HAS_WEAKREF))] @@ -221,6 +292,27 @@ mod _sre { f(string.as_wtf8()) } + /// Whether a `str` subject can take the [`AsciiStr`] drive. + /// + /// `PyStr` already knows: `StrKind` is decided when the string is + /// built, so this is a field load rather than a scan. A non-`str` + /// argument answers `false` and is reported by [`Self::with_str`]. + fn is_ascii_str(string: &PyObject) -> bool { + string + .downcast_ref::() + .is_some_and(|s| s.kind().is_ascii()) + } + + fn with_ascii_str(string: &PyObject, vm: &VirtualMachine, f: F) -> PyResult + where + F: FnOnce(AsciiStr<'_>) -> PyResult, + { + let string = string.downcast_ref::().ok_or_else(|| { + vm.new_type_error(format!("expected string got '{}'", string.class())) + })?; + f(AsciiStr(string.as_wtf8().as_bytes())) + } + fn with_bytes(string: &PyObject, vm: &VirtualMachine, f: F) -> PyResult where F: FnOnce(&[u8]) -> PyResult, From ef51c5c164c5ccfe7238e93b854af42b64616a1a Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:32:36 +0900 Subject: [PATCH 71/94] str: count a stepped slice's characters with a ceiling division (#8525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `do_stepped_slice` and `do_stepped_slice_reverse` took the character count of a non-ASCII result as `(range.len() / step) + 1`, which overshoots by one whenever the span is an exact multiple of the step: `"aéc"[::3]` is one character and reported two, `"가나다라"[::2]` two and reported three. ASCII subjects were unaffected, since that arm collects into an `AsciiString` whose length comes from the data. The count is stored as the string's character length, so the result then claimed a character its buffer does not hold, and `reversed()` on it indexed past the end and panicked: s = "".join(["a", "é", "c"]) list(reversed(s[::3])) # index out of bounds: the len is 1 but the index is 1 Use `div_ceil`, which is the number of elements the underlying range yields. A differential over 44331 subscript and slice shapes -- every combination of start, stop and step over strings straddling the ASCII split, the surrogate range and the astral plane -- now matches CPython 3.14 exactly, where it differed on 3100 lines before. Assisted-by: Claude --- crates/vm/src/builtins/str.rs | 8 ++--- .../snippets/builtin_str_unicode_slice.py | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index 07325159a..ae8ba0ed0 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -1870,14 +1870,14 @@ impl SliceableSequenceOp for PyStr { .collect::() .into(), PyKindStr::Utf8(s) => { - let char_len = (range.len() / step) + 1; + let char_len = range.len().div_ceil(step); let mut out = String::with_capacity(2 * char_len); out.extend(s.chars().skip(range.start).take(range.len()).step_by(step)); // SAFETY: char_len is accurate unsafe { Self::new_with_char_len(out, char_len) } } PyKindStr::Wtf8(w) => { - let char_len = (range.len() / step) + 1; + let char_len = range.len().div_ceil(step); let mut out = Wtf8Buf::with_capacity(2 * char_len); out.extend( w.code_points() @@ -1900,7 +1900,7 @@ impl SliceableSequenceOp for PyStr { .collect::() .into(), PyKindStr::Utf8(s) => { - let char_len = (range.len() / step) + 1; + let char_len = range.len().div_ceil(step); // not ascii, so the codepoints have to be at least 2 bytes each let mut out = String::with_capacity(2 * char_len); out.extend( @@ -1914,7 +1914,7 @@ impl SliceableSequenceOp for PyStr { unsafe { Self::new_with_char_len(out, char_len) } } PyKindStr::Wtf8(w) => { - let char_len = (range.len() / step) + 1; + let char_len = range.len().div_ceil(step); // not ascii, so the codepoints have to be at least 2 bytes each let mut out = Wtf8Buf::with_capacity(2 * char_len); out.extend( diff --git a/extra_tests/snippets/builtin_str_unicode_slice.py b/extra_tests/snippets/builtin_str_unicode_slice.py index 252f84b1c..1d35c6c48 100644 --- a/extra_tests/snippets/builtin_str_unicode_slice.py +++ b/extra_tests/snippets/builtin_str_unicode_slice.py @@ -59,3 +59,32 @@ assert hebrew_text[30:10:-3] == "אםהֱאּ " assert len(hebrew_text[30:10:-3]) == 7 assert hebrew_text[30:10:-1] == "א ,םיִהֹלֱא אָרָּב ," assert len(hebrew_text[30:10:-1]) == 20 + + +# A stepped slice whose span is an exact multiple of the step ends on the last +# character it collects rather than one past it, so the character count is the +# span divided by the step and not one more. The subject goes through a +# variable because a constant subscript is folded at compile time and would +# never reach the runtime slice at all. +def stepped(s, step): + return s[::step] + + +for subject, step, expected in [ + ("a\u00e9c", 3, "a"), + ("가나다라", 2, "가다"), + ("가나다라마바", 3, "가라"), + ("가나다라", -2, "라나"), + ("가나다라마바", -3, "바다"), + ("\U0001f600\U0001f601\U0001f602\U0001f603", 2, "\U0001f600\U0001f602"), +]: + sliced = stepped(subject, step) + assert sliced == expected, (subject, step, sliced) + assert len(sliced) == len(expected), (subject, step, len(sliced)) + # An overstated count makes the string claim characters its buffer does not + # hold, which reversed() then reads past. + assert list(reversed(sliced)) == list(expected)[::-1] + +assert len(stepped(hebrew_text, 2)) == 30 +assert len(stepped(hebrew_text, 4)) == 15 +assert len(stepped(hebrew_text, -2)) == 30 From 4a8c613b047dc4568de02188a17f7cc8427cd212 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:33:58 +0900 Subject: [PATCH 72/94] _sre: drive a non-ASCII str subject through a character index on the string (#8522) * common: index a WTF-8 buffer's code points for random access `Wtf8`'s iterators are sequential, so resolving a code point index through them is O(n) and code that indexes the same string repeatedly walks it once per index. `Wtf8Index` is a side table -- one 24-byte group per 64 code points, 0.375 bytes per code point -- that answers the same question in constant time; the layout is PyPy's `UTF8_INDEX_STORAGE`. `StrData` builds one on the first call to the new `char_index_to_byte`, in a slot published by compare-exchange, and drops it with the string. ASCII strings answer from the index itself and never build a table. A clone gets an empty slot, since it indexes its own copy of the buffer. Assisted-by: Claude * _sre: drive a non-ASCII str subject through the string's character index The `&Wtf8` drive answers `count` and `create_cursor` by decoding from the start of the subject, so a scan that restarts at successive positions walks the subject once per position, and `slice` walks it again per extracted group. `Utf8Str` holds the `PyStr` and asks it instead: `count` is the cached character length, and `create_cursor` and `slice` resolve their positions through `char_index_to_byte`. Stepping is the `&Wtf8` drive's, unchanged. The table lives on the string, so a `Match` that outlives the scan shares it -- `group` has no cursor of its own to move relative to. `SreStr for &Wtf8` has no callers left; the `StrDrive` impl stays, since `Utf8Str` steps through it. The three subject helpers now share one downcast. Assisted-by: Claude --- crates/common/src/lib.rs | 1 + crates/common/src/str.rs | 87 +++++++++++- crates/common/src/wtf8_index.rs | 229 ++++++++++++++++++++++++++++++++ crates/vm/src/builtins/str.rs | 7 + crates/vm/src/stdlib/_sre.rs | 101 +++++++++++--- 5 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 crates/common/src/wtf8_index.rs diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 53a8e0d75..d1e04b46d 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -21,6 +21,7 @@ pub mod rc; pub mod refcount; pub mod static_cell; pub mod str; +pub mod wtf8_index; pub use rustpython_wtf8 as wtf8; diff --git a/crates/common/src/str.rs b/crates/common/src/str.rs index c006a5f4d..8edb48fa4 100644 --- a/crates/common/src/str.rs +++ b/crates/common/src/str.rs @@ -1,7 +1,8 @@ // spell-checker:ignore uncomputed -use crate::atomic::{PyAtomic, Radium}; +use crate::atomic::{OncePtr, PyAtomic, Radium}; use crate::format::CharLen; use crate::wtf8::{CodePoint, Wtf8, Wtf8Buf}; +use crate::wtf8_index::Wtf8Index; use ascii::{AsciiChar, AsciiStr, AsciiString}; use core::fmt; use core::ops::{Bound, RangeBounds}; @@ -117,6 +118,55 @@ pub struct StrData { data: Box, kind: StrKind, len: StrLen, + index: Wtf8IndexSlot, +} + +/// A [`Wtf8Index`] built on first use. +/// +/// The table is a pure function of `data`, so publishing it races benignly: a +/// thread that loses the exchange drops its own copy and reads the winner's. +#[derive(Default)] +struct Wtf8IndexSlot(OncePtr); + +impl Wtf8IndexSlot { + #[inline(always)] + fn new() -> Self { + Self(OncePtr::new()) + } + + #[inline] + fn get_or_build(&self, data: &Wtf8, char_len: usize) -> &Wtf8Index { + let index = self + .0 + .get_or_init(|| Box::new(Wtf8Index::new(data, char_len))); + // The slot owns the table, never replaces it, and outlives the borrow. + unsafe { index.as_ref() } + } +} + +impl fmt::Debug for Wtf8IndexSlot { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self.0.get() { + Some(_) => f.write_str(""), + None => f.write_str(""), + } + } +} + +impl Clone for Wtf8IndexSlot { + /// A fresh slot: the clone copies the buffer, so it has to index that copy, + /// and the table is rebuilt on demand rather than eagerly here. + fn clone(&self) -> Self { + Self::new() + } +} + +impl Drop for Wtf8IndexSlot { + fn drop(&mut self) { + if let Some(index) = self.0.get() { + drop(unsafe { Box::from_raw(index.as_ptr()) }); + } + } } struct StrLen(PyAtomic); @@ -163,6 +213,7 @@ impl Default for StrData { data: >::default(), kind: StrKind::Ascii, len: StrLen::zero(), + index: Wtf8IndexSlot::new(), } } } @@ -193,6 +244,7 @@ impl From> for StrData { len: value.len().into(), data: value.into(), kind: StrKind::Ascii, + index: Wtf8IndexSlot::new(), } } } @@ -212,6 +264,7 @@ impl From for StrData { data: ch.to_string().into(), kind: StrKind::Utf8, len: 1.into(), + index: Wtf8IndexSlot::new(), } } } @@ -226,6 +279,7 @@ impl From for StrData { data: Wtf8Buf::from(ch).into(), kind: StrKind::Wtf8, len: 1.into(), + index: Wtf8IndexSlot::new(), } } } @@ -241,7 +295,12 @@ impl StrData { StrKind::Ascii => data.len().into(), _ => StrLen::uncomputed(), }; - Self { data, kind, len } + Self { + data, + kind, + len, + index: Wtf8IndexSlot::new(), + } } /// # Safety @@ -253,6 +312,7 @@ impl StrData { data, kind, len: char_len.into(), + index: Wtf8IndexSlot::new(), } } @@ -322,6 +382,29 @@ impl StrData { len } + /// The byte offset the `index`-th code point starts at. + /// + /// An `index` at or past the end answers the buffer's byte length, so a + /// caller walking to a bound does not have to special-case it. + /// + /// O(1), but the first call on a non-ASCII string builds an index over the + /// whole buffer, so a caller that resolves a single index and stops is + /// better served by [`Self::nth_char`]. + pub fn char_index_to_byte(&self, index: usize) -> usize { + // For ASCII the two units coincide, and the table would be a Nth entry + // saying N. + if self.kind.is_ascii() { + return index.min(self.data.len()); + } + let char_len = self.char_len(); + if index >= char_len { + return self.data.len(); + } + self.index + .get_or_build(&self.data, char_len) + .byte_offset(&self.data, index) + } + pub fn nth_char(&self, index: usize) -> CodePoint { match self.as_str_kind() { PyKindStr::Ascii(s) => s[index].into(), diff --git a/crates/common/src/wtf8_index.rs b/crates/common/src/wtf8_index.rs new file mode 100644 index 000000000..a6121f1c0 --- /dev/null +++ b/crates/common/src/wtf8_index.rs @@ -0,0 +1,229 @@ +// spell-checker:ignore rpython rlib rutf +//! Random access into a WTF-8 buffer. +//! +//! WTF-8 is variable width, so a buffer's n-th code point can only be found by +//! decoding the n-1 before it: [`Wtf8`]'s iterators are sequential, and +//! resolving an index through them is O(n). Code that indexes the same string +//! repeatedly -- a regex scan restarting at successive positions, say -- then +//! walks the whole buffer once per index, which is quadratic in its length. +//! +//! [`Wtf8Index`] is the side table that makes the lookup O(1): one 24-byte +//! group per 64 code points, so 0.375 bytes per code point. It is a cache, and +//! holds no state of its own beyond the buffer's shape -- building it twice for +//! the same buffer yields the same table. +//! +//! The layout is PyPy's `UTF8_INDEX_STORAGE` (`rpython/rlib/rutf8.py`). + +use crate::wtf8::Wtf8; + +/// One group of 64 code points. +#[derive(Clone, Copy)] +struct Group { + /// The byte offset the group's first code point starts at. + base: usize, + /// `ofs[i]` is the byte offset of the group's `4 * i + 1`-th code point, + /// relative to `base`. One entry covers four code points, so the widest + /// offset an entry has to hold is that of the 61st code point of a group, + /// at most `61 * 4 = 244` bytes in -- inside a `u8`, which is what buys the + /// table its density. + ofs: [u8; 16], +} + +/// A code-point-index to byte-offset table for one WTF-8 buffer. +pub struct Wtf8Index { + groups: Box<[Group]>, +} + +impl Wtf8Index { + /// Builds the table for `data`, whose code point count is `char_len`. + /// + /// O(`data.len()`), and touches every byte, so it pays for itself only when + /// the caller goes on to index the buffer more than a couple of times. + #[must_use] + pub fn new(data: &Wtf8, char_len: usize) -> Self { + let mut groups = vec![ + Group { + base: 0, + ofs: [0; 16], + }; + char_len / 64 + 1 + ]; + // Signed: the countdown overshoots the last group -- the loop stops on + // the first negative value rather than at a group boundary. + let mut remaining = char_len as isize; + let mut base = 0; + let mut current = 0; + loop { + groups[current].base = base; + let mut next = base; + let mut group_filled = true; + for i in 0..16 { + // Past the end, step as if one more single-byte code point + // followed, so the entry stays in range and is never read. + next = if remaining == 0 { + next + 1 + } else { + next_pos(data, next) + }; + groups[current].ofs[i] = (next - base) as u8; + remaining -= 4; + if remaining < 0 { + debug_assert_eq!(current + 1, groups.len()); + group_filled = false; + break; + } + next = next_pos(data, next_pos(data, next_pos(data, next))); + } + if !group_filled { + break; + } + current += 1; + base = next; + } + Self { + groups: groups.into_boxed_slice(), + } + } + + /// The byte offset of `data`'s `index`-th code point. + /// + /// `data` must be the buffer the table was built for, and `index` must be + /// below its code point count. + #[inline] + #[must_use] + pub fn byte_offset(&self, data: &Wtf8, index: usize) -> usize { + let group = &self.groups[index >> 6]; + // The entry sits on the 4k+1-th code point of the group, so a lookup is + // one table read plus at most two steps in either direction. + let pos = group.base + group.ofs[(index >> 2) & 0x0F] as usize; + match index & 0x3 { + 0 => prev_pos(data, pos), + 1 => pos, + 2 => next_pos(data, pos), + _ => next_pos(data, next_pos(data, pos)), + } + } + + /// The table's heap footprint, in bytes. + #[must_use] + pub fn byte_size(&self) -> usize { + core::mem::size_of_val(&*self.groups) + } +} + +/// The byte offset of the code point after the one at `pos`. +/// +/// `data` must be well-formed WTF-8 and `pos` a code point boundary before its +/// end -- reading only the lead byte is what makes this branch-light. +#[inline] +fn next_pos(data: &Wtf8, pos: usize) -> usize { + match data.as_bytes()[pos] { + 0x00..=0x7F => pos + 1, + 0x80..=0xDF => pos + 2, + 0xE0..=0xEF => pos + 3, + _ => pos + 4, + } +} + +/// The byte offset of the code point before the one at `pos`, which must not be +/// zero. +/// +/// A `pos` one past the end reads as the extra code point [`Wtf8Index::new`] +/// steps over there. +#[inline] +fn prev_pos(data: &Wtf8, pos: usize) -> usize { + let data = data.as_bytes(); + let mut pos = pos - 1; + if pos >= data.len() || data[pos] <= 0x7F { + return pos; + } + pos -= 1; + if data[pos] >= 0xC0 { + return pos; + } + pos -= 1; + if data[pos] >= 0xC0 { + return pos; + } + pos - 1 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wtf8::{CodePoint, Wtf8Buf}; + + /// Every index of `s`, against the offsets its own iterator reports. + fn check(s: &Wtf8) { + let expected: Vec = s + .code_point_indices() + .map(|(byte_offset, _)| byte_offset) + .collect(); + let index = Wtf8Index::new(s, expected.len()); + for (i, &want) in expected.iter().enumerate() { + assert_eq!( + index.byte_offset(s, i), + want, + "index {i} of {s:?} ({} code points)", + expected.len() + ); + } + } + + fn wtf8(s: &str) -> Wtf8Buf { + Wtf8Buf::from(s) + } + + #[test] + fn empty() { + check(wtf8("").as_ref()); + } + + #[test] + fn widths() { + // One case per encoded width, and the boundaries between them. + check(wtf8("abc").as_ref()); + check(wtf8("\u{80}\u{7ff}").as_ref()); + check(wtf8("\u{800}\u{ffff}").as_ref()); + check(wtf8("\u{10000}\u{10ffff}").as_ref()); + check(wtf8("a\u{80}\u{800}\u{10000}").as_ref()); + } + + #[test] + fn group_boundaries() { + // A group covers 64 code points and an entry four, so the interesting + // lengths are the ones on and around both. + for len in [1, 3, 4, 5, 63, 64, 65, 127, 128, 129, 255, 256, 257] { + for unit in ["a", "\u{80}", "\u{800}", "\u{10000}"] { + check(wtf8(&unit.repeat(len)).as_ref()); + } + // Mixed widths, so a group's entries do not share a stride. + check(wtf8(&"a\u{80}\u{800}\u{10000}".repeat(len)).as_ref()); + } + } + + #[test] + fn lone_surrogates() { + let mut s = wtf8("a"); + for cp in [0xD800, 0xDBFF, 0xDC00, 0xDFFF] { + s.push(CodePoint::from_u32(cp).unwrap()); + s.push_str("b"); + } + check(s.as_ref()); + + // Surrogates only, spanning more than one group. + let mut s = wtf8(""); + for i in 0..200 { + s.push(CodePoint::from_u32(0xD800 + (i % 0x400)).unwrap()); + } + check(s.as_ref()); + } + + #[test] + fn byte_size_is_one_group_per_64_code_points() { + let s = wtf8(&"\u{10000}".repeat(200)); + let index = Wtf8Index::new(s.as_ref(), 200); + assert_eq!(index.byte_size(), (200 / 64 + 1) * size_of::()); + assert_eq!(size_of::(), 24); + } +} diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index ae8ba0ed0..c25940cd3 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -712,6 +712,13 @@ impl PyStr { self.data.char_len() } + /// The byte offset the `index`-th character starts at, or the string's byte + /// length if `index` is at or past its end. + #[inline] + pub fn char_index_to_byte(&self, index: usize) -> usize { + self.data.char_index_to_byte(index) + } + #[pymethod] #[inline(always)] pub const fn isascii(&self) -> bool { diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index 6fe6b4347..168363103 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -70,15 +70,76 @@ mod _sre { } } - impl SreStr for &Wtf8 { + /// A `str` subject with non-ASCII characters, driven through the string's + /// own character-index table. + /// + /// The `&Wtf8` drive answers `count` and `create_cursor` by decoding from + /// the start of the subject, so both are O(n) and a scan that restarts at + /// successive positions walks the subject once per position. `PyStr` + /// already caches its character length and can resolve a character index to + /// a byte offset in constant time, so this drive asks the string instead of + /// re-deriving: the table it builds on the first lookup is shared by every + /// later one, including by `Match` objects that outlive the scan and have + /// no cursor of their own to move relative to. + /// + /// Stepping is the `&Wtf8` drive's, unchanged -- the subject is the same + /// buffer, decoded the same way. Only the two operations that resolve a + /// position from scratch differ. + #[derive(Clone, Copy)] + struct Utf8Str<'a>(&'a Py); + + impl StrDrive for Utf8Str<'_> { + fn count(&self) -> usize { + self.0.char_len() + } + + fn create_cursor(&self, n: usize) -> StringCursor { + // `StringCursor`'s pointer is private to the engine, so the cursor + // is taken from the `&Wtf8` drive at the start of the suffix that + // begins at `n` -- an O(1) reslice -- rather than built here. + let suffix = &self.0.as_wtf8()[self.0.char_index_to_byte(n)..]; + let mut cursor = <&Wtf8 as StrDrive>::create_cursor(&suffix, 0); + cursor.position = n; + cursor + } + + fn adjust_cursor(&self, cursor: &mut StringCursor, n: usize) { + // Rebuilding is O(1), so it is never the slower branch and the + // `&Wtf8` drive's walk-or-restart choice does not apply. + *cursor = self.create_cursor(n); + } + + fn advance(cursor: &mut StringCursor) -> u32 { + <&Wtf8 as StrDrive>::advance(cursor) + } + + fn peek(cursor: &StringCursor) -> u32 { + <&Wtf8 as StrDrive>::peek(cursor) + } + + fn skip(cursor: &mut StringCursor, n: usize) { + <&Wtf8 as StrDrive>::skip(cursor, n) + } + + fn back_advance(cursor: &mut StringCursor) -> u32 { + <&Wtf8 as StrDrive>::back_advance(cursor) + } + + fn back_peek(cursor: &StringCursor) -> u32 { + <&Wtf8 as StrDrive>::back_peek(cursor) + } + + fn back_skip(cursor: &mut StringCursor, n: usize) { + <&Wtf8 as StrDrive>::back_skip(cursor, n) + } + } + + impl SreStr for Utf8Str<'_> { fn slice(&self, start: usize, end: usize, vm: &VirtualMachine) -> PyObjectRef { + let end = self.0.char_index_to_byte(end); + let start = self.0.char_index_to_byte(start).min(end); vm.ctx - .new_str( - self.code_points() - .take(end) - .skip(start) - .collect::(), - ) + .new_str(self.0.as_wtf8()[start..end].to_owned()) .into() } } @@ -275,28 +336,31 @@ mod _sre { } else if Pattern::is_ascii_str(subject) { Pattern::with_ascii_str(subject, $vm, $f) } else { - Pattern::with_str(subject, $vm, $f) + Pattern::with_utf8_str(subject, $vm, $f) } }}; } #[pyclass(with(Hashable, Comparable, Representable), flags(HAS_WEAKREF))] impl Pattern { + fn downcast_str<'a>(string: &'a PyObject, vm: &VirtualMachine) -> PyResult<&'a Py> { + string.downcast_ref::().ok_or_else(|| { + vm.new_type_error(format!("expected string got '{}'", string.class())) + }) + } + fn with_str(string: &PyObject, vm: &VirtualMachine, f: F) -> PyResult where F: FnOnce(&Wtf8) -> PyResult, { - let string = string.downcast_ref::().ok_or_else(|| { - vm.new_type_error(format!("expected string got '{}'", string.class())) - })?; - f(string.as_wtf8()) + f(Self::downcast_str(string, vm)?.as_wtf8()) } /// Whether a `str` subject can take the [`AsciiStr`] drive. /// /// `PyStr` already knows: `StrKind` is decided when the string is /// built, so this is a field load rather than a scan. A non-`str` - /// argument answers `false` and is reported by [`Self::with_str`]. + /// argument answers `false` and is reported by [`Self::with_utf8_str`]. fn is_ascii_str(string: &PyObject) -> bool { string .downcast_ref::() @@ -307,12 +371,17 @@ mod _sre { where F: FnOnce(AsciiStr<'_>) -> PyResult, { - let string = string.downcast_ref::().ok_or_else(|| { - vm.new_type_error(format!("expected string got '{}'", string.class())) - })?; + let string = Self::downcast_str(string, vm)?; f(AsciiStr(string.as_wtf8().as_bytes())) } + fn with_utf8_str(string: &PyObject, vm: &VirtualMachine, f: F) -> PyResult + where + F: FnOnce(Utf8Str<'_>) -> PyResult, + { + f(Utf8Str(Self::downcast_str(string, vm)?)) + } + fn with_bytes(string: &PyObject, vm: &VirtualMachine, f: F) -> PyResult where F: FnOnce(&[u8]) -> PyResult, From 9616b28d3d1d3eb463ec5a553af845c814d8733b Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:03:09 +0900 Subject: [PATCH 73/94] str: resolve subscripts and slices through the code point index (#8526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nth_char` and the four `SliceableSequenceOp` methods each walked the buffer to reach a character index, so `s[i]` and `s[a:b]` on a non-ASCII string were O(i) and O(b), and a loop over either was quadratic. They now resolve through `char_index_to_byte`, which is what the index table was added for: a plain slice becomes a byte reslice, and a stepped slice one lookup per collected character. An index within four code points of an end is still walked to, and so is a slice that reaches within four of both. PyPy draws the same line with `MAX_UNROLL_NEXT_CODEPOINT_POS`, in a guard that also asks the JIT whether the index is a constant; there is no JIT here, but the reason to skip the build survives it -- `s[0]` on a long string should not pay for a table. The stepped slices took their character count from `(range.len() / step) + 1`, which overshoots whenever the last step lands short: `"aéc"[::3]` reported a length of 2 for a one-character string, and `reversed()` on it read past the end of the buffer and panicked. The count is now the index iterator's own length, so it cannot drift from the characters actually collected. Assisted-by: Claude --- crates/common/src/str.rs | 77 ++++++++++++++++- crates/vm/src/builtins/str.rs | 150 ++++++++++++---------------------- 2 files changed, 128 insertions(+), 99 deletions(-) diff --git a/crates/common/src/str.rs b/crates/common/src/str.rs index 8edb48fa4..72aecb793 100644 --- a/crates/common/src/str.rs +++ b/crates/common/src/str.rs @@ -113,6 +113,16 @@ pub enum PyKindStr<'a> { Wtf8(&'a Wtf8), } +/// How far from an end an index is resolved by walking rather than by building +/// the code point index. +/// +/// PyPy spells this `MAX_UNROLL_NEXT_CODEPOINT_POS`, in a guard that also asks +/// the JIT whether the index is a constant, so that the walk unrolls. There is +/// no JIT here to ask, and the walk is short rather than free -- but four steps +/// still beat a pass over the whole buffer, and skipping the build is what +/// keeps `s[0]` and `s[1:-1]` on a long string from paying for a table. +const MAX_WALK_TO_INDEX: usize = 4; + #[derive(Debug, Clone)] pub struct StrData { data: Box, @@ -405,11 +415,74 @@ impl StrData { .byte_offset(&self.data, index) } + /// The byte offset of code point `index`, for a caller that resolves one + /// index and stops. + /// + /// Building the table costs a pass over the whole buffer, so it is worth it + /// only for a caller that comes back; an index within + /// [`MAX_WALK_TO_INDEX`] steps of either end is cheaper to walk to, and + /// walking keeps `s[0]` on a long string from paying for a table it will + /// never use again. Anything further in builds, on the reasoning that a + /// string indexed once in the middle tends to be indexed again. + fn char_index_to_byte_once(&self, index: usize) -> usize { + if index <= MAX_WALK_TO_INDEX { + return self + .data + .code_point_indices() + .nth(index) + .map_or(self.data.len(), |(byte, _)| byte); + } + let from_end = self.char_len() - index; + if from_end <= MAX_WALK_TO_INDEX { + return self + .data + .code_point_indices() + .nth_back(from_end - 1) + .map_or(self.data.len(), |(byte, _)| byte); + } + self.char_index_to_byte(index) + } + + /// The byte range spanned by the code points in `range`. + /// + /// A range that reaches within [`MAX_WALK_TO_INDEX`] of *both* ends is + /// walked to for the same reason a single index near one end is -- a slice + /// like `s[1:-1]` should not build a table over the whole string. + #[must_use] + pub fn char_range_to_bytes(&self, range: core::ops::Range) -> core::ops::Range { + if self.kind.is_ascii() { + return range; + } + let from_end = self.char_len() - range.end; + if range.start <= MAX_WALK_TO_INDEX && from_end <= MAX_WALK_TO_INDEX { + // Two walks over disjoint ends, each of at most MAX_WALK_TO_INDEX + // steps -- one iterator driven from both sides would have them meet + // on a short string. + let start = self + .data + .code_point_indices() + .nth(range.start) + .map_or(self.data.len(), |(byte, _)| byte); + let end = match from_end { + 0 => self.data.len(), + n => self + .data + .code_point_indices() + .nth_back(n - 1) + .map_or(self.data.len(), |(byte, _)| byte), + }; + return start..end; + } + self.char_index_to_byte(range.start)..self.char_index_to_byte(range.end) + } + pub fn nth_char(&self, index: usize) -> CodePoint { match self.as_str_kind() { PyKindStr::Ascii(s) => s[index].into(), - PyKindStr::Utf8(s) => s.chars().nth(index).unwrap().into(), - PyKindStr::Wtf8(w) => w.code_points().nth(index).unwrap(), + _ => self.data[self.char_index_to_byte_once(index)..] + .code_points() + .next() + .unwrap(), } } } diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index c25940cd3..06e367386 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -1807,6 +1807,31 @@ pub(crate) fn init(ctx: &'static Context) { PyStrIterator::extend_class(ctx, ctx.types.str_iterator_type); } +impl PyStr { + /// The code points at `indices`, in that order, as a new string. + /// + /// Each index is resolved through the string's own index table, so the + /// cost is one lookup per collected character rather than a walk to the + /// furthest one. The iterator's length is the result's character count, + /// which is why it has to be exact. + fn gather_chars(&self, indices: impl ExactSizeIterator) -> Self { + let char_len = indices.len(); + // Not ascii, so the code points are at least two bytes each. + let mut out = Wtf8Buf::with_capacity(2 * char_len); + let s = self.as_wtf8(); + for index in indices { + out.push( + s[self.data.char_index_to_byte(index)..] + .code_points() + .next() + .expect("index is below the character count"), + ); + } + // SAFETY: char_len is accurate + unsafe { Self::new_with_char_len(out, char_len) } + } +} + impl SliceableSequenceOp for PyStr { type Item = CodePoint; type Sliced = Self; @@ -1816,125 +1841,56 @@ impl SliceableSequenceOp for PyStr { } fn do_slice(&self, range: Range) -> Self::Sliced { - match self.as_str_kind() { - PyKindStr::Ascii(s) => s[range].into(), - PyKindStr::Utf8(s) => { - let char_len = range.len(); - let out = rustpython_common::str::get_chars(s, range); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } - PyKindStr::Wtf8(w) => { - let char_len = range.len(); - let out = rustpython_common::str::get_codepoints(w, range); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } + if let PyKindStr::Ascii(s) = self.as_str_kind() { + return s[range].into(); } + // Both ends resolve through the string's own index, so the slice is a + // byte reslice rather than a walk to `range.start` and another to + // `range.end`. + let char_len = range.len(); + let bytes = self.data.char_range_to_bytes(range); + let out = &self.as_wtf8()[bytes]; + // SAFETY: char_len is accurate + unsafe { Self::new_with_char_len(out.to_owned(), char_len) } } fn do_slice_reverse(&self, range: Range) -> Self::Sliced { - match self.as_str_kind() { - PyKindStr::Ascii(s) => { - let mut out = s[range].to_owned(); - out.as_mut_slice().reverse(); - out.into() - } - PyKindStr::Utf8(s) => { - let char_len = range.len(); - let mut out = String::with_capacity(2 * char_len); - out.extend( - s.chars() - .rev() - .skip(self.char_len() - range.end) - .take(range.len()), - ); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, range.len()) } - } - PyKindStr::Wtf8(w) => { - let char_len = range.len(); - let mut out = Wtf8Buf::with_capacity(2 * char_len); - out.extend( - w.code_points() - .rev() - .skip(self.char_len() - range.end) - .take(range.len()), - ); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } + if let PyKindStr::Ascii(s) = self.as_str_kind() { + let mut out = s[range].to_owned(); + out.as_mut_slice().reverse(); + return out.into(); } + let char_len = range.len(); + let bytes = self.data.char_range_to_bytes(range); + let mut out = Wtf8Buf::with_capacity(bytes.len()); + out.extend(self.as_wtf8()[bytes].code_points().rev()); + // SAFETY: char_len is accurate + unsafe { Self::new_with_char_len(out, char_len) } } fn do_stepped_slice(&self, range: Range, step: usize) -> Self::Sliced { - match self.as_str_kind() { - PyKindStr::Ascii(s) => s[range] + if let PyKindStr::Ascii(s) = self.as_str_kind() { + return s[range] .as_slice() .iter() .copied() .step_by(step) .collect::() - .into(), - PyKindStr::Utf8(s) => { - let char_len = range.len().div_ceil(step); - let mut out = String::with_capacity(2 * char_len); - out.extend(s.chars().skip(range.start).take(range.len()).step_by(step)); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } - PyKindStr::Wtf8(w) => { - let char_len = range.len().div_ceil(step); - let mut out = Wtf8Buf::with_capacity(2 * char_len); - out.extend( - w.code_points() - .skip(range.start) - .take(range.len()) - .step_by(step), - ); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } + .into(); } + self.gather_chars(range.step_by(step)) } fn do_stepped_slice_reverse(&self, range: Range, step: usize) -> Self::Sliced { - match self.as_str_kind() { - PyKindStr::Ascii(s) => s[range] + if let PyKindStr::Ascii(s) = self.as_str_kind() { + return s[range] .chars() .rev() .step_by(step) .collect::() - .into(), - PyKindStr::Utf8(s) => { - let char_len = range.len().div_ceil(step); - // not ascii, so the codepoints have to be at least 2 bytes each - let mut out = String::with_capacity(2 * char_len); - out.extend( - s.chars() - .rev() - .skip(self.char_len() - range.end) - .take(range.len()) - .step_by(step), - ); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } - PyKindStr::Wtf8(w) => { - let char_len = range.len().div_ceil(step); - // not ascii, so the codepoints have to be at least 2 bytes each - let mut out = Wtf8Buf::with_capacity(2 * char_len); - out.extend( - w.code_points() - .rev() - .skip(self.char_len() - range.end) - .take(range.len()) - .step_by(step), - ); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } + .into(); } + self.gather_chars(range.rev().step_by(step)) } fn empty() -> Self::Sliced { From 903ba6ed811576bb706d9c8ef6283e24ee1c807f Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Sat, 15 Aug 2026 02:23:05 +0900 Subject: [PATCH 74/94] ci: fix OSCCA pull request permissions (#8515) Assisted-by: Codex:gpt-5.6-sol --- .github/workflows/oscca-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/oscca-pr.yml b/.github/workflows/oscca-pr.yml index f96c18fd3..38b67625c 100644 --- a/.github/workflows/oscca-pr.yml +++ b/.github/workflows/oscca-pr.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-slim timeout-minutes: 5 permissions: - issues: write + pull-requests: write steps: - name: Label and assign pull request uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 From f35fe5e29643f75ba06fe23d71dafd80a32ce8a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:40:34 +0900 Subject: [PATCH 75/94] build(deps): bump thiserror in the thiserror group across 1 directory (#8527) Bumps the thiserror group with 1 update in the / directory: [thiserror](https://github.com/dtolnay/thiserror). Updates `thiserror` from 2.0.19 to 2.0.20 - [Release notes](https://github.com/dtolnay/thiserror/releases) - [Commits](https://github.com/dtolnay/thiserror/compare/2.0.19...2.0.20) --- updated-dependencies: - dependency-name: thiserror dependency-version: 2.0.20 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: thiserror ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26015d399..9ae2c1875 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4209,18 +4209,18 @@ checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", From 7bdfc9555cb370cc02bcdbcec54e77e5282b7591 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:40:43 +0900 Subject: [PATCH 76/94] build(deps): bump psm from 0.1.31 to 0.1.32 (#8528) Bumps [psm](https://github.com/rust-lang/stacker) from 0.1.31 to 0.1.32. - [Commits](https://github.com/rust-lang/stacker/compare/psm-0.1.31...psm-0.1.32) --- updated-dependencies: - dependency-name: psm dependency-version: 0.1.32 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ae2c1875..c8d3f2f6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,9 +2766,9 @@ dependencies = [ [[package]] name = "psm" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" dependencies = [ "ar_archive_writer", "cc", From 0775f33128dd98d856fa03fca930084bd0392f76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:40:51 +0900 Subject: [PATCH 77/94] build(deps): bump quote from 1.0.46 to 1.0.47 (#8529) Bumps [quote](https://github.com/dtolnay/quote) from 1.0.46 to 1.0.47. - [Release notes](https://github.com/dtolnay/quote/releases) - [Commits](https://github.com/dtolnay/quote/compare/1.0.46...1.0.47) --- updated-dependencies: - dependency-name: quote dependency-version: 1.0.47 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8d3f2f6e..7a9181d7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2847,9 +2847,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] From a97e8f0746d7e12b61b9d1fe2aba1bba814d7a3c Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:23:37 +0900 Subject: [PATCH 78/94] str: take search bounds through the character index (#8530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * str: count an empty needle in characters str.count with an empty needle ran find_iter over the range's bytes, which reports one position per encoded byte boundary rather than one per character: '가나다'.count('') answered 10 instead of 4. Count the range's code points instead; bytes.count keeps the byte-position answer. Assisted-by: Claude * str: take search bounds through the character index find, rfind, index, rindex, count, startswith and endswith resolved their start/stop bounds with AnyStr::get_chars, which walks the payload to both bounds, and find reported its hit by counting the code points in front of it -- so a sweep over a subject's own indices walked it once per call. Resolve the bounds with StrData::char_range_to_bytes instead, which is the converter the subscript and slice paths already use, and map the hit back with Wtf8Index::char_index_at_byte, added here as the table's inverse: a bracketed search over the groups, then at most an entry's worth of steps. The payload alone does not say whether it is ASCII, so get_chars walked there too; that path is now off it as well. n=8000, sweeping the bound over the subject, best of 3, interleaved: '가나다라'*n/4 'abcd'*n/4 find 18.00ms -> 0.15ms 3.48ms -> 0.14ms startswith 16.75ms -> 0.12ms count 22.78ms -> 4.02ms count stays linear in the range it is given, as it is in CPython. Assisted-by: Claude --- crates/common/src/str.rs | 22 +++++++- crates/common/src/wtf8_index.rs | 78 +++++++++++++++++++++++++++-- crates/vm/src/anystr.rs | 6 ++- crates/vm/src/builtins/str.rs | 75 ++++++++++++++++++--------- extra_tests/snippets/builtin_str.py | 9 ++++ 5 files changed, 161 insertions(+), 29 deletions(-) diff --git a/crates/common/src/str.rs b/crates/common/src/str.rs index 72aecb793..326f76c43 100644 --- a/crates/common/src/str.rs +++ b/crates/common/src/str.rs @@ -443,7 +443,8 @@ impl StrData { self.char_index_to_byte(index) } - /// The byte range spanned by the code points in `range`. + /// The byte range spanned by the code points in `range`, whose end must not + /// exceed the string's code point count. /// /// A range that reaches within [`MAX_WALK_TO_INDEX`] of *both* ends is /// walked to for the same reason a single index near one end is -- a slice @@ -476,6 +477,25 @@ impl StrData { self.char_index_to_byte(range.start)..self.char_index_to_byte(range.end) } + /// The character index of the character starting at byte offset `bytepos`, + /// the inverse of [`Self::char_index_to_byte`]. + /// + /// `bytepos` must be a character boundary at or before the end. + /// + /// Logarithmic rather than constant, because the index is keyed the other + /// way -- but a search whose bounds came from `char_index_to_byte` has the + /// table already, and this is what turns a byte offset back into the answer + /// a caller asked for in characters. + pub fn byte_to_char_index(&self, bytepos: usize) -> usize { + if self.kind.is_ascii() { + return bytepos; + } + let char_len = self.char_len(); + self.index + .get_or_build(&self.data, char_len) + .char_index_at_byte(&self.data, bytepos, char_len) + } + pub fn nth_char(&self, index: usize) -> CodePoint { match self.as_str_kind() { PyKindStr::Ascii(s) => s[index].into(), diff --git a/crates/common/src/wtf8_index.rs b/crates/common/src/wtf8_index.rs index a6121f1c0..4b2e5ebee 100644 --- a/crates/common/src/wtf8_index.rs +++ b/crates/common/src/wtf8_index.rs @@ -104,6 +104,63 @@ impl Wtf8Index { } } + /// The index of the code point starting at byte offset `bytepos`, the + /// inverse of [`Self::byte_offset`]. + /// + /// `data` must be the buffer the table was built for, `char_len` its code + /// point count, and `bytepos` a code point boundary at or before its end. + /// + /// Logarithmic rather than constant: the table is keyed by code point + /// index, so going the other way is a search through it. The bracketing + /// below is what keeps that search short -- a code point occupies one to + /// four bytes, which pins the answer to a narrow band around `bytepos` + /// before the first comparison. + #[must_use] + pub fn char_index_at_byte(&self, data: &Wtf8, bytepos: usize, char_len: usize) -> usize { + let bytes_remaining = data.len() - bytepos; + // At least one byte per remaining code point, and at most four, so the + // group holding the answer lies between these. + let mut group_min = + usize::max(bytepos / 4, char_len.saturating_sub(bytes_remaining + 1)) >> 6; + let mut group_max = usize::min(bytepos, char_len.saturating_sub(bytes_remaining / 4)) >> 6; + while group_min < group_max { + let middle = group_min.midpoint(group_max) + 1; + if bytepos < self.groups[middle].base { + group_max = middle - 1; + } else { + group_min = middle; + } + } + + let base = self.groups[group_min].base; + if base == bytepos { + return group_min << 6; + } + // Walk the group's entries to the last one at or before `bytepos`, + // then step the remaining code points, of which there are at most + // three -- an entry covers four. + let entries = if group_min == self.groups.len() - 1 { + ((char_len - 1) >> 2) & 0x0F + } else { + 16 + }; + let mut index = group_min << 6; + let mut pos = base; + for entry in 0..entries { + let at = base + self.groups[group_min].ofs[entry] as usize; + if at >= bytepos { + break; + } + pos = at; + index = (group_min << 6) + (entry << 2) + 1; + } + while pos < bytepos { + pos = next_pos(data, pos); + index += 1; + } + index + } + /// The table's heap footprint, in bytes. #[must_use] pub fn byte_size(&self) -> usize { @@ -153,21 +210,34 @@ mod tests { use super::*; use crate::wtf8::{CodePoint, Wtf8Buf}; - /// Every index of `s`, against the offsets its own iterator reports. + /// Every index of `s`, both ways, against the offsets its own iterator + /// reports. fn check(s: &Wtf8) { let expected: Vec = s .code_point_indices() .map(|(byte_offset, _)| byte_offset) .collect(); - let index = Wtf8Index::new(s, expected.len()); + let char_len = expected.len(); + let index = Wtf8Index::new(s, char_len); for (i, &want) in expected.iter().enumerate() { assert_eq!( index.byte_offset(s, i), want, - "index {i} of {s:?} ({} code points)", - expected.len() + "index {i} of {s:?} ({char_len} code points)" + ); + assert_eq!( + index.char_index_at_byte(s, want, char_len), + i, + "byte {want} of {s:?} ({char_len} code points)" ); } + // One past the last code point is a boundary too, and the searches that + // use this ask for it as an end bound. + assert_eq!( + index.char_index_at_byte(s, s.len(), char_len), + char_len, + "end of {s:?}" + ); } fn wtf8(s: &str) -> Wtf8Buf { diff --git a/crates/vm/src/anystr.rs b/crates/vm/src/anystr.rs index 69ba52526..45a69dbfe 100644 --- a/crates/vm/src/anystr.rs +++ b/crates/vm/src/anystr.rs @@ -147,7 +147,11 @@ pub(crate) trait AnyStr { fn as_bytes(&self) -> &[u8]; fn elements(&self) -> impl Iterator; fn get_bytes(&self, range: Range) -> &Self; - // FIXME: get_chars is expensive for str + /// The characters in `range`, which for a `str` payload means walking to + /// both bounds -- the payload does not carry the string's character index. + /// `PyStr` therefore converts its own ranges and does not reach the search + /// helpers below through this; what remains are the byte strings, where a + /// character range is already a byte range. fn get_chars(&self, range: Range) -> &Self; fn bytes_len(&self) -> usize; // NOTE: str::chars().count() consumes the O(n) time. But pystr::char_len does cache. diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index 06e367386..2e099424a 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -9,7 +9,7 @@ use super::{ use crate::{ AsObject, Context, Py, PyExact, PyObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, TryFromBorrowedObject, VirtualMachine, - anystr::{self, AnyStr, AnyStrContainer, AnyStrWrapper, adjust_indices}, + anystr::{self, AnyStr, AnyStrContainer, AnyStrWrapper, StringRange, adjust_indices}, atomic_func, bytes_inner::{swapcase_ascii, title_ascii}, cformat::cformat_string, @@ -719,6 +719,13 @@ impl PyStr { self.data.char_index_to_byte(index) } + /// The character index of the character starting at byte offset `bytepos`, + /// which must be a character boundary at or before the end. + #[inline] + pub fn byte_to_char_index(&self, bytepos: usize) -> usize { + self.data.byte_to_char_index(bytepos) + } + #[pymethod] #[inline(always)] pub const fn isascii(&self) -> bool { @@ -924,11 +931,12 @@ impl PyStr { #[pymethod] fn endswith(&self, options: anystr::StartsEndsWithArgs, vm: &VirtualMachine) -> PyResult { - let (affix, substr) = - match options.prepare(self.as_wtf8(), self.len(), |s, r| s.get_chars(r)) { - Some(x) => x, - None => return Ok(false), - }; + let (affix, substr) = match options.prepare(self.as_wtf8(), self.len(), |s, r| { + &s[self.data.char_range_to_bytes(r)] + }) { + Some(x) => x, + None => return Ok(false), + }; substr.py_starts_ends_with( &affix, "endswith", @@ -944,11 +952,12 @@ impl PyStr { options: anystr::StartsEndsWithArgs, vm: &VirtualMachine, ) -> PyResult { - let (affix, substr) = - match options.prepare(self.as_wtf8(), self.len(), |s, r| s.get_chars(r)) { - Some(x) => x, - None => return Ok(false), - }; + let (affix, substr) = match options.prepare(self.as_wtf8(), self.len(), |s, r| { + &s[self.data.char_range_to_bytes(r)] + }) { + Some(x) => x, + None => return Ok(false), + }; substr.py_starts_ends_with( &affix, "startswith", @@ -1167,42 +1176,52 @@ impl PyStr { Ok(vm.ctx.new_str(joined)) } - // FIXME: two traversals of str is expensive + /// The bytes the character range `range` spans and the byte offset it + /// starts at, or `None` if the range is inverted. + /// + /// The bounds go through the string's character index, so reaching a range + /// deep in the subject costs a lookup rather than a walk to it. #[inline] - fn _to_char_idx(r: &Wtf8, byte_idx: usize) -> usize { - r[..byte_idx].code_points().count() + fn char_range_bytes(&self, range: Range) -> Option<(usize, &Wtf8)> { + if !range.is_normal() { + return None; + } + let bytes = self.data.char_range_to_bytes(range); + Some((bytes.start, &self.as_wtf8()[bytes])) } + /// Searches the character range `range` with `find`, which answers in bytes + /// relative to the range, and reports the hit as a character index. #[inline] fn _find(&self, args: FindArgs, find: F) -> Option where F: Fn(&Wtf8, &Wtf8) -> Option, { let (sub, range) = args.get_value(self.len()); - self.as_wtf8().py_find(sub.as_wtf8(), range, find) + let (start, haystack) = self.char_range_bytes(range)?; + let found = find(haystack, sub.as_wtf8())?; + Some(self.byte_to_char_index(start + found)) } #[pymethod] fn find(&self, args: FindArgs) -> isize { - self._find(args, |r, s| Some(Self::_to_char_idx(r, r.find(s)?))) - .map_or(-1, |v| v as isize) + self._find(args, Wtf8::find).map_or(-1, |v| v as isize) } #[pymethod] fn rfind(&self, args: FindArgs) -> isize { - self._find(args, |r, s| Some(Self::_to_char_idx(r, r.rfind(s)?))) - .map_or(-1, |v| v as isize) + self._find(args, Wtf8::rfind).map_or(-1, |v| v as isize) } #[pymethod] fn index(&self, args: FindArgs, vm: &VirtualMachine) -> PyResult { - self._find(args, |r, s| Some(Self::_to_char_idx(r, r.find(s)?))) + self._find(args, Wtf8::find) .ok_or_else(|| vm.new_value_error("substring not found")) } #[pymethod] fn rindex(&self, args: FindArgs, vm: &VirtualMachine) -> PyResult { - self._find(args, |r, s| Some(Self::_to_char_idx(r, r.rfind(s)?))) + self._find(args, Wtf8::rfind) .ok_or_else(|| vm.new_value_error("substring not found")) } @@ -1275,8 +1294,18 @@ impl PyStr { #[pymethod] fn count(&self, args: FindArgs) -> usize { let (needle, range) = args.get_value(self.len()); - self.as_wtf8() - .py_count(needle.as_wtf8(), range, |h, n| h.find_iter(n).count()) + let chars = range.len(); + self.char_range_bytes(range).map_or(0, |(_, haystack)| { + if needle.is_empty() { + // An empty needle sits between every pair of characters and at + // both ends, so it occurs once more than the range holds + // characters. Counting it in the bytes would answer in encoded + // positions instead. + chars + 1 + } else { + haystack.find_iter(needle.as_wtf8()).count() + } + }) } #[pymethod] diff --git a/extra_tests/snippets/builtin_str.py b/extra_tests/snippets/builtin_str.py index fde9deb8e..6eead5ddb 100644 --- a/extra_tests/snippets/builtin_str.py +++ b/extra_tests/snippets/builtin_str.py @@ -170,6 +170,15 @@ assert "aaa".count("a", 1, 2) == 1 assert "aaa".count("a", 2, 2) == 0 assert "aaa".count("a", 2, 1) == 0 +# An empty needle is counted in characters, not in encoded positions. +assert "".count("") == 1 +assert "abc".count("") == 4 +assert "가나다".count("") == 4 +assert "가나다".count("", 1) == 3 +assert "가나다".count("", 1, 2) == 2 +assert "가나다".count("", 4, 4) == 0 +assert "a\U0001f600b".count("") == 4 + assert "___a__".find("a") == 3 assert "___a__".find("a", -10) == 3 assert "___a__".find("a", -3) == 3 From 9e4c5708c62fd286685b9a1c5f9be0edb8cb6b8b Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:23:05 +0900 Subject: [PATCH 79/94] str, bytes: answer equality with equality rather than with an ordering (#8531) PyStr's comparison, PyBytesInner's, and the specialized CompareOpStr instruction all answered == and != by taking Ord::cmp of the two buffers and asking whether the result was Equal. An ordering has to read the bytes: it memcmps the common prefix even where the lengths already settle the question. CompareOpStr bypasses the Comparable slot, so it had also lost the identity shortcut that slot takes, and a string compared with itself was read end to end. Add PyComparisonOp::eval_eq, which settles Eq and Ne from an equality test and leaves an ordering operator to the caller, and answer through it in the three places: slice equality checks the length first, and CompareOpStr answers an object compared with itself the way the slot it specializes does. n=1,000,000, per comparison: before after s == s (the very same object) 23.21us 0.16us s == a string one shorter 24.63us 0.17us b == bytes one shorter 25.28us 0.20us ba == bytearray one shorter 27.68us 0.23us s == an equal, distinct string 23.78us 24.50us s < an equal string 29.87us 25.07us Assisted-by: Claude --- crates/vm/src/builtins/str.rs | 5 +++ crates/vm/src/bytes_inner.rs | 7 ++- crates/vm/src/frame.rs | 9 ++-- crates/vm/src/types/slot.rs | 23 ++++++++++ extra_tests/snippets/operator_comparison.py | 47 +++++++++++++++++++++ 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index 2e099424a..f3655d892 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -1592,6 +1592,11 @@ impl Comparable for PyStr { return Ok(res.into()); } let other = class_or_notimplemented!(Self, other); + // Equality does not need the ordering, and answers two strings of + // different length without reading either. + if let Some(res) = op.eval_eq(|| zelf.as_wtf8() == other.as_wtf8()) { + return Ok(res.into()); + } Ok(op.eval_ord(zelf.as_wtf8().cmp(other.as_wtf8())).into()) } } diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index 6c76808c5..6ceebb70d 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -345,7 +345,12 @@ impl PyBytesInner { // but not memoryview, and not equal if compare with unicode str(PyStr) PyComparisonValue::from_option( other - .try_bytes_like(vm, |other| op.eval_ord(self.elements.as_slice().cmp(other))) + .try_bytes_like(vm, |other| { + // Equality does not need the ordering, and answers two + // buffers of different length without reading either. + op.eval_eq(|| self.elements.as_slice() == other) + .unwrap_or_else(|| op.eval_ord(self.elements.as_slice().cmp(other))) + }) .ok(), ) } diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index ebb158c8f..a1e7a98d5 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -6894,10 +6894,13 @@ impl ExecutingFrame<'_> { b.downcast_ref_if_exact::(vm), ) { let op = self.compare_op_from_arg(arg); - if op != PyComparisonOp::Eq && op != PyComparisonOp::Ne { + // The same two shortcuts the unspecialized comparison takes: + // one object is equal to itself, and equality answers two + // strings of different length without reading either. + let Some(result) = op.eval_eq(|| a.is(b) || a_str.as_wtf8() == b_str.as_wtf8()) + else { return self.execute_compare(vm, arg); - } - let result = op.eval_ord(a_str.as_wtf8().cmp(b_str.as_wtf8())); + }; self.pop_value(); self.pop_value(); self.push_value(vm.ctx.new_bool(result).into()); diff --git a/crates/vm/src/types/slot.rs b/crates/vm/src/types/slot.rs index d834406cf..c039e6b5b 100644 --- a/crates/vm/src/types/slot.rs +++ b/crates/vm/src/types/slot.rs @@ -1995,6 +1995,29 @@ impl PyComparisonOp { self.map_eq(|| a.borrow().is(b.borrow())) } + /// The answer to this comparison for two operands that `equal` reports as + /// equal or not, or `None` for an ordering operator, which equality alone + /// cannot settle -- `equal` is not called in that case. + /// + /// This is what lets a type answer `==` and `!=` with an equality test + /// rather than with an ordering: the two agree on the answer, but equality + /// can settle a length mismatch without looking at the contents at all. + /// + /// The two neighbouring helpers answer different questions: [`Self::map_eq`] + /// answers only where its predicate holds, so a caller still handles the + /// other side, and [`Self::eq_only`] declares the comparison + /// `NotImplemented` for an ordering operator. This one leaves the ordering + /// operators to the caller, which is what a type with a real ordering + /// needs. + #[inline] + pub fn eval_eq(self, equal: impl FnOnce() -> bool) -> Option { + match self { + Self::Eq => Some(equal()), + Self::Ne => Some(!equal()), + _ => None, + } + } + /// Returns `Some(true)` when self is `Eq` and `f()` returns true. Returns `Some(false)` when self /// is `Ne` and `f()` returns true. Otherwise returns `None`. #[inline] diff --git a/extra_tests/snippets/operator_comparison.py b/extra_tests/snippets/operator_comparison.py index 71231f033..35a2083e9 100644 --- a/extra_tests/snippets/operator_comparison.py +++ b/extra_tests/snippets/operator_comparison.py @@ -87,3 +87,50 @@ assert not math.nan > 123 assert not math.nan < 123 assert not math.nan >= 123 assert not math.nan <= 123 + + +# str and bytes comparisons, through a function so that the operands are not +# constants the compiler can fold, and in a loop so the specialized comparison +# is reached. +def cmp_all(a, b): + return (a == b, a != b, a < b, a <= b, a > b, a >= b) + + +def check(a, b, expected): + for _ in range(200): + assert cmp_all(a, b) == expected, (a, b, cmp_all(a, b), expected) + + +EQ = (True, False, False, True, False, True) +LT = (False, True, True, True, False, False) +GT = (False, True, False, False, True, True) + +same = "abc" * 3 +check(same, same, EQ) # the very same object +check(same, "abcabcabc", EQ) # equal, distinct objects +check("abc", "abd", LT) # same length, differing content +check("abc", "abcd", LT) # a prefix is less than what extends it +check("abcd", "abc", GT) +check("", "a", LT) +check("", "", EQ) +check("\ud800", "\ud800", EQ) # lone surrogates are compared as themselves +check("\ud800", "\udfff", LT) +check("a\U0001f600", "a\U0001f600", EQ) +check("가나다", "가나다", EQ) +check("가나", "가나다", LT) + +# Comparing with a non-string is never an error for == and !=. +assert not "abc" == 3 +assert "abc" != 3 + +bsame = b"abc" * 3 +check(bsame, bsame, EQ) +check(bsame, b"abcabcabc", EQ) +check(b"abc", b"abd", LT) +check(b"abc", b"abcd", LT) +check(b"abcd", b"abc", GT) +check(bytearray(b"abc"), bytearray(b"abcd"), LT) +check(bytearray(b"abc"), b"abc", EQ) # bytearray and bytes compare by content +check(b"abc", bytearray(b"abd"), LT) +assert not b"abc" == "abc" +assert b"abc" != "abc" From 7cda53b946ddbdf717c1c6fabc3c275187699741 Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sat, 15 Aug 2026 21:04:45 +0900 Subject: [PATCH 80/94] sqlite3: validate narg before creating functions/aggregates (#8532) CPython 3.14 added check_num_params() which raises ProgrammingError when narg/n_arg/num_params is out of range (-1..=SQLITE_LIMIT_FUNCTION_ARG). RustPython passed invalid values directly to SQLite, resulting in an OperationalError instead. Add check_num_params() helper and call it in create_function(), create_aggregate(), and create_window_function(). Assisted-by: GitHub Copilot:claude-sonnet-4-6 --- Lib/test/test_sqlite3/test_userfunctions.py | 3 --- crates/stdlib/src/_sqlite3.rs | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_sqlite3/test_userfunctions.py b/Lib/test/test_sqlite3/test_userfunctions.py index e7cecb852..6da92d776 100644 --- a/Lib/test/test_sqlite3/test_userfunctions.py +++ b/Lib/test/test_sqlite3/test_userfunctions.py @@ -170,7 +170,6 @@ class FunctionTests(unittest.TestCase): def tearDown(self): self.con.close() - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for invalid num args def test_func_error_on_create(self): with self.assertRaisesRegex(sqlite.ProgrammingError, "not -100"): self.con.create_function("bla", -100, lambda x: 2*x) @@ -514,7 +513,6 @@ class WindowFunctionTests(unittest.TestCase): self.cur.execute(self.query % "sumint") self.assertEqual(self.cur.fetchall(), self.expected) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for invalid num args def test_win_error_on_create(self): with self.assertRaisesRegex(sqlite.ProgrammingError, "not -100"): self.con.create_window_function("shouldfail", -100, WindowSumInt) @@ -649,7 +647,6 @@ class AggregateTests(unittest.TestCase): def tearDown(self): self.con.close() - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for invalid num args def test_aggr_error_on_create(self): with self.assertRaisesRegex(sqlite.ProgrammingError, "not -100"): self.con.create_function("bla", -100, AggrSum) diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index 5348cc1f5..5576b8462 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -1279,6 +1279,7 @@ mod _sqlite3 { SQLITE_UTF8 }; let db = self.db_lock(vm)?; + check_num_params(&db, args.narg, "narg", vm)?; let Some(data) = CallbackData::new(args.func, vm) else { return db.create_function( name.as_ptr(), @@ -1310,6 +1311,7 @@ mod _sqlite3 { fn create_aggregate(&self, args: CreateAggregateArgs, vm: &VirtualMachine) -> PyResult<()> { let name = args.name.to_cstring(vm)?; let db = self.db_lock(vm)?; + check_num_params(&db, args.narg, "n_arg", vm)?; let Some(data) = CallbackData::new(args.aggregate_class, vm) else { return db.create_function( name.as_ptr(), @@ -1392,6 +1394,7 @@ mod _sqlite3 { ) -> PyResult<()> { let name = name.to_cstring(vm)?; let db = self.db_lock(vm)?; + check_num_params(&db, narg, "num_params", vm)?; let Some(data) = CallbackData::new(aggregate_class, vm) else { unsafe { sqlite3_create_window_function( @@ -3475,6 +3478,22 @@ mod _sqlite3 { Ok(obj) } + fn check_num_params( + db: &Sqlite, + n: c_int, + param_name: &str, + vm: &VirtualMachine, + ) -> PyResult<()> { + let limit = unsafe { sqlite3_limit(db.db, SQLITE_LIMIT_FUNCTION_ARG, -1) }; + if n < -1 || n > limit { + return Err(new_programming_error( + vm, + format!("'{param_name}' must be between -1 and {limit}, not {n}"), + )); + } + Ok(()) + } + fn ptr_to_str<'a>(p: *const libc::c_char, vm: &VirtualMachine) -> PyResult<&'a str> { if p.is_null() { return Err(vm.new_memory_error("string pointer is null")); From a142de9abcfea37599e8885a8a14d491fb6e2a45 Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sat, 15 Aug 2026 21:05:12 +0900 Subject: [PATCH 81/94] sqlite3: add Connection exception attributes and fix autocommit ValueError (#8533) Add DB-API 2.0 optional extension: expose exception classes as attributes on Connection objects (Warning, Error, InterfaceError, DatabaseError, DataError, OperationalError, IntegrityError, InternalError, ProgrammingError, NotSupportedError). Also fix autocommit validation to raise ValueError (not TypeError) when an unsupported type is passed. Assisted-by: GitHub Copilot:claude-sonnet-4-6 --- Lib/test/test_sqlite3/test_dbapi.py | 2 - Lib/test/test_sqlite3/test_transactions.py | 1 - crates/stdlib/src/_sqlite3.rs | 43 +++++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_sqlite3/test_dbapi.py b/Lib/test/test_sqlite3/test_dbapi.py index 68f8969a0..f99825f63 100644 --- a/Lib/test/test_sqlite3/test_dbapi.py +++ b/Lib/test/test_sqlite3/test_dbapi.py @@ -364,7 +364,6 @@ class ConnectionTests(unittest.TestCase): with self.cx: pass - @unittest.expectedFailure # TODO: RUSTPYTHON def test_exceptions(self): # Optional DB-API extension. self.assertEqual(self.cx.Warning, sqlite.Warning) @@ -401,7 +400,6 @@ class ConnectionTests(unittest.TestCase): with self.assertRaises(AttributeError): self.cx.in_transaction = True - @unittest.expectedFailure # TODO: RUSTPYTHON def test_connection_exceptions(self): exceptions = [ "DataError", diff --git a/Lib/test/test_sqlite3/test_transactions.py b/Lib/test/test_sqlite3/test_transactions.py index d777af0ff..a3de7a7a8 100644 --- a/Lib/test/test_sqlite3/test_transactions.py +++ b/Lib/test/test_sqlite3/test_transactions.py @@ -387,7 +387,6 @@ class AutocommitAttribute(unittest.TestCase): cx.autocommit = mode self.assertEqual(cx.autocommit, mode) - @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit validation error messages differ def test_autocommit_setget_invalid(self): msg = "autocommit must be True, False, or.*LEGACY" for mode in "a", 12, (), None: diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index 5576b8462..559ef518e 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -322,7 +322,7 @@ mod _sqlite3 { ))) } } else { - Err(vm.new_type_error(format!( + Err(vm.new_value_error(format!( "autocommit must be True, False, or sqlite3.LEGACY_TRANSACTION_CONTROL, not {}", obj.class().name() ))) @@ -1645,6 +1645,47 @@ mod _sqlite3 { fn total_changes(&self, vm: &VirtualMachine) -> PyResult { self._db_lock(vm).map(|x| x.total_changes()) } + + #[pygetset(name = "Warning")] + fn exc_warning(&self) -> PyTypeRef { + warning_type().to_owned() + } + #[pygetset(name = "Error")] + fn exc_error(&self) -> PyTypeRef { + error_type().to_owned() + } + #[pygetset(name = "InterfaceError")] + fn exc_interface_error(&self) -> PyTypeRef { + interface_error_type().to_owned() + } + #[pygetset(name = "DatabaseError")] + fn exc_database_error(&self) -> PyTypeRef { + database_error_type().to_owned() + } + #[pygetset(name = "DataError")] + fn exc_data_error(&self) -> PyTypeRef { + data_error_type().to_owned() + } + #[pygetset(name = "OperationalError")] + fn exc_operational_error(&self) -> PyTypeRef { + operational_error_type().to_owned() + } + #[pygetset(name = "IntegrityError")] + fn exc_integrity_error(&self) -> PyTypeRef { + integrity_error_type().to_owned() + } + #[pygetset(name = "InternalError")] + fn exc_internal_error(&self) -> PyTypeRef { + internal_error_type().to_owned() + } + #[pygetset(name = "ProgrammingError")] + fn exc_programming_error(&self) -> PyTypeRef { + programming_error_type().to_owned() + } + #[pygetset(name = "NotSupportedError")] + fn exc_not_supported_error(&self) -> PyTypeRef { + not_supported_error_type().to_owned() + } } #[pyattr] From e19b6221743a0503e8966d5dee6b2ca4cf87cbd4 Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sat, 15 Aug 2026 21:05:33 +0900 Subject: [PATCH 82/94] sqlite3: pass None for NULL authorizer args instead of crashing (#8534) * sqlite3: pass None for NULL authorizer args instead of crashing CPython's authorizer callback receives NULL for arg1/arg2/db_name/access when not applicable (e.g. SQLITE_READ on a table gives NULL for the database name in some versions). Previously RustPython passed these pointers to ptr_to_str which would crash or produce an error. Now ptr_to_str_or_none is used: NULL pointers become Python None, which matches CPython behavior and allows test_table_access and test_column_access to pass. Assisted-by: GitHub Copilot:claude-sonnet-4-6 * fix(sqlite3): avoid reentrant deadlock in Statement::new sqlite3_prepare_v2 can synchronously invoke the authorizer callback, which may call back into Connection methods (e.g. set_authorizer) that require the same db lock. Holding db_lock across the prepare() call caused a self-deadlock when a callback re-entered the connection. Release the lock after sql_limit check and copy the raw handle before calling prepare(), so FFI calls that can trigger Python re-entrancy happen outside the lock scope. Fixes hang in test_authorizer_concurrent_mutation_in_call --- Lib/test/test_sqlite3/test_userfunctions.py | 2 -- crates/stdlib/src/_sqlite3.rs | 30 ++++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_sqlite3/test_userfunctions.py b/Lib/test/test_sqlite3/test_userfunctions.py index 6da92d776..d63bccf96 100644 --- a/Lib/test/test_sqlite3/test_userfunctions.py +++ b/Lib/test/test_sqlite3/test_userfunctions.py @@ -800,13 +800,11 @@ class AuthorizerTests(unittest.TestCase): def tearDown(self): self.con.close() - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs def test_table_access(self): with self.assertRaises(sqlite.DatabaseError) as cm: self.con.execute("select * from t2") self.assertIn('prohibited', str(cm.exception)) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs def test_column_access(self): with self.assertRaises(sqlite.DatabaseError) as cm: self.con.execute("select c2 from t1") diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index 559ef518e..b887ed0f6 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -587,10 +587,10 @@ mod _sqlite3 { ) -> c_int { let (callable, vm) = unsafe { (*data.cast::()).retrieve() }; let f = || -> PyResult { - let arg1 = ptr_to_str(arg1, vm)?; - let arg2 = ptr_to_str(arg2, vm)?; - let db_name = ptr_to_str(db_name, vm)?; - let access = ptr_to_str(access, vm)?; + let arg1 = ptr_to_str_or_none(arg1, vm)?; + let arg2 = ptr_to_str_or_none(arg2, vm)?; + let db_name = ptr_to_str_or_none(db_name, vm)?; + let access = ptr_to_str_or_none(access, vm)?; let val = callable.call((action, arg1, arg2, db_name, access), vm)?; let Some(val) = val.downcast_ref::() else { @@ -2842,12 +2842,14 @@ mod _sqlite3 { } let sql_cstr = sql.to_cstring(vm)?; - let db = connection.db_lock(vm)?; - - db.sql_limit(sql.byte_len(), vm)?; + let raw = { + let db = connection.db_lock(vm)?; + db.sql_limit(sql.byte_len(), vm)?; + **db + }; let mut tail = null(); - let st = db.prepare(sql_cstr.as_ptr(), &mut tail, vm)?; + let st = raw.prepare(sql_cstr.as_ptr(), &mut tail, vm)?; let Some(st) = st else { return Ok(None); @@ -3540,7 +3542,17 @@ mod _sqlite3 { return Err(vm.new_memory_error("string pointer is null")); } unsafe { CStr::from_ptr(p).to_str() } - .map_err(|_| vm.new_value_error("Invalid UIF-8 codepoint")) + .map_err(|_| vm.new_value_error("Invalid UTF-8 codepoint")) + } + + fn ptr_to_str_or_none(p: *const libc::c_char, vm: &VirtualMachine) -> PyResult { + if p.is_null() { + return Ok(vm.ctx.none()); + } + let s = unsafe { CStr::from_ptr(p) } + .to_str() + .map_err(|_| vm.new_value_error("Invalid UTF-8 codepoint".to_owned()))?; + Ok(vm.ctx.new_str(s).into()) } fn ptr_to_string( From dbd3a0a6b36d505def388863b75b5ac75916654b Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sun, 16 Aug 2026 20:13:10 +0900 Subject: [PATCH 83/94] sqlite3: add SQLITE_DBCONFIG constants, setconfig() and getconfig() (#8535) * sqlite3: add SQLITE_DBCONFIG constants, setconfig() and getconfig() Implement Connection.setconfig() and Connection.getconfig() using sqlite3_db_config(), and export all SQLITE_DBCONFIG_* integer constants to the module. Fixes test_connection_config in test_dbapi.py. Assisted-by: GitHub Copilot:claude-sonnet-4-6 * sqlite3: fix setconfig to propagate actual SQLite error When sqlite3_db_config() returns a non-OK result, propagate the actual SQLite error instead of replacing it with a generic 'Unable to set config' message, to match CPython's set_error_from_db() behavior. Assisted-by: GitHub Copilot:claude-sonnet-4-6 --- Lib/test/test_sqlite3/test_dbapi.py | 1 - crates/stdlib/src/_sqlite3.rs | 83 ++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_sqlite3/test_dbapi.py b/Lib/test/test_sqlite3/test_dbapi.py index f99825f63..ef8acd0f3 100644 --- a/Lib/test/test_sqlite3/test_dbapi.py +++ b/Lib/test/test_sqlite3/test_dbapi.py @@ -525,7 +525,6 @@ class ConnectionTests(unittest.TestCase): cx.executemany, "insert into t values(?)", ((v,) for v in range(3))) - @unittest.expectedFailure # TODO: RUSTPYTHON; SQLITE_DBCONFIG constants not implemented def test_connection_config(self): op = sqlite.SQLITE_DBCONFIG_ENABLE_FKEY with memory_database() as cx: diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index b887ed0f6..02d408450 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -31,11 +31,11 @@ mod _sqlite3 { sqlite3_column_double, sqlite3_column_int64, sqlite3_column_name, sqlite3_column_text, sqlite3_column_type, sqlite3_complete, sqlite3_context, sqlite3_context_db_handle, sqlite3_create_collation_v2, sqlite3_create_function_v2, sqlite3_create_window_function, - sqlite3_data_count, sqlite3_db_handle, sqlite3_errcode, sqlite3_errmsg, sqlite3_exec, - sqlite3_expanded_sql, sqlite3_extended_errcode, sqlite3_finalize, sqlite3_get_autocommit, - sqlite3_interrupt, sqlite3_last_insert_rowid, sqlite3_libversion, sqlite3_limit, - sqlite3_open_v2, sqlite3_prepare_v2, sqlite3_progress_handler, sqlite3_reset, - sqlite3_result_blob, sqlite3_result_double, sqlite3_result_error, + sqlite3_data_count, sqlite3_db_config, sqlite3_db_handle, sqlite3_errcode, sqlite3_errmsg, + sqlite3_exec, sqlite3_expanded_sql, sqlite3_extended_errcode, sqlite3_finalize, + sqlite3_get_autocommit, sqlite3_interrupt, sqlite3_last_insert_rowid, sqlite3_libversion, + sqlite3_limit, sqlite3_open_v2, sqlite3_prepare_v2, sqlite3_progress_handler, + sqlite3_reset, sqlite3_result_blob, sqlite3_result_double, sqlite3_result_error, sqlite3_result_error_nomem, sqlite3_result_error_toobig, sqlite3_result_int64, sqlite3_result_null, sqlite3_result_text, sqlite3_set_authorizer, sqlite3_sleep, sqlite3_step, sqlite3_stmt, sqlite3_stmt_busy, sqlite3_stmt_readonly, sqlite3_threadsafe, @@ -161,7 +161,17 @@ mod _sqlite3 { SQLITE_ALTER_TABLE, SQLITE_ANALYZE, SQLITE_ATTACH, SQLITE_CREATE_INDEX, SQLITE_CREATE_TABLE, SQLITE_CREATE_TEMP_INDEX, SQLITE_CREATE_TEMP_TABLE, SQLITE_CREATE_TEMP_TRIGGER, SQLITE_CREATE_TEMP_VIEW, SQLITE_CREATE_TRIGGER, - SQLITE_CREATE_VIEW, SQLITE_CREATE_VTABLE, SQLITE_DELETE, SQLITE_DENY, SQLITE_DETACH, + SQLITE_CREATE_VIEW, SQLITE_CREATE_VTABLE, SQLITE_DBCONFIG_DEFENSIVE, + SQLITE_DBCONFIG_DQS_DDL, SQLITE_DBCONFIG_DQS_DML, SQLITE_DBCONFIG_ENABLE_ATTACH_CREATE, + SQLITE_DBCONFIG_ENABLE_ATTACH_WRITE, SQLITE_DBCONFIG_ENABLE_COMMENTS, + SQLITE_DBCONFIG_ENABLE_FKEY, SQLITE_DBCONFIG_ENABLE_FTS3_TOKENIZER, + SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, SQLITE_DBCONFIG_ENABLE_QPSG, + SQLITE_DBCONFIG_ENABLE_TRIGGER, SQLITE_DBCONFIG_ENABLE_VIEW, + SQLITE_DBCONFIG_LEGACY_ALTER_TABLE, SQLITE_DBCONFIG_LEGACY_FILE_FORMAT, + SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, SQLITE_DBCONFIG_RESET_DATABASE, + SQLITE_DBCONFIG_REVERSE_SCANORDER, SQLITE_DBCONFIG_STMT_SCANSTATUS, + SQLITE_DBCONFIG_TRIGGER_EQP, SQLITE_DBCONFIG_TRUSTED_SCHEMA, + SQLITE_DBCONFIG_WRITABLE_SCHEMA, SQLITE_DELETE, SQLITE_DENY, SQLITE_DETACH, SQLITE_DROP_INDEX, SQLITE_DROP_TABLE, SQLITE_DROP_TEMP_INDEX, SQLITE_DROP_TEMP_TABLE, SQLITE_DROP_TEMP_TRIGGER, SQLITE_DROP_TEMP_VIEW, SQLITE_DROP_TRIGGER, SQLITE_DROP_VIEW, SQLITE_DROP_VTABLE, SQLITE_FUNCTION, SQLITE_IGNORE, SQLITE_INSERT, SQLITE_LIMIT_ATTACHED, @@ -1519,6 +1529,39 @@ mod _sqlite3 { self.db_lock(vm)?.limit(category, limit, vm) } + #[pymethod] + fn setconfig( + &self, + op: c_int, + enable: OptionalArg, + vm: &VirtualMachine, + ) -> PyResult<()> { + let db = self.db_lock(vm)?; + if !is_int_dbconfig(op) { + return Err(vm.new_value_error(format!("unknown config 'op': {op}"))); + } + let enable = enable.unwrap_or(true) as c_int; + let mut actual: c_int = 0; + let rc = unsafe { sqlite3_db_config(db.db, op, enable, &mut actual) }; + db.check(rc, vm)?; + if enable != actual { + return Err(new_operational_error(vm, "Unable to set config".to_owned())); + } + Ok(()) + } + + #[pymethod] + fn getconfig(&self, op: c_int, vm: &VirtualMachine) -> PyResult { + let db = self.db_lock(vm)?; + if !is_int_dbconfig(op) { + return Err(vm.new_value_error(format!("unknown config 'op': {op}"))); + } + let mut current: c_int = 0; + let rc = unsafe { sqlite3_db_config(db.db, op, -1, &mut current) }; + db.check(rc, vm)?; + Ok(current != 0) + } + #[pymethod] fn __enter__(zelf: PyRef) -> PyRef { zelf @@ -3537,6 +3580,34 @@ mod _sqlite3 { Ok(()) } + fn is_int_dbconfig(op: c_int) -> bool { + use libsqlite3_sys::*; + matches!( + op, + SQLITE_DBCONFIG_ENABLE_FKEY + | SQLITE_DBCONFIG_ENABLE_TRIGGER + | SQLITE_DBCONFIG_ENABLE_FTS3_TOKENIZER + | SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION + | SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE + | SQLITE_DBCONFIG_ENABLE_QPSG + | SQLITE_DBCONFIG_TRIGGER_EQP + | SQLITE_DBCONFIG_RESET_DATABASE + | SQLITE_DBCONFIG_DEFENSIVE + | SQLITE_DBCONFIG_WRITABLE_SCHEMA + | SQLITE_DBCONFIG_LEGACY_ALTER_TABLE + | SQLITE_DBCONFIG_DQS_DDL + | SQLITE_DBCONFIG_DQS_DML + | SQLITE_DBCONFIG_ENABLE_VIEW + | SQLITE_DBCONFIG_LEGACY_FILE_FORMAT + | SQLITE_DBCONFIG_TRUSTED_SCHEMA + | SQLITE_DBCONFIG_STMT_SCANSTATUS + | SQLITE_DBCONFIG_REVERSE_SCANORDER + | SQLITE_DBCONFIG_ENABLE_ATTACH_CREATE + | SQLITE_DBCONFIG_ENABLE_ATTACH_WRITE + | SQLITE_DBCONFIG_ENABLE_COMMENTS + ) + } + fn ptr_to_str<'a>(p: *const libc::c_char, vm: &VirtualMachine) -> PyResult<&'a str> { if p.is_null() { return Err(vm.new_memory_error("string pointer is null")); From c3bfefaeb0d9797b0e3fb729ccbb0c05ccde2d28 Mon Sep 17 00:00:00 2001 From: sijun-yang Date: Sun, 16 Aug 2026 20:13:54 +0900 Subject: [PATCH 84/94] Remove private comprehension flags (#8537) * Remove the private assignment-expression marker Why: extend_namedexpr_scope() already propagates assignment-expression targets and records DEF_GLOBAL or DEF_NONLOCAL in comprehension scopes. ASSIGNED_IN_COMPREHENSION remained only for conflict checks. Its private bit overlaps CPython's packed LOCAL scope bit. Changes: - Register assignment-expression targets as ordinary assignments. - Detect later target conflicts from existing declaration flags. Assisted-by: Codex:gpt-5.6-sol * Remove the private comprehension iterator flag Why: ITER duplicates the DEF_LOCAL and DEF_COMP_ITER facts already recorded for comprehension targets. Its private bit also overlaps CPython's packed scope field. Changes: - Use those flags for rebinding checks and local restoration around inlined comprehensions. Assisted-by: Codex:gpt-5.6-sol * Preserve source names in named-expression diagnostics Why: Assignment-expression diagnostics exposed the mangled keys used for symbol-table lookup. CPython reports identifiers as written in source. Changes: - Use source names in both comprehension conflict diagnostics. - Enable test_named_expression_invalid_mangled_class_variables. Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_named_expressions.py | 1 - crates/codegen/src/compile.rs | 4 +- crates/codegen/src/symboltable.rs | 79 +++++------------------------- 3 files changed, 14 insertions(+), 70 deletions(-) diff --git a/Lib/test/test_named_expressions.py b/Lib/test/test_named_expressions.py index 2e0643484..a859e051d 100644 --- a/Lib/test/test_named_expressions.py +++ b/Lib/test/test_named_expressions.py @@ -365,7 +365,6 @@ class NamedExpressionInvalidTest(unittest.TestCase): with self.assertRaisesRegex(SyntaxError, msg): exec(f"lambda: {code}", {}) # Function scope - @unittest.expectedFailure # TODO: RUSTPYTHON; wrong error message def test_named_expression_invalid_mangled_class_variables(self): code = """class Foo: def bar(self): diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index afe868dab..fe0a98318 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -10919,9 +10919,7 @@ impl<'warnings> Compiler<'warnings> { if sym.flags.contains(SymbolFlags::DEF_PARAM) { continue; // skip .0 } - let is_local = sym - .flags - .intersects(SymbolFlags::DEF_LOCAL | SymbolFlags::ITER) + let is_local = sym.flags.contains(SymbolFlags::DEF_LOCAL) && !sym.flags.contains(SymbolFlags::DEF_NONLOCAL); if is_local { pushed_locals.push(name.clone()); diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index a66410f90..a771e19d3 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -312,19 +312,8 @@ bitflags! { Self::DEF_LOCAL.bits() | Self::DEF_PARAM.bits() | Self::DEF_IMPORT.bits() - | Self::ITER.bits() | Self::DEF_TYPE_PARAM.bits() ); - - - // TODO: Remove these, RustPython specific - - // indicates if the symbol gets a value assigned by a named expression in a comprehension - // this is required to correct the scope in the analysis. - const ASSIGNED_IN_COMPREHENSION = 2 << 11; - // indicates that the symbol is used a bound iterator variable. We distinguish this case - // from normal assignment to detect disallowed re-assignment to iterator variables. - const ITER = 2 << 12; } } @@ -1030,7 +1019,6 @@ enum SymbolUsage { AnnotationAssigned, Parameter, AnnotationParameter, - AssignedNamedExprInComprehension, Iter, TypeParam, } @@ -1048,8 +1036,6 @@ struct SymbolTableBuilder { varnames_stack: Vec>, // Track if we're inside an iterable definition expression (for nested comprehensions) in_iter_def_exp: bool, - // Track if we're scanning an inner loop iteration target (not the first generator) - in_comp_inner_loop_target: bool, // yield/yield from inside comprehension scopes is rejected with a // message that names the comprehension kind. comprehension_yield_context: Option<&'static str>, @@ -1084,7 +1070,6 @@ impl SymbolTableBuilder { current_varnames: Vec::new(), varnames_stack: Vec::new(), in_iter_def_exp: false, - in_comp_inner_loop_target: false, comprehension_yield_context: None, in_conditional_block: false, recursion_depth: 0, @@ -2525,24 +2510,8 @@ impl SymbolTableBuilder { self.scan_expression(value, ExpressionContext::Load)?; - // special handling for assigned identifier in named expressions - // that are used in comprehensions. This required to correctly - // propagate the scope of the named assigned named and not to - // propagate inner names. if let Some((id, target_range)) = named_target { - let table = self.tables.last().unwrap(); - if table.typ == CompilerScope::Comprehension { - self.register_name( - id, - SymbolUsage::AssignedNamedExprInComprehension, - target_range, - )?; - } else { - // omit one recursion. When the handling of an store changes for - // Identifiers this needs adapted - more forward safe would be - // calling scan_expression directly. - self.register_name(id, SymbolUsage::Assigned, target_range)?; - } + self.register_name(id, SymbolUsage::Assigned, target_range)?; } else { self.scan_expression(target, ExpressionContext::Store)?; } @@ -2613,9 +2582,7 @@ impl SymbolTableBuilder { } for generator in &generators[1..] { - self.in_comp_inner_loop_target = true; self.scan_expression(&generator.target, ExpressionContext::Iter)?; - self.in_comp_inner_loop_target = false; let was_in_iter_def_exp = self.in_iter_def_exp; self.in_iter_def_exp = true; self.scan_expression(&generator.iter, ExpressionContext::IterDefinitionExp)?; @@ -3037,11 +3004,15 @@ impl SymbolTableBuilder { if self.tables[table_idx] .symbols .get(mangled.as_str()) - .is_some_and(|symbol| symbol.flags.contains(SymbolFlags::ITER)) + .is_some_and(|symbol| { + symbol + .flags + .contains(SymbolFlags::DEF_LOCAL | SymbolFlags::DEF_COMP_ITER) + }) { return Err(SymbolTableError { error: format!( - "assignment expression cannot rebind comprehension iteration variable '{mangled}'" + "assignment expression cannot rebind comprehension iteration variable '{name}'" ), location, }); @@ -3151,7 +3122,6 @@ impl SymbolTableBuilder { | SymbolUsage::AnnotationAssigned | SymbolUsage::Parameter | SymbolUsage::AnnotationParameter - | SymbolUsage::AssignedNamedExprInComprehension | SymbolUsage::Iter | SymbolUsage::TypeParam ) { @@ -3179,16 +3149,16 @@ impl SymbolTableBuilder { let symbol = if let Some(symbol) = table.symbols.get_mut(name.as_ref()) { let flags = &symbol.flags; - // INNER_LOOP_CONFLICT: comprehension inner loop cannot rebind - // a variable that was used as a named expression target + // Mirrors CPython's INNER_LOOP_CONFLICT check. extend_namedexpr_scope() + // marks named-expression targets as global or nonlocal in the comprehension. // Example: [i for i in range(5) if (j := 0) for j in range(5)] // Here 'j' is used in named expr first, then as inner loop iter target - if self.in_comp_inner_loop_target - && flags.contains(SymbolFlags::ASSIGNED_IN_COMPREHENSION) + if matches!(role, SymbolUsage::Iter) + && flags.intersects(SymbolFlags::DEF_GLOBAL | SymbolFlags::DEF_NONLOCAL) { return Err(SymbolTableError { error: format!( - "comprehension inner loop cannot rebind assignment expression target '{name}'" + "comprehension inner loop cannot rebind assignment expression target '{original_name}'" ), location, }); @@ -3345,9 +3315,6 @@ impl SymbolTableBuilder { SymbolUsage::Assigned => { flags.insert(SymbolFlags::DEF_LOCAL); } - SymbolUsage::AssignedNamedExprInComprehension => { - flags.insert(SymbolFlags::DEF_LOCAL | SymbolFlags::ASSIGNED_IN_COMPREHENSION); - } SymbolUsage::Global => { symbol.scope = SymbolScope::GlobalExplicit; flags.insert(SymbolFlags::DEF_GLOBAL); @@ -3356,33 +3323,13 @@ impl SymbolTableBuilder { flags.insert(SymbolFlags::USE); } SymbolUsage::Iter => { - // CPython symtable_add_def_helper() records an inlined - // comprehension target as a local definition as well as a - // comprehension iterator. Keep ITER as the internal - // re-assignment check marker; DEF_LOCAL is part of the public - // ste_symbols flags exposed by _symtable. - flags.insert( - SymbolFlags::DEF_LOCAL | SymbolFlags::ITER | SymbolFlags::DEF_COMP_ITER, - ); + flags.insert(SymbolFlags::DEF_LOCAL | SymbolFlags::DEF_COMP_ITER); } SymbolUsage::TypeParam => { flags.insert(SymbolFlags::DEF_LOCAL | SymbolFlags::DEF_TYPE_PARAM); } } - // and even more checking - // it is not allowed to assign to iterator variables (by named expressions) - if flags.contains(SymbolFlags::ITER) - && flags.contains(SymbolFlags::ASSIGNED_IN_COMPREHENSION) - { - return Err(SymbolTableError { - error: format!( - "assignment expression cannot rebind comprehension iteration variable '{}'", - symbol.name - ), - location, - }); - } Ok(()) } } From 11de045ef8e27b3402359ea2ee28eb06178756f3 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:21:31 +0900 Subject: [PATCH 85/94] Per-interpreter runtime state and a process-wide GC stop-the-world (#8517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * vm: add per-interpreter runtime state and interpreter registry - vm/runtime.rs: process-global interpreter registry (monotonic ids, weak entries), InterpreterWhence/InterpreterInfo, process main id recording via main_interpreter_id(), a threading-gated owner map (store_owned_interpreter/ take_owned_interpreter/is_owned_interpreter/owned_interpreter_count), and the SUPPORTS_ISOLATED_INTERPRETERS constant. - PyGlobalState gains interpreter_id/whence/is_main and is_main_interpreter(); PyConfig/Settings derive Clone so a subinterpreter can clone parent config. - Interpreter: id()/whence()/is_main()/is_process_main(), create_subinterpreter() and create_owned_subinterpreter(); unregister on Drop. - thread.rs: per-interpreter thread slots (INTERP_THREAD_SLOTS), slot swap when switching interpreters on one OS thread, cleanup keyed by interpreter id. - Install signal handlers and init the main-thread ident only on the main interpreter; _thread._is_main_interpreter reflects the current interpreter. - sys.implementation.supports_isolated_interpreters reads the constant. - Guard the registry static for non-threading builds where rc::Weak is !Send. Assisted-by: Claude Code:claude-opus-4-8 * gc: stop every interpreter while collecting The generation lists are process-global, so a collection reads and frees objects owned by every interpreter. CollectStopTheWorld stopped only the collecting interpreter, leaving other interpreters' threads free to mutate the same object graph during the reference-subtraction, reachability and snapshot phases. Stop all live interpreters instead, in runtime id order, and restart them in reverse. The global `collecting` mutex serializes collectors process-wide, so no second collector takes these exclusions in another order; fork acquires a single interpreter's exclusion, so the orders cannot cycle. - runtime: add live_interpreter_states(), ordered by interpreter id. - StopTheWorldState methods take &PyGlobalState instead of &VirtualMachine, so an interpreter's world can be stopped without a VM for it; update the call sites in frame, _thread, posix, faulthandler and capi. - Document in gc_state() that the collector is process-wide: gc.disable(), thresholds, gc.garbage and gc.get_objects() observe process-wide state, and a per-interpreter collector additionally needs untrack_object (called from default_dealloc with no VM in scope) routed to the owning interpreter. Tests: stop_the_world_parks_threads_of_another_interpreter asserts a thread entered in one interpreter parks another interpreter's threads (it fails without this change), plus a collect-while-another-interpreter-churns test. Assisted-by: Claude Code:claude-opus-4-8 * vm: scope the interpreter registry per thread without threading The registry held `rc::Weak` in a process-global `OnceLock` and covered the resulting `!Send`/`!Sync` with an `unsafe impl` justifying it as "non-threading builds are single-threaded". That is not this codebase's model: `static_cell!` is thread-local without the `threading` feature precisely so each OS thread can own its own `Context::genesis()` and `GcState`, so two threads could reach the same `Rc` counts through the registry. Use `static_cell!` for the registry as well, matching `gc_state()`, and drop the `unsafe impl`. Ids are consequently unique per registry rather than per process in non-threading builds, which is documented on `alloc_interpreter_id`. Move the recorded main interpreter id into the registry so it follows the same scoping instead of living in a separate global `OnceLock`. Also make `CollectStopTheWorld::new` accumulate into a live guard: it built a bare `Vec` and only moved it into the restarting `Drop` type after stopping every interpreter, so an unwind partway through the loop left the already stopped interpreters parked forever with their exclusion held. Assisted-by: Claude Code:claude-opus-4-8 * vm: keep the interpreter registry usable across bootstrap, drop and fork Three gaps where the registry did not describe the interpreters that actually exist, each of which hides an interpreter from the collector's stop-the-world. Register before `initialize()`. Registration ran as the last step of `initialize_vm`, so the whole bootstrap — which executes Python bytecode and allocates GC-tracked objects — was invisible to `live_interpreter_states()`. It still cannot run any earlier than this: the init hooks take `PyRc::get_mut` on the state, which fails as soon as the registry holds a weak reference to it. Stop unregistering in `Interpreter::drop`. The handle does not decide the interpreter's lifetime — every `ThreadedVirtualMachine` from `new_thread()` holds its own `PyRc` — so an interpreter with running workers disappeared from the registry while its threads kept mutating the object graph. The entries are weak, so lifetime is already correct without the removal; dead entries are now reaped when registering instead. Interpreters are consequently released rather than unregistered at a fixed point, so the two tests asserting disappearance now wait for it: a collection in progress legitimately holds a reference to every live interpreter. Repair other interpreters after fork. `py_os_after_fork_child` only fixed the forking interpreter, leaving every other one with slots for threads that did not survive (still ATTACHED if they were running bytecode) plus locks and stop-the-world flags held by them. Since a collection stops all interpreters, the child's first collection would wait for threads that no longer exist. Reset their locks, stop-the-world state and thread tables, drop this thread's cached slots for them, and reinit the registry's own locks first, since enumerating interpreters now takes them. Tests: test_gc, test_threading and test_fork1 pass, as do the vm tests in both the threading and default configurations. Assisted-by: Claude Code:claude-opus-4-8 * vm: attach and detach thread slots when switching interpreters `enter_vm` decided whether to attach from `was_outermost` (an empty VM_STACK), which held while a thread could only ever be in one interpreter. With a slot per (thread, interpreter) pair, entering interpreter B from a thread already inside interpreter A's section switched CURRENT_THREAD_SLOT to B's slot but attached nothing: the thread then ran B's bytecode with B's slot DETACHED while A's slot stayed ATTACHED. A collector stopping B force-parks the DETACHED slot and concludes B is stopped, and then walks the object graph this thread is still mutating. Pair the attach/detach with the slot switch instead (≈ `_PyThreadState_Swap`): `begin_interpreter_section` detaches the enclosing interpreter's slot, makes the target slot current and attaches it, and `end_interpreter_section` undoes that and re-attaches the enclosing interpreter. Both live in `set_current_vm`, which every path making a VM current already goes through, so `enter_vm` and `VmBootstrapGuard` no longer track outermost-ness themselves. `nested_enter_of_subinterpreter_is_stoppable` covers this: it runs a subinterpreter nested inside the parent's section and asserts the sub's threads park when the sub's world is stopped. It fails with the previous attach-at-outermost-only behavior. Assisted-by: Claude Code:claude-opus-4-8 * vm: list only the current interpreter's subclasses Interpreters share the context, so `class Foo(int)` in one of them pushes onto the same `int.subclasses` every other one reads, and `int.__subclasses__()` returned types no other interpreter can reach. Record the creating interpreter on `HeapTypeExt` and filter `__subclasses__` by it, the way `lookup_tp_subclasses` reads `tp_subclasses` out of per-interpreter state for static builtin types. Types built before any interpreter exists — the ones the shared context creates, including the exception hierarchy — carry no id and stay visible to every interpreter, which is what `_PyStaticType_InitBuiltin` produces by registering the builtin subclass links once per interpreter. The other walks over `subclasses` (version-tag invalidation, abc flag propagation, mro updates, slot propagation) are left as they are: each starts from a type being mutated, so from a heap type, whose subclasses all live in the interpreter that created it. `subinterpreter_subclasses_are_scoped_to_their_interpreter` covers this and fails without the filter. Assisted-by: Claude Code:claude-opus-5 * gc: give each interpreter its own collector state The generation lists stay process-wide, because an object is untracked from `default_dealloc`, where no interpreter is in scope to route to. What changes is that a collection no longer acts on every interpreter's objects, and the gc module no longer reports one interpreter's state to another. `track_object` stamps the running interpreter into a new `gc_owner` word on the object header, and a collection takes as candidates only the objects carrying its own tag plus the ones carrying none. `gc_owner` fits in the padding the header's alignment already forces, so objects do not grow; an assertion on the header size keeps it that way. Objects allocated with no interpreter running — everything the shared context builds — carry no owner and stay candidates for every interpreter, which is where they were before. Objects that outlive the interpreter that tracked them are adopted the same way by the next full collection, rather than being left to a collector that will never come. `enabled`, the thresholds, the debug flags, the statistics, `gc.garbage` and `gc.callbacks` move onto `PyGlobalState` — the last two off the shared `Context` — so `gc.disable()`, `gc.set_threshold()`, `gc.get_stats()`, `gc.get_objects()` and `gc.garbage` describe the interpreter that asks. The occupancy counts behind `gc.get_count()` and `gc.get_freeze_count()` stay process-wide: they measure how full the shared lists are. Their decrements are saturating now, since a collection zeroes the generations it emptied while another interpreter's objects are still sitting in them. Stop-the-world still stops every interpreter. Unowned objects are candidates and any interpreter can incref one, so the refcounts a collection reads are only stable while all of them are parked. This also fixes a deadlock it exposed: `CollectStopTheWorld` dropped its references to the stopped interpreters while the collection still held the generation read locks, so releasing the last reference to one — which frees its objects, and so untracks them — waited for a write lock behind that read lock. The references are now held until the guard itself drops. `collections_only_reach_the_collecting_interpreter` and `get_objects_only_reports_the_calling_interpreter` cover this and both fail without the owner check. Assisted-by: Claude Code:claude-opus-5 * _queue, _thread: detach before taking locks held across waits `_queue.Semaphore` holds its mutex across the `allow_threads` condvar wait, and `join_internal` holds a thread handle's completion mutex the same way. Stop-the-world can stop a thread while it holds either one. The remaining acquisitions ran attached, so a thread blocking on such a mutex had no safepoint left to reach: the stop never completed, and the holder was never resumed to release it. Route those acquisitions through helpers that detach first. The fork-child reinit paths keep their direct locks. Assisted-by: Claude * _io, _winapi: detach on the remaining stopped-holdable lock takes `TextIOWrapper.__repr__` took `data` directly while every other method takes it through `lock_opt`, which detaches. `Overlapped` holds `inner` across the `allow_threads` in `GetOverlappedResult`, and all four of its takes were direct. A thread stopped by stop-the-world can be holding either mutex, so taking one while attached left the blocked thread with no safepoint to reach. Assisted-by: Claude * gc: size the interpreter owner tag to the header padding `gc_owner` was a u32. With 4-byte pointers its alignment pushed it out of the padding that follows the gc bits and generation, growing every object by a word and tripping the `SIZEOF_PYOBJECT_HEAD` assertion on 32-bit targets. Introduce `GcOwner = u16`, which the `repr(C)` layout places at offset 10 on 32-bit and 18 on 64-bit, leaving the header at 6 words on both. Tags now run out after 65535 interpreters; `alloc_owner` already falls back to `GC_NO_OWNER`, so an interpreter past that collects as it did before tagging. Also widen three test deadlines that measure liveness, not speed. Assisted-by: Claude * vm: reuse cleared frame blocks and shorten interpreter hot paths - `datastack` remembers the most recently popped frame block. `push_frame` reports an exact LIFO reuse, and `setup_datastack_frame` then skips zero-filling localsplus. - Small-int loads push the context's cached int as a borrowed stack reference instead of taking a new one. - Calls to jitted functions go straight to `execute_call_vectorcall`. - Binary-op specialization reads ints through the new `PyInt::try_to_i64_fast` instead of the generic primitive conversion, and `try_to_bool` moves its non-bool path into a `#[cold]` helper. - Dict caches read an entry through a keys-version stamp (`get_index_if_keys_version`) rather than an entry-index hint. - Frame publishing caches a pointer to `ThreadSlot::top_iframe` in a thread-local `Cell` instead of borrowing `CURRENT_THREAD_SLOT`. * gc, vm: address review notes on owner tags and interpreter docs Look up retired owner tags with a sorted binary search instead of a linear scan, which every scanned object paid for once per dropped interpreter. Drop the claim that clearing a tag frees it for reuse; `alloc_owner` only ever hands out new tags. Also correct the tag space it mentions, which is 16-bit since the tag was sized to the header padding. Document `is_main` as "top-level interpreter" rather than "the process main": every top-level interpreter sets it, and only the first registered one becomes the main `main_interpreter_id` reports. Assert membership rather than an exact owned-interpreter count delta; the owned table is process-global and other tests store into it in parallel. Assisted-by: Claude * vm: gate interpreter registration on stop-the-world admission A collection snapshots the registry, stops the interpreters it found, and then reads tracked objects with their threads parked. An interpreter that registered after the snapshot was taken was absent from it, so nothing stopped it and its bootstrap ran Python — allocating and mutating the shared generation lists — underneath that scan. Registration and the stop now share a process-global gate: the collection holds it from before the snapshot until the restart, and registration takes it around the registry insert. The insert runs detached, since a thread that waited for the gate, or re-attached while holding it, would leave the stop it waits for no safepoint to complete at. Assisted-by: Claude * gc: keep the tracking counters off the barrier path Every tracked allocation and every free went through sequentially consistent counter updates, and each free through a `fetch_update` CAS loop. The counters drive only the gen0 threshold and `gc.get_count()`, and the generation locks — not the counters — order the list changes they describe, so they are relaxed now and the decrement is a load plus a conditional `fetch_sub`. `is_enabled` and `threshold`, both read once per allocation, are relaxed for the same reason: an allocation racing `gc.disable()` may use either value. Drop `alloc_count`, which nothing has ever read. Assisted-by: Claude * dict: settle lookups and iteration steps under one read guard A lookup probed under a read guard, dropped it with the matched entry in hand, and then took the lock again to re-find the entry. `lookup_extract` reads the entry while the probe still holds the guard when key identity settles the match, which is the case that cannot run Python. Dict and set iterators took one lock to compare the size and another to read the entry, then cloned both the key and the value even though a keys or values view keeps only one of them. `next_entry_checked` does the size check and the read under one guard, and clones through a per-view projection. A store that missed its inline-cache hint re-probed the dict afterwards only to recover the entry index the store had just computed; `unchecked_push` reports that index instead. Assisted-by: Claude * vm: build a call's argument vector once The specialized CALL handlers collected the positional arguments into one vector and then copied them into a second one to put `self` in front, so every specialized builtin, method-descriptor, class and non-Python call paid two allocations, a copy and two frees. The arguments are already laid out on the value stack in vectorcall order, so `take_call_args` fills one vector by index — the shape `execute_call_vectorcall` already used. `vectorcall_native_function` and the keyword path of `vectorcall_function` then cloned that vector again to build `FuncArgs`; both now move it in through `from_vectorcall_owned`, as the other vectorcall slots do. Assisted-by: Claude * vm: reach an instance dict without cloning it The specialized attribute instructions cloned the instance dict — a rwlock round-trip plus a refcount round-trip — for two things that only look at it: `LoadAttrMethodLazyDict` asking whether the dict exists, and the keys-version stamp check that is the whole of `shadowing_instance_attr`'s fast path. Both now read it borrowed, through `has_instance_dict` / `with_instance_dict`. `generic_getattr_opt` probed the dict with the name's `&Wtf8`, which hashes the name on every lookup and can never match a key by pointer. Passing the `Py` uses the string's cached hash and the interned-key identity check, and drops an allocation when a stored key is not an exact `str`. Assisted-by: Claude * vm: shorten the per-instruction safepoint and the call preamble The dispatch loop asks once per instruction whether stop-the-world wants this thread, and that read went through `CURRENT_THREAD_SLOT` — a `RefCell` borrow, so two stores to thread-local memory around an `Option` test. Cache the `stop_requested` pointer in a plain `Cell` at the same three places the frame pointers are cached, and the safepoint becomes one relaxed load. `lasti` is advanced from the index the loop just read, rather than reloaded to increment it, and `Resume` reads `quickened` before swapping it, so a call to an already-quickened code object costs a load instead of an atomic read-modify-write. An exact-args vectorcall to a Python function built a heap `FrameObject` where the equivalent `invoke` path uses a data stack frame; it now does the same when tracing is off. Assisted-by: Claude * vm: give KwArgs a zero-sized hasher `KwArgs::default()` is built for every call, keyword-less ones included, and it seeded a `RandomState` each time — a thread-local read and 16 bytes in every `FuncArgs`. Keyword names come from the program text, so the map now uses `BuildHasherDefault`, whose `Default` is a zero-init. Assisted-by: Claude * vm: promote borrowed stack refs before a yield `yield ` tripped the "borrowed refs on stack at yield point" assertion: `LoadSmallInt` pushes a borrowed ref, and a yield saves the stack with the frame, which is exactly what that assertion forbids. Promote the stack first, so a suspended frame owns everything it holds. Assisted-by: Claude * vm: run the exact-args vectorcall on a heap frame again `vectorcall_function` took the data stack path for an exact-args call whenever tracing was off. A data stack frame that materializes — one traceback entry is enough — copies its localsplus into the `FrameObject`, so every value it holds gains a reference for as long as the frame runs, and a tail call holds the callee's function object until the caller's next call or return. Both are reachable without this hunk, through the `CallPyExactArgs` specialization, but routing the vectorcall entry point through the data stack made them apply from a call site's first execution: test_enumerate, test_memoryio, test_sys, test_tempfile and test_traceback all read a refcount or a finalization that moved. Assisted-by: Claude * vm: release a tail call's function at the callee's return The trampoline moved the callee's function object into the caller's `SuspendedFrame` and dropped it only once the caller had run again, so a function entered through a tail call kept one reference past its return: `sys.getrefcount` on it read one high for the caller's next stretch of bytecode, and anything the function reached stayed alive that much longer. The callee's frame is released before the `ReturnValue` or `Unwind` action is formed, and a materialized frame object holds its own references, so the owner is dropped where the suspended caller is popped instead. Assisted-by: Claude * vm: stop copying a running frame's locals when it materializes `materialize_slow` cloned the source frame's fast locals into the new `FrameObject`, so every value in a data stack frame gained a reference lasting as long as that frame object: one traceback entry through a frame kept all of its locals alive, and `sys.getrefcount` read one high for each of them. Nothing read that copy while the frame ran. `find_live_source_iframe` already routes reads to the live frame, and `exit_iframe` fills the slots from it at return, so the copy now starts empty as `materialize_slow_chain`'s does, and `snapshot_to_heap` goes with its last caller. Two readers had to be brought to the live frame the same way. `has_active_hidden_locals` read the frame object's own slots, which let `locals()` in a class body write an inlined comprehension's hidden variable into the class namespace; it now shares `live_fastlocals` with the other two. Another thread's frame has no live frame to reach from here, so the six cross-thread sites fill the copy under stop-the-world through `materialize_with_locals`. Assisted-by: Claude * vm: key data stack frame reuse on the exact frame size push_frame and pop_frame compared 16-byte-aligned sizes, so two frames whose sizes differ by less than the alignment matched as a reuse. The caller reads that as "every slot was cleared by the previous frame", but the larger frame's tail slots were never touched by it. Assisted-by: Claude * vm: store the frame object payload address in the cross-thread slot set_current_frame and reinit_frame_slot_after_fork wrote the address of the Py, while the cross-thread readers hand that pointer to Py::from_payload_ptr, which subtracts the object header from it. Both writers now store the payload address. Assisted-by: Claude * vm: copy a foreign thread's frames instead of linking them sys._current_frames(), _thread._current_exceptions() and the cross-thread f_back path handed back frame objects still linked to the running frames. The owning thread wrote its locals into those same buffers at exit_iframe while the reader was cloning out of them. materialize_detached and materialize_detached_chain copy the values under stop-the-world and leave nothing for the owner to write into; materialize_with_locals is gone. FrameColdData gains attached_tid, the thread still running the frame a frame object was materialized from, which exit_iframe clears once it has written the values in. check_locals_access now rejects a read from any other thread while it is set; the FrameOwner test could not, because materialize_slow forces FrameObject ownership. Also removes with_iframe, which had no callers. Assisted-by: Claude * vm: resolve the two rustdoc links this branch added MAIN_INTERPRETER_ID is not in scope in interpreter.rs, and new_thread only exists in a threading build, so neither link resolved. Assisted-by: Claude --- crates/capi/src/objimpl.rs | 28 +- crates/capi/src/pystate.rs | 4 +- crates/stdlib/src/_queue.rs | 33 +- crates/stdlib/src/faulthandler.rs | 4 +- crates/vm/src/builtins/bool.rs | 7 + crates/vm/src/builtins/builtin_func.rs | 4 +- crates/vm/src/builtins/dict.rs | 91 +- crates/vm/src/builtins/frame.rs | 27 +- crates/vm/src/builtins/function.rs | 39 +- crates/vm/src/builtins/int.rs | 16 + crates/vm/src/builtins/set.rs | 14 +- crates/vm/src/builtins/type.rs | 37 +- crates/vm/src/datastack.rs | 48 +- crates/vm/src/dict_inner.rs | 178 +++- crates/vm/src/frame.rs | 628 ++++++----- crates/vm/src/function/argument.rs | 19 +- crates/vm/src/function/mod.rs | 4 +- crates/vm/src/gc_state.rs | 650 ++++++++---- crates/vm/src/lib.rs | 6 +- crates/vm/src/object/core.rs | 78 +- crates/vm/src/object/mod.rs | 2 +- crates/vm/src/protocol/object.rs | 5 +- crates/vm/src/stdlib/_ast/python.rs | 4 +- crates/vm/src/stdlib/_ctypes/structure.rs | 3 +- crates/vm/src/stdlib/_ctypes/union.rs | 3 +- crates/vm/src/stdlib/_functools.rs | 3 +- crates/vm/src/stdlib/_io.rs | 5 +- crates/vm/src/stdlib/_signal.rs | 4 +- crates/vm/src/stdlib/_thread.rs | 82 +- crates/vm/src/stdlib/_winapi.rs | 21 +- crates/vm/src/stdlib/gc.rs | 60 +- crates/vm/src/stdlib/posix.rs | 69 +- crates/vm/src/stdlib/sys.rs | 3 +- crates/vm/src/stdlib/sys/monitoring.rs | 2 +- crates/vm/src/vm/context.rs | 16 +- crates/vm/src/vm/interpreter.rs | 1165 +++++++++++++++++++-- crates/vm/src/vm/mod.rs | 281 ++--- crates/vm/src/vm/runtime.rs | 326 ++++++ crates/vm/src/vm/setting.rs | 2 + crates/vm/src/vm/thread.rs | 374 +++++-- 40 files changed, 3357 insertions(+), 988 deletions(-) create mode 100644 crates/vm/src/vm/runtime.rs diff --git a/crates/capi/src/objimpl.rs b/crates/capi/src/objimpl.rs index 99b0be7cc..aa74bb693 100644 --- a/crates/capi/src/objimpl.rs +++ b/crates/capi/src/objimpl.rs @@ -9,7 +9,7 @@ pub unsafe extern "C" fn PyObject_GC_Track(op: *mut PyObject) { with_vm(|_vm| { let obj = unsafe { &*op }; if !obj.is_gc_tracked() { - unsafe { gc_state::gc_state().track_object(obj.into()) }; + unsafe { gc_state::gc_state().track_object(obj.into(), gc_state::current_owner()) }; } }) } @@ -36,29 +36,33 @@ pub unsafe extern "C" fn PyObject_GC_IsFinalized(op: *mut PyObject) -> c_int { #[unsafe(no_mangle)] pub extern "C" fn PyGC_Collect() -> isize { - let result = gc_state::gc_state().collect(2); - (result.collected + result.uncollectable) as isize + with_vm(|vm| { + let result = vm.state.gc.collect(2); + (result.collected + result.uncollectable) as isize + }) } #[unsafe(no_mangle)] pub extern "C" fn PyGC_Enable() -> c_int { - let gc = gc_state::gc_state(); - let was_enabled = gc.is_enabled(); - gc.enable(); - was_enabled.into() + with_vm(|vm| { + let was_enabled: c_int = vm.state.gc.is_enabled().into(); + vm.state.gc.enable(); + was_enabled + }) } #[unsafe(no_mangle)] pub extern "C" fn PyGC_Disable() -> c_int { - let gc = gc_state::gc_state(); - let was_enabled = gc.is_enabled(); - gc.disable(); - was_enabled.into() + with_vm(|vm| { + let was_enabled: c_int = vm.state.gc.is_enabled().into(); + vm.state.gc.disable(); + was_enabled + }) } #[unsafe(no_mangle)] pub extern "C" fn PyGC_IsEnabled() -> c_int { - gc_state::gc_state().is_enabled().into() + with_vm(|vm| -> c_int { vm.state.gc.is_enabled().into() }) } #[unsafe(no_mangle)] diff --git a/crates/capi/src/pystate.rs b/crates/capi/src/pystate.rs index 865a11644..cec3bc240 100644 --- a/crates/capi/src/pystate.rs +++ b/crates/capi/src/pystate.rs @@ -109,8 +109,8 @@ mod tests { current_vm_is_set(), "This thread did not have a vm attached" ); - vm.state.stop_the_world.stop_the_world(vm); - vm.state.stop_the_world.start_the_world(vm); + vm.state.stop_the_world.stop_the_world(&vm.state); + vm.state.stop_the_world.start_the_world(&vm.state); }); }); }); diff --git a/crates/stdlib/src/_queue.rs b/crates/stdlib/src/_queue.rs index 1c8a4b0b2..96e77a34a 100644 --- a/crates/stdlib/src/_queue.rs +++ b/crates/stdlib/src/_queue.rs @@ -74,9 +74,20 @@ mod _queue { } } - fn release(&self) { + /// Take `mutex`, detaching first so that blocking on it cannot stall a + /// stop-the-world request. + /// + /// A waiter holds this mutex across its `allow_threads` wait, so it can + /// still hold it when it is stopped. An attached thread blocking on it + /// would then never reach a safepoint, the stop would never complete, + /// and the holder would never be resumed to release it. + fn lock_count(&self, vm: &VirtualMachine) -> parking_lot::MutexGuard<'_, usize> { + vm.allow_threads(|| self.mutex.lock()) + } + + fn release(&self, vm: &VirtualMachine) { { - let mut count = self.mutex.lock(); + let mut count = self.lock_count(vm); *count += 1; } // lock dropped. now we can notify a waiting thread @@ -95,7 +106,7 @@ mod _queue { // Guard must be dropped before check_signals() below, since a // signal handler may call back into this same queue. { - let mut count = self.mutex.lock(); + let mut count = self.lock_count(vm); if *count > 0 { *count -= 1; @@ -151,11 +162,15 @@ mod _queue { } impl PySimpleQueue { - fn push(&self, item: PyObjectRef) { + #[cfg_attr( + not(feature = "threading"), + expect(unused_variables, reason = "only the semaphore needs the vm") + )] + fn push(&self, item: PyObjectRef, vm: &VirtualMachine) { self.buf.lock().push_back(item); #[cfg(feature = "threading")] - self.sem.release(); + self.sem.release(vm); } /// Returns a strong reference from the head of the buffer. @@ -221,14 +236,14 @@ mod _queue { } #[pymethod] - fn put(&self, args: PutArgs) { + fn put(&self, args: PutArgs, vm: &VirtualMachine) { let PutArgs { item, .. } = args; - self.push(item); + self.push(item, vm); } #[pymethod] - fn put_nowait(&self, item: PyObjectRef) { - self.push(item); + fn put_nowait(&self, item: PyObjectRef, vm: &VirtualMachine) { + self.push(item, vm); } #[pymethod] diff --git a/crates/stdlib/src/faulthandler.rs b/crates/stdlib/src/faulthandler.rs index 6edd023f1..3fbb8391b 100644 --- a/crates/stdlib/src/faulthandler.rs +++ b/crates/stdlib/src/faulthandler.rs @@ -260,8 +260,8 @@ mod decl { use core::sync::atomic::Ordering; let current_tid = rustpython_vm::stdlib::_thread::get_ident(); { - vm.state.stop_the_world.stop_the_world(vm); - scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } + vm.state.stop_the_world.stop_the_world(&vm.state); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); } let registry = vm.state.thread_frames.lock(); #[expect( clippy::iter_over_hash_type, diff --git a/crates/vm/src/builtins/bool.rs b/crates/vm/src/builtins/bool.rs index 4bb980d71..1cfa8cc27 100644 --- a/crates/vm/src/builtins/bool.rs +++ b/crates/vm/src/builtins/bool.rs @@ -34,6 +34,7 @@ impl<'a> TryFromBorrowedObject<'a> for bool { impl PyObjectRef { /// Convert Python bool into Rust bool. + #[inline(always)] pub fn try_to_bool(self, vm: &VirtualMachine) -> PyResult { if self.is(&vm.ctx.true_value) { return Ok(true); @@ -41,6 +42,12 @@ impl PyObjectRef { return Ok(false); } + self.try_to_bool_slow(vm) + } + + #[cold] + #[inline(never)] + fn try_to_bool_slow(self, vm: &VirtualMachine) -> PyResult { let slots = &self.class().slots; // 1. Try nb_bool slot first diff --git a/crates/vm/src/builtins/builtin_func.rs b/crates/vm/src/builtins/builtin_func.rs index eabe8d4ea..b34447b79 100644 --- a/crates/vm/src/builtins/builtin_func.rs +++ b/crates/vm/src/builtins/builtin_func.rs @@ -247,9 +247,9 @@ fn vectorcall_native_function( let mut all_args = Vec::with_capacity(args.len() + 1); all_args.push(self_obj); all_args.extend(args); - FuncArgs::from_vectorcall(&all_args, nargs + 1, kwnames) + FuncArgs::from_vectorcall_owned(all_args, nargs + 1, kwnames) } else { - FuncArgs::from_vectorcall(&args, nargs, kwnames) + FuncArgs::from_vectorcall_owned(args, nargs, kwnames) }; (zelf.value.func)(vm, func_args) diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index 1a380d74d..d2b9dea31 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -114,11 +114,6 @@ impl PyDict { &self.entries } - /// Monotonically increasing version for mutation tracking. - pub(crate) fn version(&self) -> u64 { - self.entries.version() - } - /// Returns all keys as a Vec, atomically under a single read lock. /// Thread-safe: prevents "dictionary changed size during iteration" errors. pub fn keys_vec(&self) -> Vec { @@ -821,18 +816,15 @@ impl Py { } } - /// Fast lookup using a cached entry index hint. - pub(crate) fn get_item_opt_hint( + /// Read a cached exact-dict entry after validating its key-layout stamp. + #[inline] + pub(crate) fn get_item_by_index_and_keys_version( &self, - key: &K, - hint: u16, - vm: &VirtualMachine, - ) -> PyResult> { - if self.exact_dict(vm) { - self.entries.get_hint(vm, key, usize::from(hint)) - } else { - self.get_item_opt(key, vm) - } + version: u16, + index: u16, + ) -> Option { + self.entries + .get_index_if_keys_version(u32::from(version), usize::from(index)) } /// Lookup trying a cached entry index hint first. @@ -1098,6 +1090,7 @@ macro_rules! dict_view { $class_name: literal, $iter_class_name: literal, $reverse_iter_class_name: literal, + $project_fn: expr, $result_fn: expr ) => { #[pyclass(module = false, name = $class_name)] @@ -1120,7 +1113,7 @@ macro_rules! dict_view { } fn item(vm: &VirtualMachine, key: PyObjectRef, value: PyObjectRef) -> PyObjectRef { - $result_fn(vm, key, value) + $result_fn(vm, $project_fn(&key, &value)) } fn __reversed__(&self) -> Self::ReverseIter { @@ -1206,7 +1199,7 @@ macro_rules! dict_view { while let Some((next_position, key, value)) = dict.entries.next_entry(position) { - entries.push(($result_fn)(vm, key, value)); + entries.push(($result_fn)(vm, ($project_fn)(&key, &value))); position = next_position; } entries @@ -1223,18 +1216,22 @@ macro_rules! dict_view { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { let mut internal = zelf.internal.lock(); let next = if let IterStatus::Active(dict) = &internal.status { - if dict.entries.has_changed_size(&zelf.size) { - internal.status = IterStatus::Exhausted; - return Err( - vm.new_runtime_error("dictionary changed size during iteration") - ); - } - match dict.entries.next_entry(internal.position) { - Some((position, key, value)) => { - internal.position = position; - PyIterReturn::Return(($result_fn)(vm, key, value)) + match dict.entries.next_entry_checked( + internal.position, + &zelf.size, + $project_fn, + ) { + Err(dict_inner::DictChanged) => { + internal.status = IterStatus::Exhausted; + return Err( + vm.new_runtime_error("dictionary changed size during iteration") + ); } - None => { + Ok(Some((position, item))) => { + internal.position = position; + PyIterReturn::Return(($result_fn)(vm, item)) + } + Ok(None) => { internal.status = IterStatus::Exhausted; PyIterReturn::StopIteration(None) } @@ -1282,7 +1279,7 @@ macro_rules! dict_view { while let Some((found_index, key, value)) = dict.entries.prev_entry(position) { - entries.push(($result_fn)(vm, key, value)); + entries.push(($result_fn)(vm, ($project_fn)(&key, &value))); if found_index == 0 { break; } @@ -1309,22 +1306,26 @@ macro_rules! dict_view { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { let mut internal = zelf.internal.lock(); let next = if let IterStatus::Active(dict) = &internal.status { - if dict.entries.has_changed_size(&zelf.size) { - internal.status = IterStatus::Exhausted; - return Err( - vm.new_runtime_error("dictionary changed size during iteration") - ); - } - match dict.entries.prev_entry(internal.position) { - Some((found_index, key, value)) => { + match dict.entries.prev_entry_checked( + internal.position, + &zelf.size, + $project_fn, + ) { + Err(dict_inner::DictChanged) => { + internal.status = IterStatus::Exhausted; + return Err( + vm.new_runtime_error("dictionary changed size during iteration") + ); + } + Ok(Some((found_index, item))) => { if found_index == 0 { internal.status = IterStatus::Exhausted; } else { internal.position = found_index - 1; } - PyIterReturn::Return(($result_fn)(vm, key, value)) + PyIterReturn::Return(($result_fn)(vm, item)) } - None => { + Ok(None) => { internal.status = IterStatus::Exhausted; PyIterReturn::StopIteration(None) } @@ -1348,7 +1349,8 @@ dict_view! { "dict_keys", "dict_keyiterator", "dict_reversekeyiterator", - |_vm: &VirtualMachine, key: PyObjectRef, _value: PyObjectRef| key + |key: &PyObjectRef, _value: &PyObjectRef| key.clone(), + |_vm: &VirtualMachine, key: PyObjectRef| key } dict_view! { @@ -1361,7 +1363,8 @@ dict_view! { "dict_values", "dict_valueiterator", "dict_reversevalueiterator", - |_vm: &VirtualMachine, _key: PyObjectRef, value: PyObjectRef| value + |_key: &PyObjectRef, value: &PyObjectRef| value.clone(), + |_vm: &VirtualMachine, value: PyObjectRef| value } dict_view! { @@ -1374,7 +1377,9 @@ dict_view! { "dict_items", "dict_itemiterator", "dict_reverseitemiterator", - |vm: &VirtualMachine, key: PyObjectRef, value: PyObjectRef| + |key: &PyObjectRef, value: &PyObjectRef| (key.clone(), value.clone()), + // Builds a tuple, so it runs after the dict's read guard is released. + |vm: &VirtualMachine, (key, value): (PyObjectRef, PyObjectRef)| vm.new_tuple((key, value)).into() } diff --git a/crates/vm/src/builtins/frame.rs b/crates/vm/src/builtins/frame.rs index 94ec827b7..e4b3aa2b1 100644 --- a/crates/vm/src/builtins/frame.rs +++ b/crates/vm/src/builtins/frame.rs @@ -512,7 +512,7 @@ impl FrameObject { let live = self.find_live_source_iframe(); if !live.is_null() { // Read live prev_line. Use read_volatile to bypass LLVM noalias - // on the &mut InterpreterFrame borrow in with_iframe. + // on the &mut InterpreterFrame borrow held by the running frame. let prev = unsafe { let field_ptr = core::ptr::addr_of!((*live).prev_line); core::ptr::read_volatile(field_ptr as *const u32) @@ -897,32 +897,19 @@ impl Py { { // Enter STW before dereferencing `prev` — the owning thread may // return and free the stack-allocated iframe at any time. - vm.state.stop_the_world.stop_the_world(vm); - scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } + vm.state.stop_the_world.stop_the_world(&vm.state); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); } let prev_ref = unsafe { &*prev }; // Fast path: already materialized. if let Some(fo) = prev_ref.frame_obj() { fo.mark_escaped(); return Some(fo.to_owned()); } - // Slow path: materialize the entire chain and link retained_back. - let mut cur = prev; - let mut child_fo: Option> = None; - while !cur.is_null() { - let iframe = unsafe { &*cur }; - let fo = iframe.materialize(vm).to_owned(); - if let Some(child) = child_fo.take() { - let mut guard = child.iframe().cold().retained_back.lock(); - if guard.is_none() { - *guard = Some(fo.clone()); - } - } - child_fo = Some(fo); - cur = iframe.previous(); - } - let fo = prev_ref.materialize(vm); + // Slow path: copy the whole chain, linked through retained_back. + // SAFETY: the world is stopped, so the owning thread is parked. + let fo = unsafe { prev_ref.materialize_detached_chain(vm) }; fo.mark_escaped(); - return Some(fo.to_owned()); + return Some(fo); } #[allow(unreachable_code)] diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index 90315bcb1..a5342d1df 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -550,6 +550,20 @@ impl Py { self.code.flags.contains(bytecode::CodeFlags::OPTIMIZED) } + /// Whether this function currently has native JIT code. Adaptive Python + /// call specializations must yield to that entry point. + #[inline] + pub(crate) fn is_jitted(&self) -> bool { + #[cfg(feature = "jit")] + { + self.jitted_code.lock().is_some() + } + #[cfg(not(feature = "jit"))] + { + false + } + } + pub fn invoke_with_locals( &self, func_args: FuncArgs, @@ -643,8 +657,8 @@ impl Py { .and_then(|()| vm.run_frame_fast(iframe)); // Release data stack memory — must happen on both success and error. unsafe { - if let Some(base) = iframe.release_datastack_frame() { - vm.datastack_pop(base); + if let Some((base, size)) = iframe.release_datastack_frame() { + vm.datastack_pop_frame(base, size); } } result @@ -669,7 +683,10 @@ impl Py { ); // SAFETY: the frame is alive (held by `frame`) and untracked. unsafe { - crate::gc_state::gc_state().track_object(core::ptr::NonNull::from(frame.as_object())); + crate::gc_state::gc_state().track_object( + core::ptr::NonNull::from(frame.as_object()), + crate::gc_state::current_owner(), + ); } frame.set_generator(&obj); obj @@ -820,8 +837,8 @@ impl Py { let result = vm.run_frame_fast(iframe); unsafe { - if let Some(base) = iframe.release_datastack_frame() { - vm.datastack_pop(base); + if let Some((base, size)) = iframe.release_datastack_frame() { + vm.datastack_pop_frame(base, size); } } result @@ -1616,6 +1633,16 @@ pub(crate) fn vectorcall_function( let code: &Py = &zelf.code; let has_kwargs = kwnames.is_some_and(|kw| !kw.is_empty()); + if zelf.is_jitted() { + let func_args = if has_kwargs { + FuncArgs::from_vectorcall_owned(args, nargs, kwnames) + } else { + args.truncate(nargs); + FuncArgs::from(args) + }; + return zelf.invoke(func_args, vm); + } + let is_simple = !has_kwargs && code.flags.contains(bytecode::CodeFlags::OPTIMIZED) && !code.flags.contains(bytecode::CodeFlags::VARARGS) @@ -1640,7 +1667,7 @@ pub(crate) fn vectorcall_function( // SLOW PATH: construct FuncArgs from owned Vec and delegate to invoke() let func_args = if has_kwargs { - FuncArgs::from_vectorcall(&args, nargs, kwnames) + FuncArgs::from_vectorcall_owned(args, nargs, kwnames) } else { args.truncate(nargs); FuncArgs::from(args) diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index c12bf2c72..134617ab7 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -305,6 +305,22 @@ impl PyInt { &self.value } + /// Extract the inline magnitude without the generic primitive-conversion path. + #[inline(always)] + pub(crate) fn try_to_i64_fast(&self) -> Option { + let bits = self.value.bits(); + if bits > i64::BITS as u64 { + return None; + } + let magnitude = self.value.iter_u64_digits().next().unwrap_or(0); + let signed_magnitude = i64::try_from(magnitude).ok(); + match self.value.sign() { + Sign::Minus if magnitude == 1u64 << 63 => Some(i64::MIN), + Sign::Minus => signed_magnitude.map(|value| -value), + Sign::NoSign | Sign::Plus => signed_magnitude, + } + } + /// Fast decimal string conversion, using i64 path when possible. #[inline] #[must_use] diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index 860f86f43..d737612b1 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -1533,16 +1533,16 @@ impl IterNext for PySetIterator { fn next(zelf: &crate::Py, vm: &VirtualMachine) -> PyResult { let mut internal = zelf.internal.lock(); let next = if let IterStatus::Active(dict) = &internal.status { - if dict.has_changed_size(&zelf.size) { - internal.status = IterStatus::Exhausted; - return Err(vm.new_runtime_error("set changed size during iteration")); - } - match dict.next_entry(internal.position) { - Some((position, key, _)) => { + match dict.next_entry_checked(internal.position, &zelf.size, |key, ()| key.clone()) { + Err(crate::dict_inner::DictChanged) => { + internal.status = IterStatus::Exhausted; + return Err(vm.new_runtime_error("set changed size during iteration")); + } + Ok(Some((position, key))) => { internal.position = position; PyIterReturn::Return(key) } - None => { + Ok(None) => { internal.status = IterStatus::Exhausted; PyIterReturn::StopIteration(None) } diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 177627075..5a7169983 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -294,6 +294,16 @@ pub struct HeapTypeExt { pub slots: Option>>, pub type_data: PyRwLock>, pub specialization_cache: TypeSpecializationCache, + /// The interpreter this type was created in, or `None` for the types the + /// shared context builds before any interpreter exists. + pub interpreter_id: Option, +} + +impl HeapTypeExt { + /// The interpreter a type created right now belongs to. + fn creating_interpreter_id() -> Option { + crate::vm::thread::try_with_current_vm(|vm| vm.state.interpreter_id) + } } pub struct TypeSpecializationCache { @@ -553,6 +563,22 @@ impl PyType { self.modified_inner(); } + /// Whether the interpreter with `interpreter_id` can see this type. + /// + /// Interpreters share the context, so a subclass of a shared type is + /// recorded on an object every interpreter reaches. Only the interpreter + /// that created it can name it, so only that one lists it. + pub fn is_visible_to_interpreter(&self, interpreter_id: i64) -> bool { + match self + .heaptype_ext + .as_ref() + .and_then(|ext| ext.interpreter_id) + { + Some(owner) => owner == interpreter_id, + None => true, + } + } + pub fn new_simple_heap( name: &str, base: &Py, @@ -589,6 +615,7 @@ impl PyType { slots: None, type_data: PyRwLock::new(None), specialization_cache: TypeSpecializationCache::new(), + interpreter_id: HeapTypeExt::creating_interpreter_id(), }; let base = bases[0].clone(); @@ -1948,13 +1975,18 @@ impl PyType { } #[pymethod] - fn __subclasses__(&self) -> PyList { + fn __subclasses__(&self, vm: &VirtualMachine) -> PyList { let mut subclasses = self.subclasses.write(); subclasses.retain(|x| x.upgrade().is_some()); + let interpreter_id = vm.state.interpreter_id; PyList::from( subclasses .iter() - .map(|x| x.upgrade().unwrap()) + .filter_map(|x| x.upgrade()) + .filter(|obj| { + obj.downcast_ref::() + .is_none_or(|typ| typ.is_visible_to_interpreter(interpreter_id)) + }) .collect::>(), ) } @@ -2368,6 +2400,7 @@ impl Constructor for PyType { slots: heaptype_slots.clone(), type_data: PyRwLock::new(None), specialization_cache: TypeSpecializationCache::new(), + interpreter_id: HeapTypeExt::creating_interpreter_id(), }; (slots, heaptype_ext) }; diff --git a/crates/vm/src/datastack.rs b/crates/vm/src/datastack.rs index 101369fba..ec4c22ae8 100644 --- a/crates/vm/src/datastack.rs +++ b/crates/vm/src/datastack.rs @@ -61,6 +61,9 @@ pub struct DataStack { top: *mut u8, /// End of usable space in the current chunk. limit: *mut u8, + /// Most recently popped full-frame allocation whose localsplus slots were + /// cleared before the pop. An exact LIFO reuse can skip zero-filling them. + reusable_frame: Option<(*mut u8, usize)>, } impl DataStack { @@ -73,7 +76,12 @@ impl DataStack { // Skip one ALIGN-sized slot in the root chunk so that `pop()` never // frees it (`push_chunk` convention). let top = unsafe { top.add(ALIGN) }; - Self { chunk, top, limit } + Self { + chunk, + top, + limit, + reusable_frame: None, + } } /// Check if the current chunk has at least `size` bytes available. @@ -91,6 +99,26 @@ impl DataStack { /// (LIFO order). #[inline(always)] pub fn push(&mut self, size: usize) -> *mut u8 { + self.reusable_frame = None; + self.push_inner(size) + } + + /// Allocate a full interpreter frame and report whether it exactly reuses + /// a just-cleared frame block. + #[inline(always)] + pub fn push_frame(&mut self, size: usize) -> (*mut u8, bool) { + let reusable_frame = self.reusable_frame.take(); + let ptr = self.push_inner(size); + // Exact sizes, not aligned ones: the caller reads "reused" as "every + // slot of this frame was cleared by the last one", and two frames whose + // sizes differ by less than ALIGN share an aligned size while the + // larger one's tail slots were never touched, let alone cleared. + let reused = reusable_frame.is_some_and(|(base, old_size)| base == ptr && old_size == size); + (ptr, reused) + } + + #[inline(always)] + fn push_inner(&mut self, size: usize) -> *mut u8 { let aligned_size = (size + ALIGN - 1) & !(ALIGN - 1); unsafe { if self.top.add(aligned_size) <= self.limit { @@ -138,6 +166,24 @@ impl DataStack { /// and all allocations made after it must already have been popped. #[inline(always)] pub unsafe fn pop(&mut self, base: *mut u8) { + self.reusable_frame = None; + unsafe { self.pop_inner(base) }; + } + + /// Pop a full frame whose localsplus slots have already been cleared. + /// + /// # Safety + /// `base` and `size` must describe the most recent allocation returned by + /// `push_frame`, every later allocation must already be popped, and all + /// localsplus slots in the frame must have been cleared. + #[inline(always)] + pub unsafe fn pop_frame(&mut self, base: *mut u8, size: usize) { + unsafe { self.pop_inner(base) }; + self.reusable_frame = Some((base, size)); + } + + #[inline(always)] + unsafe fn pop_inner(&mut self, base: *mut u8) { debug_assert!(!base.is_null()); if self.is_in_current_chunk(base) { // Common case: base is within the current chunk. diff --git a/crates/vm/src/dict_inner.rs b/crates/vm/src/dict_inner.rs index 9dda6194a..76d2c50f0 100644 --- a/crates/vm/src/dict_inner.rs +++ b/crates/vm/src/dict_inner.rs @@ -20,7 +20,7 @@ use alloc::fmt; use core::mem::size_of; use core::ops::ControlFlow; use core::sync::atomic::{ - AtomicU32, AtomicU64, + AtomicU32, Ordering::{AcqRel, Acquire, Relaxed, Release}, }; use num_traits::ToPrimitive; @@ -39,7 +39,6 @@ type EntryIndex = usize; pub(crate) struct Dict { inner: PyRwLock>, - version: AtomicU64, /// Keys-version stamp, assigned lazily by `assign_keys_version` and /// reset to 0 whenever the key set changes. Value-only updates keep it. /// @@ -202,7 +201,6 @@ impl Clone for Dict { fn clone(&self) -> Self { Self { inner: PyRwLock::new(self.inner.read().clone()), - version: AtomicU64::new(0), keys_version: AtomicU32::new(0), } } @@ -217,7 +215,6 @@ impl Default for Dict { indices: vec![IndexEntry::FREE; 8], entries: Vec::new(), }), - version: AtomicU64::new(0), keys_version: AtomicU32::new(0), } } @@ -240,6 +237,10 @@ pub struct DictSize { filled: usize, } +/// The dict was resized under an iterator holding an older [`DictSize`]. +#[derive(Debug)] +pub(crate) struct DictChanged; + struct GenIndexes { idx: HashIndex, perturb: HashValue, @@ -309,7 +310,7 @@ impl DictInner { key: PyObjectRef, value: T, index_entry: IndexEntry, - ) { + ) -> usize { let entry = DictEntry { hash: hash_value, key, @@ -330,6 +331,9 @@ impl DictInner { self.resize(new_size) } } + // A resize keeps entry positions and rewrites only the index-index, so + // this stays the entry's index afterwards. + entry_index } const fn size(&self) -> DictSize { @@ -362,16 +366,6 @@ impl DictInner { type PopInnerResult = ControlFlow>>; impl Dict { - /// Monotonically increasing version counter for mutation tracking. - pub(crate) fn version(&self) -> u64 { - self.version.load(Acquire) - } - - /// Bump the version counter after any mutation. - fn bump_version(&self) { - self.version.fetch_add(1, Release); - } - /// Current keys-version stamp, or 0 if none has been assigned since the /// last key-set change. Equal nonzero stamps guarantee an unchanged key /// set (values may differ). @@ -481,7 +475,26 @@ impl Dict { where K: DictKey + ?Sized, { - let _removed = loop { + self.insert_known_hash_indexed(vm, key, hash, value)?; + Ok(()) + } + + /// [`Self::insert_known_hash`], also reporting the entry index it stored to. + /// + /// The index doubles as a `hint` for [`Self::get_hint`] / + /// [`Self::insert_with_hint`], so a caller that wants one gets it from the + /// store itself instead of probing the dict a second time. + fn insert_known_hash_indexed( + &self, + vm: &VirtualMachine, + key: &K, + hash: HashValue, + value: T, + ) -> PyResult + where + K: DictKey + ?Sized, + { + let (stored_index, _removed) = loop { let (entry_index, index_index) = self.lookup(vm, key, hash, None)?; let mut inner = self.write(); if let Some(index) = entry_index.index() { @@ -500,9 +513,8 @@ impl Dict { )] if entry.index == index_index { let removed = core::mem::replace(&mut entry.value, value); - self.bump_version(); // defer dec RC - break Some(removed); + break (index, Some(removed)); } else { // stuff shifted around, let's try again } @@ -516,12 +528,17 @@ impl Dict { continue; } self.invalidate_keys_version(); - inner.unchecked_push(index_index, hash, key.to_pyobject(vm), value, entry_index); - self.bump_version(); - break None; + let stored = inner.unchecked_push( + index_index, + hash, + key.to_pyobject(vm), + value, + entry_index, + ); + break (stored, None); } }; - Ok(()) + Ok(stored_index) } pub(crate) fn contains( @@ -616,7 +633,6 @@ impl Dict { match inner.entries.get_mut(hint) { Some(Some(entry)) if key.key_is(&entry.key) => { let removed = core::mem::replace(&mut entry.value, value); - self.bump_version(); drop(inner); // defer dec RC until after the lock is released drop(removed); @@ -625,8 +641,9 @@ impl Dict { _ => value, } }; - self.insert(vm, key, value)?; - self.hint_for_key(vm, key) + let hash = key.key_hash(vm)?; + let stored = self.insert_known_hash_indexed(vm, key, hash, value)?; + Ok(u16::try_from(stored).ok()) } /// Fast path lookup using a cached entry index (`hint`). @@ -656,6 +673,22 @@ impl Dict { } } + /// Read an entry directly when a cached keys-version still describes the + /// dictionary layout. The version is rechecked while holding the read lock + /// so the entry index and value are observed from the same key-set state. + #[inline] + pub(crate) fn get_index_if_keys_version(&self, version: u32, index: usize) -> Option { + let inner = self.read(); + if self.keys_version.load(Acquire) != version { + return None; + } + inner + .entries + .get(index) + .and_then(Option::as_ref) + .map(|entry| entry.value.clone()) + } + fn _get_inner( &self, vm: &VirtualMachine, @@ -663,7 +696,12 @@ impl Dict { hash: HashValue, ) -> PyResult> { let ret = loop { - let (entry, index_index) = self.lookup(vm, key, hash, None)?; + let (entry, index_index) = + match self.lookup_extract(vm, key, hash, None, |entry| entry.value.clone())? { + // Read under the probe's own guard: nothing to re-check. + (_, Some(value)) => break Some(value), + (lookup, None) => lookup, + }; if let Some(index) = entry.index() { let inner = self.read(); if let Some(entry) = inner.get_entry_checked(index, index_index) { @@ -701,7 +739,6 @@ impl Dict { inner.indices.resize(8, IndexEntry::FREE); inner.used = 0; inner.filled = 0; - self.bump_version(); // defer dec rc core::mem::take(&mut inner.entries) }; @@ -830,7 +867,6 @@ impl Dict { } self.invalidate_keys_version(); inner.unchecked_push(index_index, hash, key.to_owned(), value, entry); - self.bump_version(); break None; }; Ok(()) @@ -867,7 +903,6 @@ impl Dict { value.clone(), index_entry, ); - self.bump_version(); return Ok(value); } } @@ -905,7 +940,6 @@ impl Dict { let ret = (key_obj.clone(), value.clone()); self.invalidate_keys_version(); inner.unchecked_push(index_index, hash, key_obj, value, index_entry); - self.bump_version(); return Ok(ret); } } @@ -918,6 +952,58 @@ impl Dict { self.read().size() } + /// Step to the first live entry at or after `position`, verifying the size + /// against `old` under the same read guard. + /// + /// `project` runs under that guard, so it must not run Python or take + /// another dict lock; it is there so an iterator clones only the field it + /// keeps rather than both the key and the value. + pub(crate) fn next_entry_checked( + &self, + mut position: EntryIndex, + old: &DictSize, + project: impl FnOnce(&PyObjectRef, &T) -> R, + ) -> Result, DictChanged> { + let inner = self.read(); + if inner.size() != *old { + return Err(DictChanged); + } + loop { + let Some(entry) = inner.entries.get(position) else { + return Ok(None); + }; + position += 1; + if let Some(entry) = entry { + return Ok(Some((position, project(&entry.key, &entry.value)))); + } + } + } + + /// [`Self::next_entry_checked`] in reverse. + pub(crate) fn prev_entry_checked( + &self, + mut position: EntryIndex, + old: &DictSize, + project: impl FnOnce(&PyObjectRef, &T) -> R, + ) -> Result, DictChanged> { + let inner = self.read(); + if inner.size() != *old { + return Err(DictChanged); + } + loop { + let Some(entry) = inner.entries.get(position) else { + return Ok(None); + }; + if let Some(entry) = entry { + return Ok(Some((position, project(&entry.key, &entry.value)))); + } + if position == 0 { + return Ok(None); + } + position -= 1; + } + } + pub(crate) fn next_entry(&self, mut position: EntryIndex) -> Option<(usize, PyObjectRef, T)> { let inner = self.read(); loop { @@ -1004,8 +1090,30 @@ impl Dict { vm: &VirtualMachine, key: &K, hash_value: HashValue, - mut lock: Option>>, + lock: Option>>, ) -> PyResult { + let (ret, _) = self.lookup_extract(vm, key, hash_value, lock, |_| ())?; + Ok(ret) + } + + /// [`Self::lookup`], additionally reading the matched entry when the probe + /// settles it by key identity. + /// + /// That is the common case, and it is decided while the read guard is still + /// held — so a caller that only wants the entry's value gets it here instead + /// of taking the lock a second time to re-find what the probe already had. + /// `extract` therefore runs under the guard and must not run Python. It is + /// not called when the key had to be compared with `key_eq`, which does run + /// Python and so releases the guard first. + #[cfg_attr(feature = "flame-it", flame("Dict"))] + fn lookup_extract( + &self, + vm: &VirtualMachine, + key: &K, + hash_value: HashValue, + mut lock: Option>>, + extract: impl Fn(&DictEntry) -> R, + ) -> PyResult<(LookupResult, Option)> { let mut idxs = None; let mut free_slot = None; let ret = 'outer: loop { @@ -1035,7 +1143,7 @@ impl Dict { Some(free) => (IndexEntry::DUMMY, free), None => (IndexEntry::FREE, index_index), }; - return Ok(idxs); + return Ok((idxs, None)); } idx => { let entry = unsafe { @@ -1051,7 +1159,7 @@ impl Dict { reason = "Keeping the empty `else` block here for documentation" )] if key.key_is(&entry.key) { - break 'outer ret; + return Ok((ret, Some(extract(entry)))); } else if entry.hash == hash_value { break (entry.key.clone(), ret); } else { @@ -1076,7 +1184,7 @@ impl Dict { // warn!("Perturb value: {}", i); }; - Ok(ret) + Ok((ret, None)) } // returns Err(()) if changed since lookup @@ -1117,7 +1225,6 @@ impl Dict { } = IndexEntry::DUMMY; inner.used -= 1; let removed = slot.take(); - self.bump_version(); Ok(ControlFlow::Break(removed)) } @@ -1152,7 +1259,6 @@ impl Dict { // entry.index always refers valid index inner.indices.get_unchecked_mut(entry.index) } = IndexEntry::DUMMY; - self.bump_version(); Some((entry.key, entry.value)) } diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index a1e7a98d5..69417ee61 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -387,33 +387,6 @@ impl LocalsPlus { Some(base) } - /// Create a new heap-backed LocalsPlus that is a clone of the - /// fastlocals portion of this one. Stack slots are NOT copied - /// (stack_top = 0, stacksize = 0). - /// - /// # Safety - /// The caller must ensure that `self.fastlocals()` is a valid slice - /// (backing storage is alive, not concurrently mutated). - pub(crate) unsafe fn snapshot_to_heap(&self) -> Self { - let n = self.nlocalsplus as usize; - let src = self.fastlocals(); - let mut data = vec![0usize; n]; - // Clone each Option into the heap buffer. - for (i, slot) in src.iter().enumerate() { - if let Some(obj) = slot { - let cloned: Option = Some(obj.clone()); - // SAFETY: Option has the same layout as usize. - data[i] = unsafe { core::mem::transmute_copy(&cloned) }; - core::mem::forget(cloned); - } - } - Self { - data: LocalsPlusData::Heap(data.into_boxed_slice()), - nlocalsplus: self.nlocalsplus, - stack_top: 0, - } - } - /// Update fastlocals in `self` from `src`. For each slot, drops the old /// value and clones the new one. `self` must be heap-backed. /// @@ -562,6 +535,19 @@ impl LocalsPlus { unsafe { core::mem::transmute::>(raw) } } + /// Give every borrowed stack ref its own reference. + /// + /// A borrowed ref is only sound while whatever it points at is guaranteed + /// to outlive it, which stops holding where the frame itself outlives the + /// running block — at a yield, where the stack is saved with the frame. + fn promote_stack(&mut self) { + for idx in 0..self.stack_top as usize { + if let Some(stack_ref) = self.stack_index_mut(idx) { + stack_ref.promote(); + } + } + } + /// Immutable view of the active stack as `Option` slice. #[inline(always)] fn stack_as_slice(&self) -> &[Option] { @@ -813,6 +799,12 @@ pub(crate) struct FrameColdData { pub retained_back: PyMutex>, pub pending_stack_pops: PyAtomic, pub pending_unwind_from_stack: PyAtomic, + /// Thread that is still running the frame this one was materialized from, + /// or 0 once that frame has returned (and for every frame object that was + /// not materialized from a running frame). Only a thread id, never a + /// pointer: reading it can never chase freed memory, so it stays usable + /// as the gate for frames that belong to another thread. + pub attached_tid: atomic::AtomicU64, } impl Default for FrameColdData { @@ -828,6 +820,7 @@ impl Default for FrameColdData { retained_back: PyMutex::new(None), pending_stack_pops: Default::default(), pending_unwind_from_stack: Default::default(), + attached_tid: atomic::AtomicU64::new(0), } } } @@ -899,6 +892,7 @@ impl InterpreterFrame { /// For stack-allocated frames (future), the pointers remain valid for the /// frame's lifetime on the native stack. #[allow(clippy::too_many_arguments)] + #[inline(always)] pub(crate) fn new( code: &Py, globals: &Py, @@ -968,9 +962,10 @@ impl InterpreterFrame { /// Returns a mutable reference whose lifetime is bounded by the data /// stack's LIFO discipline. The caller must call /// `release_datastack_frame()` (unsafe) when done, then - /// `vm.datastack_pop(base)`. The reference must not be used after + /// `vm.datastack_pop_frame(base, size)`. The reference must not be used after /// `release_datastack_frame` returns. #[allow(clippy::too_many_arguments)] + #[inline(always)] pub(crate) fn new_on_datastack<'a>( code: &Py, globals: &Py, @@ -987,7 +982,7 @@ impl InterpreterFrame { .expect("LocalsPlus capacity overflow"); let total_bytes = datastack_iframe_total_bytes(nlocalsplus, stacksize); - let base = vm.datastack_push(total_bytes); + let (base, reused_cleared_frame) = vm.datastack_push_frame(total_bytes); // InterpreterFrame lives at the start of the allocation. let iframe_ptr = base as *mut Self; @@ -995,8 +990,10 @@ impl InterpreterFrame { let localsplus_data_ptr = unsafe { base.add(datastack_iframe_localsplus_offset()) } as *mut usize; - // Zero-initialize localsplus data. - unsafe { core::ptr::write_bytes(localsplus_data_ptr, 0, capacity) }; + if !reused_cleared_frame { + // Fresh or differently shaped storage may contain old frame data. + unsafe { core::ptr::write_bytes(localsplus_data_ptr, 0, capacity) }; + } let nlocalsplus_u32 = u32::try_from(nlocalsplus).expect("nlocalsplus exceeds u32"); let localsplus = LocalsPlus { @@ -1038,11 +1035,15 @@ impl InterpreterFrame { /// After this call, the InterpreterFrame at `self` is logically dead — /// the caller must not use `self` again except to pass the returned /// base to `vm.datastack_pop()`. - pub(crate) unsafe fn release_datastack_frame(&mut self) -> Option<*mut u8> { + pub(crate) unsafe fn release_datastack_frame(&mut self) -> Option<(*mut u8, usize)> { let base = self.datastack_base; if base.is_null() { return None; } + let total_bytes = datastack_iframe_total_bytes( + self.localsplus.nlocalsplus as usize, + self.localsplus.stack_capacity(), + ); self.datastack_base = core::ptr::null_mut(); // Drop all localsplus values while the backing store is still valid. self.localsplus.drop_values(); @@ -1055,7 +1056,7 @@ impl InterpreterFrame { // SAFETY: `self` points to valid, initialized memory on the data // stack. After this call the memory is logically dead. unsafe { core::ptr::drop_in_place(self) }; - Some(base) + Some((base, total_bytes)) } /// Get the last instruction index. @@ -1093,6 +1094,62 @@ impl InterpreterFrame { self.materialize_slow(vm) } + /// Take a standalone copy of this frame, values included, for a thread + /// that does not own it. + /// + /// Nothing links the copy back to this frame: the owning thread will not + /// find it at `exit_iframe` and so never writes into it once the world + /// restarts. That is the whole point — a linked copy is a buffer the owner + /// rewrites slot by slot while the reader clones out of it. + /// + /// # Safety + /// Caller must hold the world stopped, so the owning thread is parked and + /// its fast locals are not moving while they are read. + #[cfg(feature = "threading")] + #[cold] + #[inline(never)] + pub(crate) unsafe fn materialize_detached(&self, vm: &VirtualMachine) -> FrameObjectRef { + // Deliberately not `materialize_chain`: that hands back an existing + // linked copy when the owning thread has already made one. + let fo = self.materialize_slow_chain(vm); + unsafe { + fo.iframe_mut() + .localsplus + .sync_fastlocals_from(&self.localsplus) + }; + fo + } + + /// Copy this frame and everything it was called from for a thread that + /// does not own them, linking `f_back` along the way, and return the copy + /// of this frame. The links are `retained_back`, so the chain keeps + /// resolving once the world restarts and the real frames return. + /// + /// # Safety + /// Caller must hold the world stopped, so the owning thread is parked and + /// the chain is not being popped while it is walked. + #[cfg(feature = "threading")] + #[cold] + #[inline(never)] + pub(crate) unsafe fn materialize_detached_chain(&self, vm: &VirtualMachine) -> FrameObjectRef { + let top = unsafe { self.materialize_detached(vm) }; + let mut child = top.clone(); + let mut cur = self.previous(); + while !cur.is_null() { + let caller = unsafe { &*cur }; + let caller_fo = unsafe { caller.materialize_detached(vm) }; + { + let mut guard = child.iframe().cold().retained_back.lock(); + if guard.is_none() { + *guard = Some(caller_fo.clone()); + } + } + child = caller_fo; + cur = caller.previous(); + } + top + } + /// Create a lightweight FrameObject with empty localsplus, suitable for /// f_back chain building (retained_back). Unlike `materialize`, this does /// NOT store into `temporary_refs` or set the `materialized` pointer, so @@ -1117,9 +1174,18 @@ impl InterpreterFrame { let builtins: PyObjectRef = self.builtins().to_owned(); let func_obj: Option = self.func_obj().map(|o| o.to_owned()); - // Copy localsplus from the stack frame so materialized frames have - // usable fastlocals (for locals(), f_locals, tracebacks, etc). - let localsplus = unsafe { self.localsplus.snapshot_to_heap() }; + // Empty localsplus, sized for the code object. While the source frame + // runs, every reader resolves it through `find_live_source_iframe`, and + // `exit_iframe` fills these slots from the live frame as it returns. + // Copying the values here instead would give each of them a second + // reference lasting as long as this FrameObject — a frame reached by + // one traceback entry would keep all of its locals alive. + let nlocalsplus = code.localspluskinds.len() as u32; + let localsplus = LocalsPlus { + data: LocalsPlusData::Heap(vec![0usize; nlocalsplus as usize].into_boxed_slice()), + nlocalsplus, + stack_top: 0, + }; // Copy the locals mapping if it exists. let locals = match self.locals.get() { @@ -1143,14 +1209,16 @@ impl InterpreterFrame { // that become dangling after their call returns. The f_back chain // is resolved through the TLS CURRENT_FRAME chain instead. previous: Radium::new(0), - // Materialized frame is a detached snapshot — always FrameObject-owned. - // If we copied Thread from the source iframe, frame.clear() would - // reject the frame with "cannot clear an executing frame". + // Always FrameObject-owned. If we copied Thread from the source + // iframe, frame.clear() would reject the frame with "cannot clear + // an executing frame"; `attached_tid` carries the "still running" + // half of that state instead, so the owner field does not have to. owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), datastack_base: core::ptr::null_mut(), materialized: Radium::new(0), cold: OnceCell::from(Box::new(FrameColdData { escaped: atomic::AtomicBool::new(true), + attached_tid: atomic::AtomicU64::new(current_thread_ident()), ..FrameColdData::default() })), }; @@ -1177,8 +1245,8 @@ impl InterpreterFrame { self.materialized.store(fo_ptr, Relaxed); // Keep the FrameObject alive by storing it in temporary_refs. - // GC tracking is deferred to with_iframe cleanup, where the frame - // is no longer executing and temporary_refs is cleared — at that + // GC tracking is deferred to `exit_iframe`, where the frame is no + // longer executing and temporary_refs is cleared — at that // point the FrameObject is self-sustaining and GC can safely // traverse and collect it. self.cold() @@ -1288,6 +1356,22 @@ impl InterpreterFrame { pub(crate) fn cold_opt(&self) -> Option<&FrameColdData> { self.cold.get().map(|b| &**b) } + + /// Thread still running the frame this one was materialized from, or 0. + #[inline] + pub(crate) fn attached_tid(&self) -> u64 { + self.cold_opt() + .map_or(0, |c| c.attached_tid.load(atomic::Ordering::Acquire)) + } + + /// Mark the frame this one was materialized from as returned, so its + /// values may be read from here. + #[inline] + pub(crate) fn detach(&self) { + if let Some(cold) = self.cold_opt() { + cold.attached_tid.store(0, atomic::Ordering::Release); + } + } } /// Python-visible frame object. Currently always wraps an `InterpreterFrame`. @@ -1718,10 +1802,29 @@ impl FrameObject { self.iframe().lasti.store(val, Relaxed); } + /// Fast-local slots of the live source frame when this frame object's + /// frame is still running on this thread, and this frame object's own + /// slots otherwise. A running frame's slots live on the data stack; the + /// frame object's are empty until `exit_iframe` fills them. + /// + /// # Safety + /// Caller must ensure no concurrent mutable access: either the frame is + /// not executing (callers pass through `check_locals_access`), or this is + /// a trace callback on the thread that is executing it. + unsafe fn live_fastlocals(&self) -> &[Option] { + let live = self.find_live_source_iframe(); + if live.is_null() { + unsafe { self.iframe_ref().localsplus.fastlocals() } + } else { + unsafe { (*live).localsplus.fastlocals() } + } + } + fn has_active_hidden_locals(&self) -> bool { use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE, CO_FAST_HIDDEN}; let code = self.iframe().code(); - let fastlocals = unsafe { self.iframe_ref().localsplus.fastlocals() }; + // SAFETY: reached from `locals()` on the thread running this frame. + let fastlocals = unsafe { self.live_fastlocals() }; let is_optimized = code.flags.contains(bytecode::CodeFlags::OPTIMIZED); !is_optimized && code.localspluskinds.iter().enumerate().any(|(i, &kind)| { @@ -1753,15 +1856,7 @@ impl FrameObject { // SAFETY: Either the frame is not executing (caller checked owner), // or we're in a trace callback on the same thread that's executing. let code = self.iframe().code(); - // If this FrameObject has a live source iframe on the TLS chain, read - // its localsplus for up-to-date values (the materialized copy is a - // stale snapshot from materialize time). - let live = self.find_live_source_iframe(); - let fastlocals = if !live.is_null() { - unsafe { (*live).localsplus.fastlocals() } - } else { - unsafe { self.iframe_ref().localsplus.fastlocals() } - }; + let fastlocals = unsafe { self.live_fastlocals() }; // Iterate through all localsplus slots using localspluskinds let nlocalsplus = code.localspluskinds.len(); @@ -1865,6 +1960,15 @@ impl FrameObject { /// builtin, trace callbacks) is fine: the frame sits on the current /// thread's frame chain and is at a bytecode boundary. pub(crate) fn check_locals_access(&self, vm: &VirtualMachine) -> PyResult<()> { + // A frame object materialized from a running data stack frame is + // FrameObject-owned, so the owner test below cannot speak for it: the + // thread running that frame fills these slots when it returns. + let attached = self.iframe().attached_tid(); + if attached != 0 && attached != current_thread_ident() { + return Err(vm.new_runtime_error( + "cannot access frame locals while the frame is executing in another thread", + )); + } let owner = FrameOwner::from_i8(self.iframe().owner.load(atomic::Ordering::Acquire)); if owner != FrameOwner::Thread { return Ok(()); @@ -1948,13 +2052,7 @@ impl FrameObject { use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE}; // SAFETY: callers first pass through `check_locals_access`, so the // frame is not executing on another thread. - // Use live source iframe if available for up-to-date values. - let live = self.find_live_source_iframe(); - let fastlocals = if !live.is_null() { - unsafe { (*live).localsplus.fastlocals() } - } else { - unsafe { self.iframe_ref().localsplus.fastlocals() } - }; + let fastlocals = unsafe { self.live_fastlocals() }; let obj = fastlocals.get(i)?.as_ref()?; let kind = self .iframe() @@ -2297,6 +2395,21 @@ impl Py { } } +/// Identity of the calling thread, or 0 where there is only one thread to be. +/// 0 doubles as "no thread", which is what `attached_tid` wants for a build +/// that cannot have a frame running anywhere else. +#[inline] +fn current_thread_ident() -> u64 { + #[cfg(feature = "threading")] + { + crate::stdlib::_thread::get_ident() + } + #[cfg(not(feature = "threading"))] + { + 0 + } +} + /// Byte offset from the start of a datastack allocation to the LocalsPlus data, /// accounting for alignment padding after the InterpreterFrame header. #[inline] @@ -2457,11 +2570,11 @@ pub(crate) struct ExecutingFrame<'a> { } #[inline] -fn specialization_compact_int_value(i: &PyInt, vm: &VirtualMachine) -> Option { +fn specialization_compact_int_value(i: &PyInt) -> Option { // _PyLong_IsCompact(): a one-digit PyLong (base 2^30), // i.e. abs(value) <= 2^30 - 1. const CPYTHON_COMPACT_LONG_ABS_MAX: i64 = (1i64 << 30) - 1; - let v = i.try_to_primitive::(vm).ok()?; + let v = i.try_to_i64_fast()?; if (-CPYTHON_COMPACT_LONG_ABS_MAX..=CPYTHON_COMPACT_LONG_ABS_MAX).contains(&v) { Some(v as isize) } else { @@ -2472,7 +2585,7 @@ fn specialization_compact_int_value(i: &PyInt, vm: &VirtualMachine) -> Option Option { obj.downcast_ref_if_exact::(vm) - .and_then(|i| specialization_compact_int_value(i, vm)) + .and_then(|i| specialization_compact_int_value(i)) } #[inline] @@ -2611,7 +2724,10 @@ pub(crate) fn release_datastack_frame(frame: &Py, vm: &VirtualMachi ); // SAFETY: the frame is alive (held by `frame` and the escaped reference) // and untracked. - unsafe { crate::gc_state::gc_state().track_object(NonNull::from(frame_obj)) }; + unsafe { + crate::gc_state::gc_state() + .track_object(NonNull::from(frame_obj), crate::gc_state::current_owner()) + }; } type BinaryOpExtendGuard = fn(&PyObject, &PyObject, &VirtualMachine) -> bool; @@ -2976,8 +3092,9 @@ impl ExecutingFrame<'_> { // Advance lasti past the current instruction BEFORE firing the // line event. This ensures that f_lineno (which reads // locations[lasti - 1]) returns the line of the instruction - // being traced, not the previous one. - self.update_lasti(|i| *i += 1); + // being traced, not the previous one. Stored from `idx` rather + // than read-modify-written, which would re-load what was just read. + self.lasti.store(idx as u32 + 1, Relaxed); // Fire 'line' trace event when line number changes. // Only fire if this frame has a per-frame trace function set @@ -4286,9 +4403,10 @@ impl ExecutingFrame<'_> { Ok(None) } Instruction::LoadSmallInt { i: idx } => { - // Push small integer (-5..=256) directly without constant table lookup - let value = vm.ctx.new_int(idx.get(arg) as i32); - self.push_value(value.into()); + // Cached small integers live for the whole Context, so the value stack can + // borrow them without touching the refcount. + let value = vm.ctx.cached_int(idx.get(arg) as i32); + unsafe { self.push_borrowed(value.as_object()) }; Ok(None) } Instruction::LoadDeref { i } => { @@ -4791,8 +4909,12 @@ impl ExecutingFrame<'_> { } Instruction::RaiseVarargs { argc: kind } => self.execute_raise(vm, kind.get(arg)), Instruction::Resume { .. } | Instruction::ResumeCheck => { - // Lazy quickening: initialize adaptive counters on first execution - if !self.code.quickened.swap(true, atomic::Ordering::Relaxed) { + // Lazy quickening: initialize adaptive counters on first execution. + // Read before the swap so that the steady state — every call after + // the first — costs a load rather than a read-modify-write. + if !self.code.quickened.load(atomic::Ordering::Relaxed) + && !self.code.quickened.swap(true, atomic::Ordering::Relaxed) + { self.code.instructions.quicken(); atomic::fence(atomic::Ordering::Release); } @@ -5050,6 +5172,9 @@ impl ExecutingFrame<'_> { Ok(None) } Instruction::YieldValue { .. } => { + // The frame outlives this block from here on, so nothing it + // still holds may be a borrow of something else's slot. + self.localsplus.promote_stack(); debug_assert!( self.localsplus .stack_as_slice() @@ -5259,7 +5384,7 @@ impl ExecutingFrame<'_> { if type_version != 0 && owner.class().tp_version_tag.load(Acquire) == type_version - && owner.dict().is_none() + && !owner.has_instance_dict() && let Some(func) = self.try_read_cached_descriptor(cache_base, type_version) { let owner = self.pop_value(); @@ -5683,8 +5808,8 @@ impl ExecutingFrame<'_> { b.downcast_ref_if_exact::(vm), ) { let result = a_str.as_wtf8().py_add(b_str.as_wtf8()); - self.pop_value(); - self.pop_value(); + self.pop_stackref(); + self.pop_stackref(); self.push_value(result.to_pyobject(vm)); Ok(None) } else { @@ -5843,6 +5968,9 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_vectorcall(nargs, vm); + } let effective_nargs = nargs + u32::from(self_or_null_is_some); if !func.has_exact_argcount(effective_nargs) { return self.execute_call_vectorcall(nargs, vm); @@ -5905,6 +6033,9 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_vectorcall(nargs, vm); + } if !func.has_exact_argcount(nargs + 1) { return self.execute_call_vectorcall(nargs, vm); } @@ -6076,15 +6207,8 @@ impl ExecutingFrame<'_> { | PyMethodFlags::O | PyMethodFlags::KEYWORDS); if call_conv == PyMethodFlags::O && effective_nargs == 1 { - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let mut args_vec = Vec::with_capacity(effective_nargs as usize); - if let Some(self_val) = self_or_null { - args_vec.push(self_val); - } - args_vec.extend(pos_args); + let (callable, args_vec) = self.take_call_args(nargs as usize); + debug_assert_eq!(args_vec.len(), effective_nargs as usize); let result = callable.vectorcall(args_vec, effective_nargs as usize, None, vm)?; self.push_value(result); @@ -6110,15 +6234,8 @@ impl ExecutingFrame<'_> { | PyMethodFlags::O | PyMethodFlags::KEYWORDS); if call_conv == PyMethodFlags::FASTCALL { - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let mut args_vec = Vec::with_capacity(effective_nargs as usize); - if let Some(self_val) = self_or_null { - args_vec.push(self_val); - } - args_vec.extend(pos_args); + let (callable, args_vec) = self.take_call_args(nargs as usize); + debug_assert_eq!(args_vec.len(), effective_nargs as usize); let result = callable.vectorcall(args_vec, effective_nargs as usize, None, vm)?; self.push_value(result); @@ -6140,21 +6257,14 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_vectorcall(nargs, vm); + } if self.specialization_call_recursion_guard(vm) { return self.execute_call_vectorcall(nargs, vm); } - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let (args_vec, effective_nargs) = if let Some(self_val) = self_or_null { - let mut v = Vec::with_capacity(nargs_usize + 1); - v.push(self_val); - v.extend(pos_args); - (v, nargs_usize + 1) - } else { - (pos_args, nargs_usize) - }; + let (callable, args_vec) = self.take_call_args(nargs as usize); + let effective_nargs = args_vec.len(); let result = vectorcall_function(&callable, args_vec, effective_nargs, None, vm)?; self.push_value(result); @@ -6186,16 +6296,18 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_vectorcall(nargs, vm); + } if self.specialization_call_recursion_guard(vm) { return self.execute_call_vectorcall(nargs, vm); } let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - self.pop_value_opt(); // null (self_or_null) - self.pop_value(); // callable (bound method) let mut args_vec = Vec::with_capacity(nargs_usize + 1); args_vec.push(bound_self); - args_vec.extend(pos_args); + args_vec.extend(self.pop_multiple(nargs_usize)); + self.pop_value_opt(); // null (self_or_null) + self.pop_value(); // callable (bound method) let result = vectorcall_function( &bound_function, args_vec, @@ -6277,15 +6389,8 @@ impl ExecutingFrame<'_> { .is_some_and(|self_obj| self_obj.class().is(descr.objclass)) { let func = descr.method.func; - let positional_args: Vec = - self.pop_multiple(nargs as usize).collect(); - let self_or_null = self.pop_value_opt(); - self.pop_value(); // callable - let mut all_args = Vec::with_capacity(total_nargs as usize); - if let Some(self_val) = self_or_null { - all_args.push(self_val); - } - all_args.extend(positional_args); + let (_callable, all_args) = self.take_call_args(nargs as usize); + debug_assert_eq!(all_args.len(), total_nargs as usize); let args = FuncArgs { args: all_args, kwargs: Default::default(), @@ -6324,15 +6429,8 @@ impl ExecutingFrame<'_> { .is_some_and(|self_obj| self_obj.class().is(descr.objclass)) { let func = descr.method.func; - let positional_args: Vec = - self.pop_multiple(nargs as usize).collect(); - let self_or_null = self.pop_value_opt(); - self.pop_value(); // callable - let mut all_args = Vec::with_capacity(total_nargs as usize); - if let Some(self_val) = self_or_null { - all_args.push(self_val); - } - all_args.extend(positional_args); + let (_callable, all_args) = self.take_call_args(nargs as usize); + debug_assert_eq!(all_args.len(), total_nargs as usize); let args = FuncArgs { args: all_args, kwargs: Default::default(), @@ -6371,15 +6469,8 @@ impl ExecutingFrame<'_> { .is_some_and(|self_obj| self_obj.class().is(descr.objclass)) { let func = descr.method.func; - let positional_args: Vec = - self.pop_multiple(nargs as usize).collect(); - let self_or_null = self.pop_value_opt(); - self.pop_value(); // callable - let mut all_args = Vec::with_capacity(total_nargs as usize); - if let Some(self_val) = self_or_null { - all_args.push(self_val); - } - all_args.extend(positional_args); + let (_callable, all_args) = self.take_call_args(nargs as usize); + debug_assert_eq!(all_args.len(), total_nargs as usize); let args = FuncArgs { args: all_args, kwargs: Default::default(), @@ -6396,22 +6487,9 @@ impl ExecutingFrame<'_> { if let Some(cls) = callable.downcast_ref::() && cls.slots.vectorcall.load().is_some() { - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let self_is_some = self_or_null.is_some(); - let mut args_vec = Vec::with_capacity(nargs_usize + usize::from(self_is_some)); - if let Some(self_val) = self_or_null { - args_vec.push(self_val); - } - args_vec.extend(pos_args); - let result = callable.vectorcall( - args_vec, - nargs_usize + usize::from(self_is_some), - None, - vm, - )?; + let (callable, args_vec) = self.take_call_args(nargs as usize); + let effective_nargs = args_vec.len(); + let result = callable.vectorcall(args_vec, effective_nargs, None, vm)?; self.push_value(result); return Ok(None); } @@ -6496,15 +6574,8 @@ impl ExecutingFrame<'_> { .is_some_and(|self_obj| self_obj.class().is(descr.objclass)) { let func = descr.method.func; - let positional_args: Vec = - self.pop_multiple(nargs as usize).collect(); - let self_or_null = self.pop_value_opt(); - self.pop_value(); // callable - let mut all_args = Vec::with_capacity(total_nargs as usize); - if let Some(self_val) = self_or_null { - all_args.push(self_val); - } - all_args.extend(positional_args); + let (_callable, all_args) = self.take_call_args(nargs as usize); + debug_assert_eq!(all_args.len(), total_nargs as usize); let args = FuncArgs { args: all_args, kwargs: Default::default(), @@ -6533,15 +6604,8 @@ impl ExecutingFrame<'_> { | PyMethodFlags::O | PyMethodFlags::KEYWORDS); if call_conv == (PyMethodFlags::FASTCALL | PyMethodFlags::KEYWORDS) { - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let mut args_vec = Vec::with_capacity(effective_nargs as usize); - if let Some(self_val) = self_or_null { - args_vec.push(self_val); - } - args_vec.extend(pos_args); + let (callable, args_vec) = self.take_call_args(nargs as usize); + debug_assert_eq!(args_vec.len(), effective_nargs as usize); let result = callable.vectorcall(args_vec, effective_nargs as usize, None, vm)?; self.push_value(result); @@ -6565,22 +6629,13 @@ impl ExecutingFrame<'_> { { return self.execute_call_vectorcall(nargs, vm); } - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let mut args_vec = - Vec::with_capacity(nargs_usize + usize::from(self_or_null_is_some)); - if let Some(self_val) = self_or_null { - args_vec.push(self_val); - } - args_vec.extend(pos_args); - let result = callable.vectorcall( - args_vec, - nargs_usize + usize::from(self_or_null_is_some), - None, - vm, - )?; + let (callable, args_vec) = self.take_call_args(nargs as usize); + debug_assert_eq!( + args_vec.len(), + nargs as usize + usize::from(self_or_null_is_some) + ); + let effective_nargs = args_vec.len(); + let result = callable.vectorcall(args_vec, effective_nargs, None, vm)?; self.push_value(result); Ok(None) } @@ -6598,6 +6653,9 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_kw_vectorcall(nargs, vm); + } if self.specialization_call_recursion_guard(vm) { return self.execute_call_kw_vectorcall(nargs, vm); } @@ -6656,6 +6714,9 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_kw_vectorcall(nargs, vm); + } let nargs_usize = nargs as usize; let kwarg_names_obj = self.pop_value(); let kwarg_names_tuple = kwarg_names_obj @@ -6851,14 +6912,16 @@ impl ExecutingFrame<'_> { a.downcast_ref_if_exact::(vm), b.downcast_ref_if_exact::(vm), ) && let (Some(a_val), Some(b_val)) = ( - specialization_compact_int_value(a_int, vm), - specialization_compact_int_value(b_int, vm), + specialization_compact_int_value(a_int), + specialization_compact_int_value(b_int), ) { let op = self.compare_op_from_arg(arg); let result = op.eval_ord(a_val.cmp(&b_val)); - self.pop_value(); - self.pop_value(); - self.push_value(vm.ctx.new_bool(result).into()); + self.pop_stackref(); + self.pop_stackref(); + if !self.try_fused_compare_int_jump(result, vm) { + self.push_value(vm.ctx.new_bool(result).into()); + } Ok(None) } else { self.execute_compare(vm, arg) @@ -7184,17 +7247,16 @@ impl ExecutingFrame<'_> { // Keep specialized opcode on guard miss (JUMP_TO_PREDICTED behavior). let cached_version = self.code.instructions.read_cache_u16(cache_base + 1); let cached_index = self.code.instructions.read_cache_u16(cache_base + 3); - if let Ok(current_version) = u16::try_from(self.globals.version()) - && cached_version == current_version + if cached_version != 0 + && let Some(x) = self + .globals + .get_item_by_index_and_keys_version(cached_version, cached_index) { - let name = self.code.names[(oparg >> 1) as usize]; - if let Some(x) = self.globals.get_item_opt_hint(name, cached_index, vm)? { - self.push_value(x); - if (oparg & 1) != 0 { - self.push_value_opt(None); - } - return Ok(None); + self.push_value(x); + if (oparg & 1) != 0 { + self.push_value_opt(None); } + return Ok(None); } let name = self.code.names[(oparg >> 1) as usize]; let x = self.load_global_or_builtin(name, vm)?; @@ -7210,20 +7272,19 @@ impl ExecutingFrame<'_> { let cached_globals_ver = self.code.instructions.read_cache_u16(cache_base + 1); let cached_builtins_ver = self.code.instructions.read_cache_u16(cache_base + 2); let cached_index = self.code.instructions.read_cache_u16(cache_base + 3); - if let Ok(current_globals_ver) = u16::try_from(self.globals.version()) + if cached_globals_ver != 0 + && cached_builtins_ver != 0 + && let Ok(current_globals_ver) = u16::try_from(self.globals.keys_version()) && cached_globals_ver == current_globals_ver && let Some(builtins_dict) = self.builtins.downcast_ref_if_exact::(vm) - && let Ok(current_builtins_ver) = u16::try_from(builtins_dict.version()) - && cached_builtins_ver == current_builtins_ver + && let Some(x) = builtins_dict + .get_item_by_index_and_keys_version(cached_builtins_ver, cached_index) { - let name = self.code.names[(oparg >> 1) as usize]; - if let Some(x) = builtins_dict.get_item_opt_hint(name, cached_index, vm)? { - self.push_value(x); - if (oparg & 1) != 0 { - self.push_value_opt(None); - } - return Ok(None); + self.push_value(x); + if (oparg & 1) != 0 { + self.push_value_opt(None); } + return Ok(None); } let name = self.code.names[(oparg >> 1) as usize]; let x = self.load_global_or_builtin(name, vm)?; @@ -7311,6 +7372,7 @@ impl ExecutingFrame<'_> { self.unwind_blocks(vm, UnwindReason::Returning { value }) } Instruction::InstrumentedYieldValue => { + self.localsplus.promote_stack(); debug_assert!( self.localsplus .stack_as_slice() @@ -8567,7 +8629,7 @@ impl ExecutingFrame<'_> { a_ref.downcast_ref_if_exact::(vm), b_ref.downcast_ref_if_exact::(vm), ) { - Ok(Self::int_add(a.as_bigint(), b.as_bigint(), vm)) + Ok(Self::int_add(a, b, vm)) } else if matches!(op, bytecode::BinaryOperator::Add) { vm._add(a_ref, b_ref) } else { @@ -8579,7 +8641,7 @@ impl ExecutingFrame<'_> { a_ref.downcast_ref_if_exact::(vm), b_ref.downcast_ref_if_exact::(vm), ) { - Ok(Self::int_sub(a.as_bigint(), b.as_bigint(), vm)) + Ok(Self::int_sub(a, b, vm)) } else if matches!(op, bytecode::BinaryOperator::Subtract) { vm._sub(a_ref, b_ref) } else { @@ -8591,7 +8653,7 @@ impl ExecutingFrame<'_> { a_ref.downcast_ref_if_exact::(vm), b_ref.downcast_ref_if_exact::(vm), ) { - Ok(Self::int_mul(a.as_bigint(), b.as_bigint(), vm)) + Ok(Self::int_mul(a, b, vm)) } else if matches!(op, bytecode::BinaryOperator::Multiply) { vm._mul(a_ref, b_ref) } else { @@ -8657,36 +8719,37 @@ impl ExecutingFrame<'_> { /// small-int cache is consulted identically. #[inline] fn int_fast_op( - a: &BigInt, - b: &BigInt, + a: &PyInt, + b: &PyInt, vm: &VirtualMachine, checked: fn(i64, i64) -> Option, fallback: impl FnOnce(&BigInt, &BigInt) -> BigInt, ) -> PyObjectRef { - use num_traits::ToPrimitive; - if let (Some(av), Some(bv)) = (a.to_i64(), b.to_i64()) + if let (Some(av), Some(bv)) = (a.try_to_i64_fast(), b.try_to_i64_fast()) && let Some(result) = checked(av, bv) { return vm.ctx.new_int(result).into(); } - vm.ctx.new_int(fallback(a, b)).into() + vm.ctx + .new_int(fallback(a.as_bigint(), b.as_bigint())) + .into() } /// Int addition with i64 fast path to avoid BigInt heap allocation. #[inline] - fn int_add(a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> PyObjectRef { + fn int_add(a: &PyInt, b: &PyInt, vm: &VirtualMachine) -> PyObjectRef { Self::int_fast_op(a, b, vm, i64::checked_add, |a, b| a + b) } /// Int subtraction with i64 fast path to avoid BigInt heap allocation. #[inline] - fn int_sub(a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> PyObjectRef { + fn int_sub(a: &PyInt, b: &PyInt, vm: &VirtualMachine) -> PyObjectRef { Self::int_fast_op(a, b, vm, i64::checked_sub, |a, b| a - b) } /// Int multiplication with i64 fast path to avoid BigInt heap allocation. #[inline] - fn int_mul(a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> PyObjectRef { + fn int_mul(a: &PyInt, b: &PyInt, vm: &VirtualMachine) -> PyObjectRef { Self::int_fast_op(a, b, vm, i64::checked_mul, |a, b| a * b) } @@ -8984,13 +9047,19 @@ impl ExecutingFrame<'_> { attr_name: &'static PyStrInterned, vm: &VirtualMachine, ) -> PyResult> { + let stamp = self.code.instructions.read_cache_ptr(cache_base + 3); + // Take the stamp check first, on a borrowed dict: a hit is the whole + // fast path, and cloning the dict for it would cost more than the + // comparison it exists to make. + let stamped = self.top_value().with_instance_dict(|dict| { + dict.is_some_and(|d| stamp != 0 && stamp == d.keys_version() as usize) + }); + if stamped { + return Ok(None); + } let Some(dict) = self.top_value().dict() else { return Ok(None); }; - let stamp = self.code.instructions.read_cache_ptr(cache_base + 3); - if stamp != 0 && stamp == dict.keys_version() as usize { - return Ok(None); - } // Take the stamp before probing so it attests the probed key set. let stamp = dict.assign_keys_version(vm); if let Some(value) = dict.get_item_opt(attr_name, vm)? { @@ -9831,7 +9900,7 @@ impl ExecutingFrame<'_> { fn execute_binary_op_int( &mut self, vm: &VirtualMachine, - op: impl FnOnce(&BigInt, &BigInt, &VirtualMachine) -> PyObjectRef, + op: impl FnOnce(&PyInt, &PyInt, &VirtualMachine) -> PyObjectRef, deopt_op: bytecode::BinaryOperator, ) -> FrameResult { let b = self.top_value(); @@ -9840,9 +9909,9 @@ impl ExecutingFrame<'_> { a.downcast_ref_if_exact::(vm), b.downcast_ref_if_exact::(vm), ) { - let result = op(a_int.as_bigint(), b_int.as_bigint(), vm); - self.pop_value(); - self.pop_value(); + let result = op(a_int, b_int, vm); + self.pop_stackref(); + self.pop_stackref(); self.push_value(result); Ok(None) } else { @@ -9899,7 +9968,7 @@ impl ExecutingFrame<'_> { let callable = self.nth_value(nargs + 1); if let Some(func) = callable.downcast_ref_if_exact::(vm) { - if self.specialization_eval_frame_active(vm) { + if self.specialization_eval_frame_active(vm) || func.is_jitted() { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -9962,7 +10031,7 @@ impl ExecutingFrame<'_> { .function_obj() .downcast_ref_if_exact::(vm) { - if self.specialization_eval_frame_active(vm) { + if self.specialization_eval_frame_active(vm) || func.is_jitted() { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -10257,7 +10326,7 @@ impl ExecutingFrame<'_> { let callable = self.nth_value(nargs + 2); if let Some(func) = callable.downcast_ref_if_exact::(vm) { - if self.specialization_eval_frame_active(vm) { + if self.specialization_eval_frame_active(vm) || func.is_jitted() { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -10308,7 +10377,7 @@ impl ExecutingFrame<'_> { .function_obj() .downcast_ref_if_exact::(vm) { - if self.specialization_eval_frame_active(vm) { + if self.specialization_eval_frame_active(vm) || func.is_jitted() { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -10451,8 +10520,8 @@ impl ExecutingFrame<'_> { a.downcast_ref_if_exact::(vm), b.downcast_ref_if_exact::(vm), ) { - if specialization_compact_int_value(a_int, vm).is_some() - && specialization_compact_int_value(b_int, vm).is_some() + if specialization_compact_int_value(a_int).is_some() + && specialization_compact_int_value(b_int).is_some() { Some(Instruction::CompareOpInt) } else { @@ -10483,6 +10552,37 @@ impl ExecutingFrame<'_> { .into() } + /// Execute an immediately following conditional jump without materializing + /// the comparison result as a Python bool. This is the adaptive interpreter + /// equivalent of keeping the result virtual across the two-opcode trace. + #[inline] + fn try_fused_compare_int_jump(&mut self, result: bool, vm: &VirtualMachine) -> bool { + if self.specialization_eval_frame_active(vm) { + return false; + } + + let jump_idx = self.lasti() as usize + Instruction::CompareOpInt.cache_entries(); + if jump_idx >= self.code.instructions.len() { + return false; + } + + let jump_op = self.code.instructions.read_op(jump_idx); + let jump_on = match jump_op { + Instruction::PopJumpIfFalse { .. } => false, + Instruction::PopJumpIfTrue { .. } => true, + _ => return false, + }; + let jump_delta = self.code.instructions.read_arg(jump_idx).as_u32(); + let after_jump = jump_idx as u32 + 1 + jump_op.cache_entries() as u32; + let target = if result == jump_on { + after_jump + jump_delta + } else { + after_jump + }; + self.update_lasti(|i| *i = target); + true + } + /// Recover the BinaryOperator from the instruction arg byte. /// `replace_op` preserves the arg byte, so the original op remains accessible. fn binary_op_from_arg(&self, arg: bytecode::OpArg) -> bytecode::BinaryOperator { @@ -10683,11 +10783,10 @@ impl ExecutingFrame<'_> { } } - // Pop the callable and transfer ownership to the trampoline via - // the VM side channel, avoiding a per-frame mutex lock on - // temporary_refs. + // Pop the callable and transfer ownership to the trampoline. This one + // reference keeps every field borrowed by the callee frame alive. let callable = self.pop_value(); - unsafe { &mut *vm.pending_tailcall_refs.get() }.push(callable); + vm.set_pending_tailcall_owner(callable); vm.set_pending_tailcall(callee_iframe); } @@ -10737,13 +10836,13 @@ impl ExecutingFrame<'_> { *dst = Some(arg); } self.pop_value_opt(); // null (self_or_null) - let callable = self.pop_value(); // callable (bound method) + self.pop_value(); // callable (bound method) fastlocals[0] = Some(bound_self); - // Transfer ownership to the trampoline via the VM side channel. - let refs = unsafe { &mut *vm.pending_tailcall_refs.get() }; - refs.push(bound_function); - refs.push(callable); + // The function owns every field borrowed by the callee frame. + // bound_self is owned by fastlocals; the bound-method object itself is + // no longer needed and was dropped above, matching the recursive path. + vm.set_pending_tailcall_owner(bound_function); vm.set_pending_tailcall(callee_iframe); } @@ -10795,7 +10894,7 @@ impl ExecutingFrame<'_> { return; } let name = self.code.names[(oparg >> 1) as usize]; - let Ok(globals_version) = u16::try_from(self.globals.version()) else { + let Ok(globals_version @ 1..) = u16::try_from(self.globals.assign_keys_version(vm)) else { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -10823,7 +10922,7 @@ impl ExecutingFrame<'_> { if let Some(builtins_dict) = self.builtins.downcast_ref_if_exact::(vm) && let Ok(Some(builtins_hint)) = builtins_dict.hint_for_key(name, vm) - && let Ok(builtins_version) = u16::try_from(builtins_dict.version()) + && let Ok(builtins_version @ 1..) = u16::try_from(builtins_dict.assign_keys_version(vm)) { unsafe { self.code @@ -11352,6 +11451,49 @@ impl ExecutingFrame<'_> { } } + /// Take a call's `[self_or_null, arg1, ..., argN]` off the stack as one + /// vectorcall argument list, along with the callable underneath them. + /// + /// The stack already holds the arguments in vectorcall order, so filling a + /// single vector by index costs one allocation — collecting the positional + /// arguments first and then pushing `self` in front of them costs two plus + /// a copy. + fn take_call_args(&mut self, nargs: usize) -> (PyObjectRef, Vec) { + let stack_len = self.localsplus.stack_len(); + debug_assert!( + stack_len >= nargs + 2, + "CALL stack underflow: need callable + self_or_null + {nargs} args, have {stack_len}" + ); + let callable_idx = stack_len - nargs - 2; + let self_or_null_idx = callable_idx + 1; + + let self_or_null = self + .localsplus + .stack_index_mut(self_or_null_idx) + .take() + .map(|sr| sr.to_pyobj()); + let mut args = Vec::with_capacity(nargs + usize::from(self_or_null.is_some())); + args.extend(self_or_null); + for stack_idx in self_or_null_idx + 1..stack_len { + let val = self + .localsplus + .stack_index_mut(stack_idx) + .take() + .unwrap() + .to_pyobj(); + args.push(val); + } + + let callable = self + .localsplus + .stack_index_mut(callable_idx) + .take() + .unwrap() + .to_pyobj(); + self.localsplus.stack_truncate(callable_idx); + (callable, args) + } + /// Pop multiple values from the stack. Panics if any slot is NULL. fn pop_multiple(&mut self, count: usize) -> impl ExactSizeIterator + '_ { let stack_len = self.localsplus.stack_len(); diff --git a/crates/vm/src/function/argument.rs b/crates/vm/src/function/argument.rs index aabe484c2..6bf4ae210 100644 --- a/crates/vm/src/function/argument.rs +++ b/crates/vm/src/function/argument.rs @@ -8,6 +8,7 @@ use crate::{ use core::ops::{Deref, DerefMut, RangeInclusive}; use indexmap::IndexMap; use itertools::Itertools; +use std::hash::DefaultHasher; pub trait IntoFuncArgs: Sized { fn into_args(self, vm: &VirtualMachine) -> FuncArgs; @@ -414,16 +415,24 @@ impl FromArgOptional for T { // issue #8228). `PyStr` is WTF-8 backed, and CPython only requires that a // keyword key be a `str`, not that it be valid UTF-8. #[derive(Clone, Debug)] -pub struct KwArgs(IndexMap); +pub struct KwArgs(KwArgsMap); + +/// The map behind [`KwArgs`]. +/// +/// The hasher is zero-sized rather than the randomly seeded default: a +/// `KwArgs` is built for every call, including the far more common +/// keyword-less one, and seeding reads a thread-local. Keyword names come +/// from the program text, so per-process hash randomization buys nothing. +pub type KwArgsMap = IndexMap>; impl Default for KwArgs { fn default() -> Self { - Self(IndexMap::new()) + Self(KwArgsMap::default()) } } impl Deref for KwArgs { - type Target = IndexMap; + type Target = KwArgsMap; fn deref(&self) -> &Self::Target { &self.0 @@ -447,7 +456,7 @@ where impl KwArgs { #[must_use] - pub const fn new(map: IndexMap) -> Self { + pub const fn new(map: KwArgsMap) -> Self { Self(map) } @@ -508,7 +517,7 @@ where T: TryFromObject, { fn from_args(vm: &VirtualMachine, args: &mut FuncArgs) -> Result { - let mut kwargs = IndexMap::new(); + let mut kwargs = KwArgsMap::default(); for (name, value) in args.remaining_keywords() { kwargs.insert(name, value.try_into_value(vm)?); } diff --git a/crates/vm/src/function/mod.rs b/crates/vm/src/function/mod.rs index 7eb87fea3..2ec1d09e8 100644 --- a/crates/vm/src/function/mod.rs +++ b/crates/vm/src/function/mod.rs @@ -11,8 +11,8 @@ mod protocol; mod time; pub use argument::{ - ArgumentError, FromArgOptional, FromArgs, FuncArgs, IntoFuncArgs, KwArgs, OptionalArg, - OptionalOption, PosArgs, + ArgumentError, FromArgOptional, FromArgs, FuncArgs, IntoFuncArgs, KwArgs, KwArgsMap, + OptionalArg, OptionalOption, PosArgs, }; pub use arithmetic::{PyArithmeticValue, PyComparisonValue}; pub use buffer::{ArgAsciiBuffer, ArgBytesLike, ArgMemoryBuffer, ArgStrOrBytesLike}; diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index 9744d4ae9..a9b8c7be1 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -4,10 +4,10 @@ use crate::common::linked_list::LinkedList; use crate::common::lock::{PyMutex, PyRwLock}; -use crate::object::{GC_PERMANENT, GC_UNTRACKED, GcLink}; +use crate::object::{GC_NO_OWNER, GC_PERMANENT, GC_UNTRACKED, GcLink, GcOwner}; use crate::{AsObject, PyObject, PyObjectRef}; use core::ptr::NonNull; -use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicUsize, Ordering}; use std::collections::HashSet; fn elapsed_secs( @@ -56,10 +56,12 @@ pub struct GcStats { pub duration: f64, } -/// A single GC generation with intrusive linked list +/// One generation's collection policy and statistics, per interpreter. +/// +/// The objects themselves live in the process-wide lists on [`GcState`], so the +/// occupancy count sits there; what an interpreter owns is when to collect and +/// what its own collections have done. pub struct GcGeneration { - /// Number of objects in this generation - count: AtomicUsize, /// Threshold for triggering collection threshold: AtomicU32, /// Collection statistics @@ -70,7 +72,6 @@ impl GcGeneration { #[must_use] pub const fn new(threshold: u32) -> Self { Self { - count: AtomicUsize::new(0), threshold: AtomicU32::new(threshold), stats: PyMutex::new(GcStats { collections: 0, @@ -82,16 +83,14 @@ impl GcGeneration { } } - pub fn count(&self) -> usize { - self.count.load(Ordering::SeqCst) - } - + /// Relaxed: this is policy read once per allocation, and a collection + /// racing `gc.set_threshold()` may use either value. pub fn threshold(&self) -> u32 { - self.threshold.load(Ordering::SeqCst) + self.threshold.load(Ordering::Relaxed) } pub fn set_threshold(&self, value: u32) { - self.threshold.store(value, Ordering::SeqCst); + self.threshold.store(value, Ordering::Relaxed); } pub fn stats(&self) -> GcStats { @@ -131,6 +130,26 @@ impl GcGeneration { } } +/// Drop one from a generation's occupancy. +/// +/// A collection resets the counts of the generations it emptied, but it only +/// empties its own interpreter's objects; another interpreter's stay behind with +/// the count already zeroed, and untracking one of those must not wrap. +fn release_count(count: &AtomicUsize) { + if count.load(Ordering::Relaxed) > 0 { + count.fetch_sub(1, Ordering::Relaxed); + } +} + +/// Whether `owner`'s collections act on `obj`. +/// +/// Objects with no owner — everything the shared context allocates, and anything +/// allocated with no interpreter current — belong to all of them. +fn is_owned_by(obj: &PyObject, owner: GcOwner) -> bool { + let obj_owner = obj.gc_owner(); + obj_owner == owner || obj_owner == GC_NO_OWNER +} + /// Wrapper for NonNull to impl Hash/Eq for use in temporary collection sets. /// Only used within collect_inner, never shared across threads. #[derive(Clone, Copy, PartialEq, Eq, Hash)] @@ -146,41 +165,83 @@ struct GcPtr(NonNull); /// well-defined while all other threads are parked at a safepoint. Restarting /// happens explicitly once the snapshot has pinned every object; `Drop` is a /// backstop that also restarts on the early-return paths. +/// +/// A collection acts on one interpreter's objects, but its candidates include +/// the ones no interpreter owns, which every interpreter can reference and so +/// incref. Reading a refcount that another interpreter is changing is what +/// makes an object look unreachable when it is not, so every live interpreter +/// is stopped, not just the collecting one. Stopping in `runtime` id order +/// keeps exclusion acquisition ordered; the `collecting` mutex additionally +/// serializes collections process-wide, so no second collector can take these +/// exclusions in another order. #[cfg(feature = "threading")] struct CollectStopTheWorld { - vm: *const crate::VirtualMachine, - stopped: bool, + /// Stopped interpreter states, in stop order. Held as strong references so + /// an interpreter cannot be dropped between stop and restart, and kept past + /// the restart so that releasing the last one — which frees that + /// interpreter's objects, and so removes them from these lists — happens + /// after the collection has let go of the generation locks. + stopped: Vec>, + /// Keeps interpreters from registering between the snapshot below and the + /// restart. One registered in that window would be missing from `stopped`, + /// so its bootstrap would keep running — and mutating the shared generation + /// lists — while this collection reads them. + admission: Option>, + restarted: bool, } #[cfg(feature = "threading")] impl CollectStopTheWorld { - /// Request stop-the-world when the current thread has an attached VM. - /// Falls back to no barrier when no VM is attached (the tracked-object - /// reads then run without other threads only if the caller guarantees it). + /// Request stop-the-world on every live interpreter when the current thread + /// has an attached VM. Falls back to no barrier when no VM is attached (the + /// tracked-object reads then run without other threads only if the caller + /// guarantees it). fn new() -> Self { - let vm = crate::vm::thread::try_with_current_vm(|vm| { - vm.state.stop_the_world.stop_the_world(vm); - vm as *const crate::VirtualMachine - }); - match vm { - Some(vm) => Self { vm, stopped: true }, - None => Self { - vm: core::ptr::null(), - stopped: false, - }, + // No attached VM means no interpreter is running Python on this thread; + // keep the historical no-barrier fallback. + if !crate::vm::thread::current_vm_is_set() { + return Self { + stopped: Vec::new(), + admission: None, + restarted: true, + }; } + + // Accumulate into a live `Self` rather than a bare Vec: if a later + // `stop_the_world` unwinds, dropping this guard restarts the + // interpreters already stopped, instead of leaving their threads parked + // and their exclusion held forever. + let mut guard = Self { + stopped: Vec::new(), + admission: Some(crate::vm::runtime::lock_admission_for_stop()), + restarted: false, + }; + for state in crate::vm::runtime::live_interpreter_states() { + state.stop_the_world.stop_the_world(&state); + guard.stopped.push(state); + } + guard } /// Restart the world. Idempotent. fn restart(&mut self) { - if self.stopped { - // SAFETY: the current thread stays attached to this VM for the - // whole collection — the VM is never popped from the thread's VM - // stack while collecting — so the pointer is valid here. - let vm = unsafe { &*self.vm }; - vm.state.stop_the_world.start_the_world(vm); - self.stopped = false; + if self.restarted { + return; } + self.restarted = true; + // Reverse of the stop order. The references stay until this guard is + // dropped; see the field comment. + for state in self.stopped.iter().rev() { + state.stop_the_world.start_the_world(state); + } + // Nothing is parked any more, so registration may resume. + self.admission = None; + } + + /// Whether this collection actually stopped the world. + #[cfg(all(unix, debug_assertions))] + fn is_stopped(&self) -> bool { + !self.stopped.is_empty() } } @@ -191,29 +252,35 @@ impl Drop for CollectStopTheWorld { } } -/// Global GC state +/// The process-wide object lists every interpreter's collections walk. +/// +/// Interpreter-owned policy and results live in [`GcInterpreterState`]; what is +/// here is shared because the lists are: an object is untracked from +/// `default_dealloc`, where no interpreter is in scope, so it has to be findable +/// without one. pub struct GcState { - /// 3 generations (0 = youngest, 2 = oldest) - pub generations: [GcGeneration; 3], - /// Permanent generation (frozen objects) - pub permanent: GcGeneration, - /// GC enabled flag - pub enabled: AtomicBool, /// Per-generation intrusive linked lists for object tracking. /// Objects start in gen0, survivors are promoted to gen1, then gen2. generation_lists: [PyRwLock>; 3], /// Frozen/permanent objects (excluded from normal GC) permanent_list: PyRwLock>, - /// Debug flags - pub debug: AtomicU32, - /// gc.garbage list (uncollectable objects with __del__) - pub garbage: PyMutex>, - /// gc.callbacks list - pub callbacks: PyMutex>, + /// Number of tracked objects per generation, across all interpreters. + /// + /// Advisory: they drive the collection threshold and `gc.get_count()`, and + /// the generation locks — not these counters — order the list changes they + /// describe. Every access is therefore relaxed, which keeps the tracking and + /// untracking of every object off the barrier path. + counts: [AtomicUsize; 3], + /// Number of frozen objects. Advisory, like `counts`. + permanent_count: AtomicUsize, /// Mutex for collection (prevents concurrent collections) collecting: PyMutex<()>, - /// Allocation counter for gen0 - alloc_count: AtomicUsize, + /// Next `gc_owner` tag to hand to an interpreter. + next_owner: AtomicU16, + /// Tags of interpreters that are gone. Their objects outlived them, so a + /// collection adopts them — tags them `GC_NO_OWNER` again — as it walks, + /// rather than leaving them for a collector that will never come. + retired: PyMutex>, } // SAFETY: All fields are either inherently Send/Sync (atomics, RwLock, Mutex) or protected by PyMutex. @@ -233,103 +300,70 @@ impl GcState { #[must_use] pub const fn new() -> Self { Self { - generations: [ - GcGeneration::new(2000), // young - GcGeneration::new(10), // old[0] - GcGeneration::new(0), // old[1] - ], - permanent: GcGeneration::new(0), - enabled: AtomicBool::new(true), generation_lists: [ PyRwLock::new(LinkedList::new()), PyRwLock::new(LinkedList::new()), PyRwLock::new(LinkedList::new()), ], permanent_list: PyRwLock::new(LinkedList::new()), - debug: AtomicU32::new(0), - garbage: PyMutex::new(Vec::new()), - callbacks: PyMutex::new(Vec::new()), + counts: [ + AtomicUsize::new(0), + AtomicUsize::new(0), + AtomicUsize::new(0), + ], + permanent_count: AtomicUsize::new(0), collecting: PyMutex::new(()), - alloc_count: AtomicUsize::new(0), + next_owner: AtomicU16::new(GC_NO_OWNER + 1), + retired: PyMutex::new(Vec::new()), } } - /// Check if GC is enabled - pub fn is_enabled(&self) -> bool { - self.enabled.load(Ordering::SeqCst) + /// Reserve a tag for a new interpreter. Tags are never reused; exhausting + /// the tag space falls back to `GC_NO_OWNER`, which costs isolation but + /// stays correct, rather than aliasing a live interpreter. + fn alloc_owner(&self) -> GcOwner { + self.next_owner + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |next| { + next.checked_add(1) + }) + .unwrap_or(GC_NO_OWNER) } - /// Enable GC - pub fn enable(&self) { - self.enabled.store(true, Ordering::SeqCst); - } - - /// Disable GC - pub fn disable(&self) { - self.enabled.store(false, Ordering::SeqCst); - } - - /// Get debug flags - pub fn get_debug(&self) -> GcDebugFlags { - GcDebugFlags::from_bits_truncate(self.debug.load(Ordering::SeqCst)) - } - - /// Set debug flags - pub fn set_debug(&self, flags: GcDebugFlags) { - self.debug.store(flags.bits(), Ordering::SeqCst); - } - - /// Get thresholds for all generations - pub fn get_threshold(&self) -> (u32, u32, u32) { - ( - self.generations[0].threshold(), - self.generations[1].threshold(), - self.generations[2].threshold(), - ) - } - - /// Set thresholds - pub fn set_threshold(&self, t0: u32, t1: Option, t2: Option) { - self.generations[0].set_threshold(t0); - if let Some(t1) = t1 { - self.generations[1].set_threshold(t1); - } - if let Some(t2) = t2 { - self.generations[2].set_threshold(t2); + /// Record that `owner`'s interpreter is gone, so the next collection adopts + /// whatever it left behind. Retagging the objects here would mean walking + /// every list under an interpreter drop, which happens while a collection + /// holds the collecting lock. + fn retire_owner(&self, owner: GcOwner) { + if owner == GC_NO_OWNER { + return; } + self.retired.lock().push(owner); } - /// Get counts for all generations + /// Get counts for all generations. Tracked objects are shared, so these are + /// process-wide even though the thresholds they are compared against are + /// per interpreter. pub fn get_count(&self) -> (usize, usize, usize) { ( - self.generations[0].count(), - self.generations[1].count(), - self.generations[2].count(), + self.counts[0].load(Ordering::Relaxed), + self.counts[1].load(Ordering::Relaxed), + self.counts[2].load(Ordering::Relaxed), ) } - /// Get statistics for all generations - pub fn get_stats(&self) -> [GcStats; 3] { - [ - self.generations[0].stats(), - self.generations[1].stats(), - self.generations[2].stats(), - ] - } - - /// Track a new object (add to gen0). + /// Track a new object (add to gen0) as owned by `owner`. /// O(1) — intrusive linked list push_front, no hashing. /// /// # Safety /// obj must be a valid pointer to a PyObject - pub unsafe fn track_object(&self, obj: NonNull) { + pub unsafe fn track_object(&self, obj: NonNull, owner: GcOwner) { let obj_ref = unsafe { obj.as_ref() }; obj_ref.set_gc_tracked(); obj_ref.set_gc_generation(0); + obj_ref.set_gc_owner(owner); self.generation_lists[0].write().push_front(obj); - self.generations[0].count.fetch_add(1, Ordering::SeqCst); - self.alloc_count.fetch_add(1, Ordering::SeqCst); + self.counts[0].fetch_add(1, Ordering::Relaxed); } /// Untrack an object (remove from GC lists). @@ -348,10 +382,10 @@ impl GcState { ( &self.generation_lists[obj_gen as usize] as &PyRwLock>, - &self.generations[obj_gen as usize].count, + &self.counts[obj_gen as usize], ) } else if obj_gen == GC_PERMANENT { - (&self.permanent_list, &self.permanent.count) + (&self.permanent_list, &self.permanent_count) } else { return; // GC_UNTRACKED or unknown — already untracked }; @@ -363,7 +397,7 @@ impl GcState { continue; // Retry with the updated generation } if unsafe { list.remove(obj) }.is_some() { - count.fetch_sub(1, Ordering::SeqCst); + release_count(count); obj_ref.clear_gc_tracked(); obj_ref.set_gc_generation(GC_UNTRACKED); } else { @@ -381,14 +415,18 @@ impl GcState { } } - /// Get tracked objects (for gc.get_objects) - /// If generation is None, returns all tracked objects. - /// If generation is Some(n), returns objects in generation n only. - pub fn get_objects(&self, generation: Option) -> Vec { + /// Get the objects `owner` tracks (for gc.get_objects), plus the ones no + /// interpreter owns. + /// If generation is None, returns all such objects. + /// If generation is Some(n), returns those in generation n only. + pub fn get_objects(&self, generation: Option, owner: GcOwner) -> Vec { fn collect_from_list( list: &LinkedList, + owner: GcOwner, ) -> impl Iterator + '_ { - list.iter().filter_map(|obj| obj.try_to_owned()) + list.iter() + .filter(move |obj| is_owned_by(obj, owner)) + .filter_map(|obj| obj.try_to_owned()) } match generation { @@ -396,14 +434,14 @@ impl GcState { // Return all tracked objects from all generations + permanent let mut result = Vec::new(); for gen_list in &self.generation_lists { - result.extend(collect_from_list(&gen_list.read())); + result.extend(collect_from_list(&gen_list.read(), owner)); } - result.extend(collect_from_list(&self.permanent_list.read())); + result.extend(collect_from_list(&self.permanent_list.read(), owner)); result } Some(g) if (0..=2).contains(&g) => { let guard = self.generation_lists[g as usize].read(); - collect_from_list(&guard).collect() + collect_from_list(&guard, owner).collect() } _ => Vec::new(), } @@ -412,14 +450,14 @@ impl GcState { /// Check if automatic GC should run and run it if needed. /// Called after object allocation. /// Returns true if GC was run, false otherwise. - pub fn maybe_collect(&self) -> bool { - if !self.is_enabled() { + fn maybe_collect(&self, gc: &GcInterpreterState) -> bool { + if !gc.is_enabled() { return false; } // Check gen0 threshold - let count0 = self.generations[0].count.load(Ordering::SeqCst) as u32; - let threshold0 = self.generations[0].threshold(); + let count0 = self.counts[0].load(Ordering::Relaxed) as u32; + let threshold0 = gc.generations[0].threshold(); if threshold0 > 0 && count0 >= threshold0 { #[cfg(feature = "threading")] { @@ -435,7 +473,7 @@ impl GcState { // thread whose frames could be read mid-mutation, so collect inline. #[cfg(not(feature = "threading"))] { - self.collect(0); + self.collect_inner(gc, 0, false); return true; } } @@ -443,18 +481,13 @@ impl GcState { false } - /// Perform garbage collection on the given generation - pub fn collect(&self, generation: usize) -> CollectResult { - self.collect_inner(generation, false) - } - - /// Force collection even if GC is disabled (for manual gc.collect() calls) - pub fn collect_force(&self, generation: usize) -> CollectResult { - self.collect_inner(generation, true) - } - - fn collect_inner(&self, generation: usize, force: bool) -> CollectResult { - if !force && !self.is_enabled() { + fn collect_inner( + &self, + gc: &GcInterpreterState, + generation: usize, + force: bool, + ) -> CollectResult { + if !force && !gc.is_enabled() { return CollectResult::default(); } @@ -473,7 +506,7 @@ impl GcState { core::sync::atomic::fence(Ordering::SeqCst); let generation = generation.min(2); - let debug = self.get_debug(); + let debug = gc.get_debug(); // Clear the method cache to release strong references that // might prevent cycle collection (_PyType_ClearCache). @@ -511,26 +544,56 @@ impl GcState { .map(|i| self.generation_lists[i].read()) .collect(); + // Only this interpreter's objects, plus the ones no interpreter owns. + // Another interpreter's objects stay out of the candidate set, so they + // act as external roots: anything they reference survives this pass. + let owner = gc.owner; + // Sorted so that the test below, which every scanned object pays for, + // stays logarithmic in the number of interpreters that have been + // dropped instead of linear. + let retired = { + let mut retired = self.retired.lock().clone(); + retired.sort_unstable(); + retired + }; let mut collecting: HashSet = HashSet::new(); for gen_list in &gen_locks { for obj in gen_list.iter() { - if obj.strong_count() > 0 { + if retired.binary_search(&obj.gc_owner()).is_ok() { + obj.set_gc_owner(GC_NO_OWNER); + } + if obj.strong_count() > 0 && is_owned_by(obj, owner) { collecting.insert(GcPtr(NonNull::from(obj))); } } } + // A full collection is the only one that sees every generation, so it + // is where adoption finishes and the tags stop being tracked. + if generation == 2 && !retired.is_empty() { + for obj in self.permanent_list.read().iter() { + if retired.binary_search(&obj.gc_owner()).is_ok() { + obj.set_gc_owner(GC_NO_OWNER); + } + } + // Only the tags this scan saw: one retired while it ran still has + // objects nobody has adopted. + self.retired + .lock() + .retain(|tag| retired.binary_search(tag).is_err()); + } + if collecting.is_empty() { // Reset counts for generations whose objects were promoted away. // For gen2 (oldest), survivors stay in-place so don't reset gen2 count. let reset_end = if generation >= 2 { 2 } else { generation + 1 }; for i in 0..reset_end { - self.generations[i].count.store(0, Ordering::SeqCst); + self.counts[i].store(0, Ordering::Relaxed); } let duration = elapsed_secs(start_time); - self.generations[generation].update_stats(0, 0, 0, duration); + gc.generations[generation].update_stats(0, 0, 0, duration); return CollectResult { collected: 0, uncollectable: 0, @@ -638,7 +701,7 @@ impl GcState { // because stack-allocated frames update only CURRENT_FRAME (via // set_current_frame_nosave), not top_frame. #[cfg(all(unix, feature = "threading", debug_assertions))] - if stw.stopped { + if stw.is_stopped() { let unreachable_set: HashSet = unreachable.iter().copied().collect(); let mut cur = crate::vm::thread::get_current_frame(); while !cur.is_null() { @@ -701,12 +764,12 @@ impl GcState { self.promote_survivors(generation, &survivor_refs); let reset_end = if generation >= 2 { 2 } else { generation + 1 }; for i in 0..reset_end { - self.generations[i].count.store(0, Ordering::SeqCst); + self.counts[i].store(0, Ordering::Relaxed); } let duration = elapsed_secs(start_time); - self.generations[generation].update_stats(0, 0, candidates, duration); + gc.generations[generation].update_stats(0, 0, candidates, duration); return CollectResult { collected: 0, uncollectable: 0, @@ -724,12 +787,12 @@ impl GcState { self.promote_survivors(generation, &survivor_refs); let reset_end = if generation >= 2 { 2 } else { generation + 1 }; for i in 0..reset_end { - self.generations[i].count.store(0, Ordering::SeqCst); + self.counts[i].store(0, Ordering::Relaxed); } let duration = elapsed_secs(start_time); - self.generations[generation].update_stats(0, 0, candidates, duration); + gc.generations[generation].update_stats(0, 0, candidates, duration); return CollectResult { collected: 0, uncollectable: 0, @@ -849,7 +912,7 @@ impl GcState { } if debug.contains(GcDebugFlags::SAVEALL) { - let mut garbage_guard = self.garbage.lock(); + let mut garbage_guard = gc.garbage.lock(); for obj_ref in &truly_dead { garbage_guard.push(obj_ref.clone()); } @@ -926,7 +989,10 @@ impl GcState { reason = "Iteration order doesn't matter here" )] for &ptr in &late_resurrected { - unsafe { self.track_object(ptr.0) }; + // Re-tracking a resurrected object: it keeps the owner it + // was allocated under. + let owner = unsafe { ptr.0.as_ref() }.gc_owner(); + unsafe { self.track_object(ptr.0, owner) }; } } rustpython_common::refcount::with_deferred_drops(|| { @@ -950,12 +1016,12 @@ impl GcState { // For gen2 (oldest), survivors stay in-place so don't reset gen2 count. let reset_end = if generation >= 2 { 2 } else { generation + 1 }; for i in 0..reset_end { - self.generations[i].count.store(0, Ordering::SeqCst); + self.counts[i].store(0, Ordering::Relaxed); } let duration = elapsed_secs(start_time); - self.generations[generation].update_stats(collected, 0, candidates, duration); + gc.generations[generation].update_stats(collected, 0, candidates, duration); CollectResult { collected, @@ -998,14 +1064,10 @@ impl GcState { } if unsafe { src.remove(ptr) }.is_some() { - self.generations[src_gen] - .count - .fetch_sub(1, Ordering::SeqCst); + release_count(&self.counts[src_gen]); dst.push_front(ptr); - self.generations[next_gen] - .count - .fetch_add(1, Ordering::SeqCst); + self.counts[next_gen].fetch_add(1, Ordering::Relaxed); obj.set_gc_generation(next_gen as u8); } @@ -1015,45 +1077,66 @@ impl GcState { /// Get count of frozen objects pub fn get_freeze_count(&self) -> usize { - self.permanent.count() + self.permanent_count.load(Ordering::Relaxed) } - /// Freeze all tracked objects (move to permanent generation). + /// Freeze the objects `owner` could collect (move them to the permanent + /// generation). /// Lock order: generation_lists[i] → permanent_list (consistent with unfreeze). - pub fn freeze(&self) { + fn freeze(&self, owner: GcOwner) { let mut count = 0usize; for (gen_idx, gen_list) in self.generation_lists.iter().enumerate() { let mut list = gen_list.write(); let mut perm = self.permanent_list.write(); - while let Some(ptr) = list.pop_front() { + let moving: Vec<_> = list + .iter() + .filter(|obj| is_owned_by(obj, owner)) + .map(NonNull::from) + .collect(); + for ptr in moving { + if unsafe { list.remove(ptr) }.is_none() { + continue; + } perm.push_front(ptr); unsafe { ptr.as_ref().set_gc_generation(GC_PERMANENT) }; count += 1; + release_count(&self.counts[gen_idx]); } - self.generations[gen_idx].count.store(0, Ordering::SeqCst); } - self.permanent.count.fetch_add(count, Ordering::SeqCst); + self.permanent_count.fetch_add(count, Ordering::Relaxed); } - /// Unfreeze all objects (move from permanent to gen2). + /// Unfreeze the objects `owner` froze (move them from permanent to gen2). /// Lock order: generation_lists[2] → permanent_list (consistent with freeze). - pub fn unfreeze(&self) { + fn unfreeze(&self, owner: GcOwner) { let mut count = 0usize; { let mut gen2 = self.generation_lists[2].write(); let mut perm_list = self.permanent_list.write(); - while let Some(ptr) = perm_list.pop_front() { + let moving: Vec<_> = perm_list + .iter() + .filter(|obj| is_owned_by(obj, owner)) + .map(NonNull::from) + .collect(); + for ptr in moving { + if unsafe { perm_list.remove(ptr) }.is_none() { + continue; + } gen2.push_front(ptr); unsafe { ptr.as_ref().set_gc_generation(2) }; count += 1; } - self.permanent.count.store(0, Ordering::SeqCst); + let _ = self.permanent_count.fetch_update( + Ordering::Relaxed, + Ordering::Relaxed, + |permanent| Some(permanent.saturating_sub(count)), + ); } - self.generations[2].count.fetch_add(count, Ordering::SeqCst); + self.counts[2].fetch_add(count, Ordering::Relaxed); } /// Reset all locks to unlocked state after fork(). @@ -1070,13 +1153,7 @@ impl GcState { unsafe { reinit_mutex_after_fork(&self.collecting); - reinit_mutex_after_fork(&self.garbage); - reinit_mutex_after_fork(&self.callbacks); - - for generation in &self.generations { - generation.reinit_stats_after_fork(); - } - self.permanent.reinit_stats_after_fork(); + reinit_mutex_after_fork(&self.retired); for rw in &self.generation_lists { reinit_rwlock_after_fork(rw); @@ -1086,11 +1163,196 @@ impl GcState { } } +/// Per-interpreter garbage collector state (≈ `PyInterpreterState.gc`). +/// +/// The generation lists are process-wide (see [`GcState`]); what an interpreter +/// owns is the policy applied to them and the results — which objects its +/// collections consider, whether they run automatically, and where uncollectable +/// objects end up. +pub struct GcInterpreterState { + /// Tag written into every object this interpreter tracks. + owner: GcOwner, + /// Per-generation thresholds and statistics. + pub generations: [GcGeneration; 3], + /// GC enabled flag + enabled: AtomicBool, + /// Debug flags + debug: AtomicU32, + /// Uncollectable objects saved by this interpreter's collections, drained + /// into `py_garbage` by `gc.collect()`. + pub garbage: PyMutex>, + /// `gc.garbage` + pub py_garbage: crate::builtins::PyListRef, + /// `gc.callbacks` + pub py_callbacks: crate::builtins::PyListRef, +} + +impl GcInterpreterState { + pub fn new(ctx: &crate::vm::Context) -> Self { + Self { + owner: gc_state().alloc_owner(), + generations: [ + GcGeneration::new(2000), // young + GcGeneration::new(10), // old[0] + GcGeneration::new(0), // old[1] + ], + enabled: AtomicBool::new(true), + debug: AtomicU32::new(0), + garbage: PyMutex::new(Vec::new()), + py_garbage: ctx.new_list(Vec::new()), + py_callbacks: ctx.new_list(Vec::new()), + } + } + + /// Check if GC is enabled. + /// + /// Relaxed, like [`GcGeneration::threshold`]: it is read once per + /// allocation, and an allocation racing `gc.disable()` may use either value. + pub fn is_enabled(&self) -> bool { + self.enabled.load(Ordering::Relaxed) + } + + /// Enable GC + pub fn enable(&self) { + self.enabled.store(true, Ordering::Relaxed); + } + + /// Disable GC + pub fn disable(&self) { + self.enabled.store(false, Ordering::Relaxed); + } + + /// Get debug flags + pub fn get_debug(&self) -> GcDebugFlags { + GcDebugFlags::from_bits_truncate(self.debug.load(Ordering::SeqCst)) + } + + /// Set debug flags + pub fn set_debug(&self, flags: GcDebugFlags) { + self.debug.store(flags.bits(), Ordering::SeqCst); + } + + /// Get thresholds for all generations + pub fn get_threshold(&self) -> (u32, u32, u32) { + ( + self.generations[0].threshold(), + self.generations[1].threshold(), + self.generations[2].threshold(), + ) + } + + /// Set thresholds + pub fn set_threshold(&self, t0: u32, t1: Option, t2: Option) { + self.generations[0].set_threshold(t0); + if let Some(t1) = t1 { + self.generations[1].set_threshold(t1); + } + if let Some(t2) = t2 { + self.generations[2].set_threshold(t2); + } + } + + /// Get statistics for all generations + pub fn get_stats(&self) -> [GcStats; 3] { + [ + self.generations[0].stats(), + self.generations[1].stats(), + self.generations[2].stats(), + ] + } + + /// Perform garbage collection on the given generation + pub fn collect(&self, generation: usize) -> CollectResult { + gc_state().collect_inner(self, generation, false) + } + + /// Force collection even if GC is disabled (for manual gc.collect() calls) + pub fn collect_force(&self, generation: usize) -> CollectResult { + gc_state().collect_inner(self, generation, true) + } + + /// The tracked objects this interpreter can reach (for gc.get_objects). + pub fn get_objects(&self, generation: Option) -> Vec { + gc_state().get_objects(generation, self.owner) + } + + /// Move the objects this interpreter could collect into the permanent + /// generation. + pub fn freeze(&self) { + gc_state().freeze(self.owner); + } + + /// Move them back out of it. + pub fn unfreeze(&self) { + gc_state().unfreeze(self.owner); + } + + /// Reset this interpreter's GC locks to unlocked state after fork(). + /// + /// # Safety + /// Must only be called after fork() in the child process when no other + /// threads exist. The calling thread must NOT hold any of these locks. + #[cfg(all(unix, feature = "threading"))] + pub unsafe fn reinit_after_fork(&self) { + unsafe { + crate::common::lock::reinit_mutex_after_fork(&self.garbage); + for generation in &self.generations { + generation.reinit_stats_after_fork(); + } + } + } +} + +impl Drop for GcInterpreterState { + fn drop(&mut self) { + // Objects this interpreter tracked can outlive it (another interpreter + // may still hold one). Clearing the tag hands them to every collection + // instead of stranding them. The tag itself is not handed back: it stays + // retired so that a later interpreter cannot inherit these objects. + gc_state().retire_owner(self.owner); + } +} + +/// The tag `track_object` should write for the interpreter running now. +#[must_use] +pub fn current_owner() -> GcOwner { + // SAFETY: the pointee is owned by the `PyGlobalState` of the VM on top of + // this thread's VM stack, which outlives the section this call runs in. + crate::vm::thread::current_gc_state().map_or(GC_NO_OWNER, |gc| unsafe { gc.as_ref() }.owner) +} + +/// Track a freshly allocated object under the interpreter running now, and let +/// it collect if the allocation pushed gen0 past its threshold. +/// +/// # Safety +/// obj must be a valid pointer to a PyObject that is not already tracked. +pub(crate) unsafe fn track_new_object(obj: NonNull) { + let state = gc_state(); + let Some(gc) = crate::vm::thread::current_gc_state() else { + // No interpreter is running: the shared context builds its own objects + // this way. They are left unowned, so every interpreter collects them. + unsafe { state.track_object(obj, GC_NO_OWNER) }; + return; + }; + // SAFETY: as in `current_owner`. + let gc = unsafe { gc.as_ref() }; + unsafe { state.track_object(obj, gc.owner) }; + state.maybe_collect(gc); +} + /// Get a reference to the GC state. /// /// In threading mode this is a true global (OnceLock). /// In non-threading mode this is thread-local, because PyRwLock/PyMutex /// use Cell-based locks that are not Sync. +/// +/// Every interpreter's tracked objects live in these lists, because untracking +/// happens in `default_dealloc`, where no interpreter is in scope to route to. +/// What a collection *acts on* is still one interpreter's own objects, selected +/// by the `gc_owner` tag; [`GcInterpreterState`] holds the rest of the state +/// that goes with that. The counts here, and so `gc.get_count()` and +/// `gc.get_freeze_count()`, stay process-wide: they measure how full these +/// lists are. pub fn gc_state() -> &'static GcState { rustpython_common::static_cell! { static GC_STATE: GcState; @@ -1102,18 +1364,21 @@ pub fn gc_state() -> &'static GcState { mod tests { use super::*; + fn interpreter_state() -> GcInterpreterState { + GcInterpreterState::new(crate::vm::Context::genesis()) + } + #[test] fn gc_state_default() { - let state = GcState::new(); + let state = interpreter_state(); assert!(state.is_enabled()); assert_eq!(state.get_debug(), GcDebugFlags::empty()); assert_eq!(state.get_threshold(), (2000, 10, 0)); - assert_eq!(state.get_count(), (0, 0, 0)); } #[test] fn gc_enable_disable() { - let state = GcState::new(); + let state = interpreter_state(); assert!(state.is_enabled()); state.disable(); assert!(!state.is_enabled()); @@ -1123,18 +1388,29 @@ mod tests { #[test] fn gc_threshold() { - let state = GcState::new(); + let state = interpreter_state(); state.set_threshold(100, Some(20), Some(30)); assert_eq!(state.get_threshold(), (100, 20, 30)); } #[test] fn gc_debug_flags() { - let state = GcState::new(); + let state = interpreter_state(); state.set_debug(GcDebugFlags::STATS | GcDebugFlags::COLLECTABLE); assert_eq!( state.get_debug(), GcDebugFlags::STATS | GcDebugFlags::COLLECTABLE ); } + + /// Live interpreters never share an owner tag, or their collections would + /// reach each other's objects. + #[test] + fn gc_owner_tags_are_distinct_while_live() { + let first = interpreter_state(); + let second = interpreter_state(); + assert_ne!(first.owner, second.owner); + assert_ne!(first.owner, GC_NO_OWNER); + assert_ne!(second.owner, GC_NO_OWNER); + } } diff --git a/crates/vm/src/lib.rs b/crates/vm/src/lib.rs index df67c9797..a15e30c34 100644 --- a/crates/vm/src/lib.rs +++ b/crates/vm/src/lib.rs @@ -109,7 +109,11 @@ pub use self::object::{ AsObject, Py, PyAtomicRef, PyExact, PyObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, PyStackRef, PyWeakRef, }; -pub use self::vm::{Context, Interpreter, InterpreterBuilder, Settings, VirtualMachine}; +pub use self::vm::runtime; +pub use self::vm::{ + Context, Interpreter, InterpreterBuilder, InterpreterInfo, InterpreterWhence, + MAIN_INTERPRETER_ID, Settings, VirtualMachine, +}; pub use rustpython_common as common; pub use rustpython_compiler_core::{bytecode, frozen}; diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index 0ee7a062e..1e36d57ab 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -303,6 +303,18 @@ bitflags::bitflags! { /// GC generation constants pub(crate) const GC_UNTRACKED: u8 = 0xFF; pub(crate) const GC_PERMANENT: u8 = 3; +/// Width of an interpreter's `gc_owner` tag. +/// +/// Sized to the padding the header alignment already forces, so the tag costs +/// no space on either pointer width. Running out of tags is not an error: an +/// interpreter that gets none uses [`GC_NO_OWNER`] and its objects stay +/// collectable by every interpreter, which is how they behaved before tagging. +pub(crate) type GcOwner = u16; + +/// `gc_owner` of an object that belongs to no single interpreter: everything +/// the shared context allocates, and anything allocated with no interpreter +/// current. Every interpreter collects these. +pub(crate) const GC_NO_OWNER: GcOwner = 0; /// Link implementation for GC intrusive linked list tracking pub(crate) struct GcLink; @@ -389,6 +401,10 @@ pub(super) struct PyInner { /// GC generation index (0-2=gen, GC_PERMANENT=permanent, GC_UNTRACKED=not tracked). /// Uses PyAtomic for interior mutability (writes happen through &self under list locks). pub(super) gc_generation: PyAtomic, + /// Interpreter that tracked this object, or `GC_NO_OWNER`. Written by + /// `track_object`; read to scope a collection to one interpreter. + /// Sits in what would otherwise be padding, so it costs no space. + pub(super) gc_owner: PyAtomic, /// Intrusive linked list pointers for GC generational tracking pub(super) gc_pointers: Pointers, @@ -398,6 +414,11 @@ pub(super) struct PyInner { } pub(crate) const SIZEOF_PYOBJECT_HEAD: usize = core::mem::size_of::>(); +// ref_count, vtable, gc_pointers (two) and typ are one word each; the gc bits, +// generation and owner share the word of padding their alignment forces. Adding +// to that group is free only while this holds. +const _: () = assert!(SIZEOF_PYOBJECT_HEAD == 6 * core::mem::size_of::()); + impl PyInner { /// Read type flags and member_count via raw pointers to avoid Stacked Borrows /// violations during bootstrap, where type objects have self-referential typ pointers. @@ -1052,6 +1073,16 @@ impl InstanceDict { self.d.read().clone() } + /// Run `f` on the dict without cloning it. + /// + /// For callers that only need to look at the dict — a predicate, a version + /// stamp — this drops the refcount round-trip [`Self::get`] pays. `f` runs + /// under the read guard, so it must not run Python or take this lock again. + #[inline] + pub(crate) fn with(&self, f: impl FnOnce(Option<&Py>) -> R) -> R { + f(self.d.read().as_deref()) + } + #[inline] pub(crate) fn set(&self, d: Option) { self.replace(d); @@ -1216,6 +1247,7 @@ impl PyInner { vtable: PyObjVTable::of::(), gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), + gc_owner: Radium::new(GC_NO_OWNER), gc_pointers: Pointers::new(), typ: PyAtomicRef::from(typ), payload, @@ -1228,6 +1260,7 @@ impl PyInner { vtable: PyObjVTable::of::(), gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), + gc_owner: Radium::new(GC_NO_OWNER), gc_pointers: Pointers::new(), typ: PyAtomicRef::from(typ), payload, @@ -1615,6 +1648,28 @@ impl PyObject { self.instance_dict().and_then(|d| d.get()) } + /// Whether this object currently has an instance dict, without cloning it. + /// + /// `false` both for an object with no dict slot and for one whose slot is + /// still empty, which is what `dict().is_none()` reports. + #[inline(always)] + pub fn has_instance_dict(&self) -> bool { + self.instance_dict() + .is_some_and(|d| d.with(|dict| dict.is_some())) + } + + /// Run `f` on the instance dict without cloning it; see [`InstanceDict::with`]. + #[inline(always)] + pub(crate) fn with_instance_dict( + &self, + f: impl FnOnce(Option<&Py>) -> R, + ) -> R { + match self.instance_dict() { + Some(d) => d.with(f), + None => f(None), + } + } + /// Set the dict field. Returns `Err(dict)` if this object does not have a dict field /// in the first place. pub fn set_dict(&self, dict: Option) -> Result<(), Option> { @@ -1734,6 +1789,20 @@ impl PyObject { self.0.gc_generation.store(generation, Ordering::Relaxed); } + /// The interpreter whose collections consider this object. + #[inline] + pub(crate) fn gc_owner(&self) -> GcOwner { + self.0.gc_owner.load(Ordering::Relaxed) + } + + /// Set the owning interpreter. Written by `track_object` before the object + /// enters a generation list, and reset to `GC_NO_OWNER` when the owning + /// interpreter goes away. + #[inline] + pub(crate) fn set_gc_owner(&self, owner: GcOwner) { + self.0.gc_owner.store(owner, Ordering::Relaxed); + } + /// _PyObject_GC_TRACK #[inline] pub(crate) fn set_gc_tracked(&self) { @@ -2392,12 +2461,11 @@ impl PyRef { if (::HAS_TRAVERSE || has_dict || is_heaptype) && !T::NEW_REF_UNTRACKED { - let gc = crate::gc_state::gc_state(); + // Tracks under the interpreter running now and collects if this + // allocation pushed gen0 past its threshold. unsafe { - gc.track_object(ptr.cast()); + crate::gc_state::track_new_object(ptr.cast()); } - // Check if automatic GC should run - gc.maybe_collect(); } Self { ptr } @@ -2645,6 +2713,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { vtable: PyObjVTable::of::(), gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), + gc_owner: Radium::new(GC_NO_OWNER), gc_pointers: Pointers::new(), payload: type_payload, }, @@ -2660,6 +2729,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { vtable: PyObjVTable::of::(), gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), + gc_owner: Radium::new(GC_NO_OWNER), gc_pointers: Pointers::new(), payload: object_payload, }, diff --git a/crates/vm/src/object/mod.rs b/crates/vm/src/object/mod.rs index b06957e1b..becfcabb1 100644 --- a/crates/vm/src/object/mod.rs +++ b/crates/vm/src/object/mod.rs @@ -9,5 +9,5 @@ pub use self::core::*; pub use self::ext::*; pub use self::payload::*; pub(crate) use core::SIZEOF_PYOBJECT_HEAD; -pub(crate) use core::{GC_PERMANENT, GC_UNTRACKED, GcLink}; +pub(crate) use core::{GC_NO_OWNER, GC_PERMANENT, GC_UNTRACKED, GcLink, GcOwner}; pub use traverse::{MaybeTraverse, Traverse, TraverseFn}; diff --git a/crates/vm/src/protocol/object.rs b/crates/vm/src/protocol/object.rs index 4974fca93..993f3442a 100644 --- a/crates/vm/src/protocol/object.rs +++ b/crates/vm/src/protocol/object.rs @@ -230,7 +230,6 @@ impl PyObject { dict: Option, vm: &VirtualMachine, ) -> PyResult> { - let name = name_str.as_wtf8(); let obj_cls = self.class(); let cls_attr_name = vm.ctx.interned_str(name_str); let cls_attr = match cls_attr_name.and_then(|name| obj_cls.get_attr(name)) { @@ -251,7 +250,9 @@ impl PyObject { let dict = dict.or_else(|| self.dict()); let attr = if let Some(dict) = dict { - dict.get_item_opt(name, vm)? + // `Py` rather than its `&Wtf8`: the key type carries the + // cached hash and compares interned keys by pointer. + dict.get_item_opt(name_str, vm)? } else { None }; diff --git a/crates/vm/src/stdlib/_ast/python.rs b/crates/vm/src/stdlib/_ast/python.rs index db92f20db..b6f794829 100644 --- a/crates/vm/src/stdlib/_ast/python.rs +++ b/crates/vm/src/stdlib/_ast/python.rs @@ -10,13 +10,11 @@ pub(crate) mod _ast { AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{PyDictRef, PySet, PyStr, PyTupleRef, PyType, PyTypeRef}, class::{PyClassImpl, StaticType}, - common::wtf8::Wtf8Buf, function::{ArgIterable, FuncArgs, KwArgs, PyMethodDef, PyMethodFlags}, stdlib::_ast::repr, types::{Constructor, Initializer}, warn, }; - use indexmap::IndexMap; #[pyattr] #[pyclass(module = "_ast", name = "AST")] #[derive(Debug, PyPayload)] @@ -295,7 +293,7 @@ pub(crate) mod _ast { .map_err(|_| vm.new_type_error("keywords must be strings"))?; Ok((key.as_wtf8().to_owned(), value)) }) - .collect::>>()?; + .collect::>>()?; let result = type_obj.call(FuncArgs::new(vec![], KwArgs::new(kwargs)), vm)?; Ok(result) } diff --git a/crates/vm/src/stdlib/_ctypes/structure.rs b/crates/vm/src/stdlib/_ctypes/structure.rs index 34f53f52d..1632d745d 100644 --- a/crates/vm/src/stdlib/_ctypes/structure.rs +++ b/crates/vm/src/stdlib/_ctypes/structure.rs @@ -1,6 +1,5 @@ use super::base::{CDATA_BUFFER_METHODS, PyCData, PyCField, StgInfo, StgInfoFlags}; use crate::builtins::{PyList, PyStr, PyTuple, PyType, PyTypeRef, PyUtf8Str}; -use crate::common::wtf8::Wtf8Buf; use crate::convert::ToPyObject; use crate::function::{FuncArgs, OptionalArg, PySetterValue}; use crate::protocol::{BufferDescriptor, PyBuffer, PyNumberMethods}; @@ -713,7 +712,7 @@ impl PyCStructure { self_obj: &Py, type_obj: &Py, args: &[PyObjectRef], - kwargs: &indexmap::IndexMap, + kwargs: &crate::function::KwArgsMap, index: usize, vm: &VirtualMachine, ) -> PyResult { diff --git a/crates/vm/src/stdlib/_ctypes/union.rs b/crates/vm/src/stdlib/_ctypes/union.rs index 727ad0118..8fe2e8348 100644 --- a/crates/vm/src/stdlib/_ctypes/union.rs +++ b/crates/vm/src/stdlib/_ctypes/union.rs @@ -1,7 +1,6 @@ use super::base::{CDATA_BUFFER_METHODS, StgInfoFlags}; use super::{PyCData, PyCField, StgInfo}; use crate::builtins::{PyList, PyStr, PyTuple, PyType, PyTypeRef, PyUtf8Str}; -use crate::common::wtf8::Wtf8Buf; use crate::convert::ToPyObject; use crate::function::{ArgBytesLike, FuncArgs, OptionalArg, PySetterValue}; use crate::protocol::{BufferDescriptor, PyBuffer}; @@ -582,7 +581,7 @@ impl PyCUnion { self_obj: &Py, type_obj: &Py, args: &[PyObjectRef], - kwargs: &indexmap::IndexMap, + kwargs: &crate::function::KwArgsMap, index: usize, vm: &VirtualMachine, ) -> PyResult { diff --git a/crates/vm/src/stdlib/_functools.rs b/crates/vm/src/stdlib/_functools.rs index 9b49e5645..944a2e8ab 100644 --- a/crates/vm/src/stdlib/_functools.rs +++ b/crates/vm/src/stdlib/_functools.rs @@ -15,7 +15,6 @@ mod _functools { recursion::ReprGuard, types::{Callable, Constructor, GetDescriptor, Representable}, }; - use indexmap::IndexMap; use rustpython_common::wtf8::Wtf8Buf; #[derive(FromArgs)] @@ -432,7 +431,7 @@ mod _functools { combined_args.extend(new_args_iter.cloned()); // Merge keywords from self.keywords and args.kwargs - let mut final_kwargs = IndexMap::new(); + let mut final_kwargs = crate::function::KwArgsMap::default(); // Add keywords from self.keywords for (key, value) in &*keywords { diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index ab1be4297..80085a822 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -4078,7 +4078,10 @@ mod _io { vm.new_runtime_error(format!("reentrant call inside {type_name}.__repr__")) ); }; - let Some(data) = zelf.data.lock() else { + // Detach while blocked, like `lock_opt`: another thread can be + // stopped holding this mutex, and blocking on it while attached + // would leave no safepoint for that stop to complete at. + let Some(data) = zelf.data.lock_wrapped(|do_lock| vm.allow_threads(do_lock)) else { // Reentrant call return Ok(vm.ctx.new_str(Wtf8Buf::from(format!("<{type_name}>")))); }; diff --git a/crates/vm/src/stdlib/_signal.rs b/crates/vm/src/stdlib/_signal.rs index 5879f8776..5abfd3275 100644 --- a/crates/vm/src/stdlib/_signal.rs +++ b/crates/vm/src/stdlib/_signal.rs @@ -177,7 +177,9 @@ pub(crate) mod _signal { module: &Py, vm: &VirtualMachine, ) { - if vm.state.config.settings.install_signal_handlers { + // Process-global signal disposition is owned by the main interpreter only. + // Subinterpreters (PEP 734) must not reinstall SIGINT / probe handlers. + if vm.state.is_main_interpreter() && vm.state.config.settings.install_signal_handlers { let sig_dfl = vm.new_pyobj(SIG_DFL as u8); let sig_ign = vm.new_pyobj(SIG_IGN as u8); diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 377c68dca..de942f947 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -796,9 +796,8 @@ pub(crate) mod _thread { } #[pyfunction] - fn _is_main_interpreter() -> bool { - // RustPython only has one interpreter - true + fn _is_main_interpreter(vm: &VirtualMachine) -> bool { + vm.state.is_main_interpreter() } /// Initialize the main thread ident. Should be called once at interpreter startup. @@ -1194,8 +1193,8 @@ pub(crate) mod _thread { { use core::sync::atomic::Ordering; let current_ident = get_ident(); - vm.state.stop_the_world.stop_the_world(vm); - scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } + vm.state.stop_the_world.stop_the_world(&vm.state); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); } let registry = vm.state.thread_frames.lock(); registry .iter() @@ -1218,26 +1217,10 @@ pub(crate) mod _thread { let iframe_ptr = slot.top_iframe.load(Ordering::Relaxed) as *const crate::frame::InterpreterFrame; if !iframe_ptr.is_null() { - // Materialize the entire frame chain and link - // retained_back so f_back works after STW ends. - let mut cur = iframe_ptr; - let mut child_fo: Option> = - None; - while !cur.is_null() { - let iframe = unsafe { &*cur }; - let fo = iframe.materialize(vm).to_owned(); - if let Some(child) = child_fo.take() { - let mut guard = child.iframe().cold().retained_back.lock(); - if guard.is_none() { - *guard = Some(fo.clone()); - } - } - child_fo = Some(fo); - cur = iframe.previous(); - } let iframe = unsafe { &*iframe_ptr }; - let fo = iframe.materialize(vm); - Some((*id, fo.to_owned())) + // SAFETY: world stopped -> owning thread parked. + let fo = unsafe { iframe.materialize_detached_chain(vm) }; + Some((*id, fo)) } else { None } @@ -1250,8 +1233,8 @@ pub(crate) mod _thread { { use core::sync::atomic::Ordering; let current_ident = get_ident(); - vm.state.stop_the_world.stop_the_world(vm); - scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } + vm.state.stop_the_world.stop_the_world(&vm.state); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); } let registry = vm.state.thread_frames.lock(); registry .iter() @@ -1267,24 +1250,10 @@ pub(crate) mod _thread { let iframe_ptr = slot.top_iframe.load(Ordering::Relaxed) as *const crate::frame::InterpreterFrame; if !iframe_ptr.is_null() { - let mut cur = iframe_ptr; - let mut child_fo: Option> = - None; - while !cur.is_null() { - let iframe = unsafe { &*cur }; - let fo = iframe.materialize(vm).to_owned(); - if let Some(child) = child_fo.take() { - let mut guard = child.iframe().cold().retained_back.lock(); - if guard.is_none() { - *guard = Some(fo.clone()); - } - } - child_fo = Some(fo); - cur = iframe.previous(); - } let iframe = unsafe { &*iframe_ptr }; - let fo = iframe.materialize(vm); - Some((*id, fo.to_owned())) + // SAFETY: world stopped -> owning thread parked. + let fo = unsafe { iframe.materialize_detached_chain(vm) }; + Some((*id, fo)) } else { // Fall back to frames stack for FrameObject-only path let frames = slot.frames.lock(); @@ -1386,6 +1355,19 @@ pub(crate) mod _thread { } } + /// Take a thread handle's completion mutex, detaching first. + /// + /// A joiner holds this mutex across its `allow_threads` wait, so it can + /// still hold it when stop-the-world stops it. An attached thread that + /// blocked on it would never reach a safepoint, so the stop could never + /// complete and the holder would never be resumed to release it. + fn lock_done<'a>( + lock: &'a parking_lot::Mutex, + vm: &VirtualMachine, + ) -> parking_lot::MutexGuard<'a, bool> { + vm.allow_threads(|| lock.lock()) + } + /// Reset a parking_lot::Mutex to unlocked state after fork. #[cfg(all(unix, feature = "host_env"))] fn reinit_parking_lot_mutex(mutex: &parking_lot::Mutex) { @@ -1468,7 +1450,7 @@ pub(crate) mod _thread { // Wait for thread completion using Condvar (supports timeout) // Loop to handle spurious wakeups let (lock, cvar) = &**done_event; - let mut done = lock.lock(); + let mut done = lock_done(lock, vm); // ThreadHandle_join semantics: self-join/finalizing checks // apply only while target thread has not reported it is exiting yet. @@ -1528,7 +1510,7 @@ pub(crate) mod _thread { drop(inner_guard); // Wait on done_event let (lock, cvar) = &**done_event; - let mut done = lock.lock(); + let mut done = lock_done(lock, vm); while !*done { vm.allow_threads(|| cvar.wait(&mut done)); } @@ -1590,7 +1572,7 @@ pub(crate) mod _thread { remove_from_shutdown_handles(vm, inner, done_event); let (lock, cvar) = &**done_event; - *lock.lock() = true; + *lock_done(lock, vm) = true; cvar.notify_all(); Ok(()) } @@ -1660,7 +1642,7 @@ pub(crate) mod _thread { // before returning True. let done = { let (lock, _) = &*self.done_event; - *lock.lock() + *lock_done(lock, vm) }; if !done { return Ok(false); @@ -1856,7 +1838,7 @@ pub(crate) mod _thread { // Starting a handle always resets the completion event. { let (done_lock, _) = &*handle.done_event; - *done_lock.lock() = false; + *lock_done(done_lock, vm) = false; } // Add non-daemon threads to shutdown registry so _shutdown() will wait for them @@ -1938,7 +1920,7 @@ pub(crate) mod _thread { // This must be LAST to ensure all cleanup is complete before join() returns { let (lock, cvar) = &*done_event_for_cleanup; - *lock.lock() = true; + *lock_done(lock, vm) = true; cvar.notify_all(); } } @@ -1973,7 +1955,7 @@ pub(crate) mod _thread { } { let (done_lock, done_cvar) = &*handle.done_event; - *done_lock.lock() = true; + *lock_done(done_lock, vm) = true; done_cvar.notify_all(); } if !daemon { diff --git a/crates/vm/src/stdlib/_winapi.rs b/crates/vm/src/stdlib/_winapi.rs index 0d54530d4..34e7b897e 100644 --- a/crates/vm/src/stdlib/_winapi.rs +++ b/crates/vm/src/stdlib/_winapi.rs @@ -8,7 +8,7 @@ mod _winapi { use crate::{ Py, PyObjectRef, PyPayload, PyResult, TryFromObject, VirtualMachine, builtins::PyStrRef, - common::lock::PyMutex, + common::lock::{PyMutex, PyMutexGuard}, convert::ToPyException, function::{ArgMapping, ArgSequence, OptionalArg}, types::Constructor, @@ -566,9 +566,18 @@ mod _winapi { .map_err(|e| e.to_pyexception(vm)) } + /// Take `inner`, detaching while blocked. + /// + /// `GetOverlappedResult` holds this mutex across its `allow_threads` + /// wait, so a stopped thread can still be holding it. Blocking on it + /// while attached would leave no safepoint for that stop to complete at. + fn lock_inner(&self, vm: &VirtualMachine) -> PyMutexGuard<'_, host_overlapped::Operation> { + vm.allow_threads(|| self.inner.lock()) + } + #[pymethod] fn GetOverlappedResult(&self, wait: bool, vm: &VirtualMachine) -> PyResult<(u32, u32)> { - let mut inner = self.inner.lock(); + let mut inner = self.lock_inner(vm); vm.allow_threads(|| inner.get_result(wait)) .map(|result| (result.transferred, result.error)) .map_err(|e| e.to_pyexception(vm)) @@ -576,7 +585,7 @@ mod _winapi { #[pymethod] fn getbuffer(&self, vm: &VirtualMachine) -> PyResult> { - let inner = self.inner.lock(); + let inner = self.lock_inner(vm); if !inner.is_completed() { return Err(vm.new_value_error( "can't get read buffer before GetOverlappedResult() signals the operation completed", @@ -589,13 +598,13 @@ mod _winapi { #[pymethod] fn cancel(&self, vm: &VirtualMachine) -> PyResult<()> { - let mut inner = self.inner.lock(); + let mut inner = self.lock_inner(vm); inner.cancel().map_err(|e| e.to_pyexception(vm)) } #[pygetset] - fn event(&self) -> isize { - let inner = self.inner.lock(); + fn event(&self, vm: &VirtualMachine) -> isize { + let inner = self.lock_inner(vm); inner.event() as isize } } diff --git a/crates/vm/src/stdlib/gc.rs b/crates/vm/src/stdlib/gc.rs index b0007b4c8..af861862e 100644 --- a/crates/vm/src/stdlib/gc.rs +++ b/crates/vm/src/stdlib/gc.rs @@ -23,20 +23,20 @@ mod gc { /// Enable automatic garbage collection. #[pyfunction] - fn enable() { - gc_state::gc_state().enable(); + fn enable(vm: &VirtualMachine) { + vm.state.gc.enable(); } /// Disable automatic garbage collection. #[pyfunction] - fn disable() { - gc_state::gc_state().disable(); + fn disable(vm: &VirtualMachine) { + vm.state.gc.disable(); } /// Return true if automatic gc is enabled. #[pyfunction] - fn isenabled() -> bool { - gc_state::gc_state().is_enabled() + fn isenabled(vm: &VirtualMachine) -> bool { + vm.state.gc.is_enabled() } /// Run a garbage collection. Returns the number of unreachable objects found. @@ -58,15 +58,14 @@ mod gc { invoke_callbacks(vm, "start", generation_num as usize, &Default::default()); // Manual gc.collect() should run even if GC is disabled - let gc = gc_state::gc_state(); + let gc = &vm.state.gc; let result = gc.collect_force(generation_num as usize); - // Move objects from gc_state.garbage to vm.ctx.gc_garbage (for DEBUG_SAVEALL) + // Publish what the collection saved as gc.garbage (for DEBUG_SAVEALL) { let mut state_garbage = gc.garbage.lock(); if !state_garbage.is_empty() { - let py_garbage = &vm.ctx.gc_garbage; - let mut garbage_vec = py_garbage.borrow_vec_mut(); + let mut garbage_vec = gc.py_garbage.borrow_vec_mut(); for obj in state_garbage.drain(..) { garbage_vec.push(obj); } @@ -82,7 +81,7 @@ mod gc { /// Return the current collection thresholds as a tuple. #[pyfunction] fn get_threshold(vm: &VirtualMachine) -> PyObjectRef { - let (t0, t1, t2) = gc_state::gc_state().get_threshold(); + let (t0, t1, t2) = vm.state.gc.get_threshold(); vm.ctx .new_tuple(vec![ vm.ctx.new_int(t0).into(), @@ -94,8 +93,13 @@ mod gc { /// Set the collection thresholds. #[pyfunction] - fn set_threshold(threshold0: u32, threshold1: OptionalArg, threshold2: OptionalArg) { - gc_state::gc_state().set_threshold( + fn set_threshold( + threshold0: u32, + threshold1: OptionalArg, + threshold2: OptionalArg, + vm: &VirtualMachine, + ) { + vm.state.gc.set_threshold( threshold0, threshold1.into_option(), threshold2.into_option(), @@ -117,20 +121,22 @@ mod gc { /// Return the current debugging flags. #[pyfunction] - fn get_debug() -> u32 { - gc_state::gc_state().get_debug().bits() + fn get_debug(vm: &VirtualMachine) -> u32 { + vm.state.gc.get_debug().bits() } /// Set the debugging flags. #[pyfunction] - fn set_debug(flags: u32) { - gc_state::gc_state().set_debug(gc_state::GcDebugFlags::from_bits_truncate(flags)); + fn set_debug(flags: u32, vm: &VirtualMachine) { + vm.state + .gc + .set_debug(gc_state::GcDebugFlags::from_bits_truncate(flags)); } /// Return a list of per-generation gc stats. #[pyfunction] fn get_stats(vm: &VirtualMachine) -> PyResult { - let stats = gc_state::gc_state().get_stats(); + let stats = vm.state.gc.get_stats(); let mut result = Vec::with_capacity(3); for stat in &stats { @@ -165,7 +171,7 @@ mod gc { { return Err(vm.new_value_error(format!("generation must be in range(0, 3), not {g}"))); } - let objects = gc_state::gc_state().get_objects(generation_opt); + let objects = vm.state.gc.get_objects(generation_opt); Ok(vm.ctx.new_list(objects)) } @@ -208,7 +214,7 @@ mod gc { let mut result = Vec::new(); // Scan all tracked objects across all generations - let all_objects = gc_state::gc_state().get_objects(None); + let all_objects = vm.state.gc.get_objects(None); for obj in all_objects { let obj_ptr = obj.as_ref() as *const crate::PyObject as usize; if stack_frames.contains(&obj_ptr) { @@ -241,14 +247,14 @@ mod gc { /// Freeze all objects tracked by gc. #[pyfunction] - fn freeze() { - gc_state::gc_state().freeze(); + fn freeze(vm: &VirtualMachine) { + vm.state.gc.freeze(); } /// Unfreeze all objects in the permanent generation. #[pyfunction] - fn unfreeze() { - gc_state::gc_state().unfreeze(); + fn unfreeze(vm: &VirtualMachine) { + vm.state.gc.unfreeze(); } /// Return the number of objects in the permanent generation. @@ -260,13 +266,13 @@ mod gc { /// gc.garbage - list of uncollectable objects #[pyattr] fn garbage(vm: &VirtualMachine) -> PyListRef { - vm.ctx.gc_garbage.clone() + vm.state.gc.py_garbage.clone() } /// gc.callbacks - list of callbacks to be invoked #[pyattr] fn callbacks(vm: &VirtualMachine) -> PyListRef { - vm.ctx.gc_callbacks.clone() + vm.state.gc.py_callbacks.clone() } /// Helper function to invoke GC callbacks @@ -276,7 +282,7 @@ mod gc { generation: usize, result: &gc_state::CollectResult, ) { - let callbacks_list = &vm.ctx.gc_callbacks; + let callbacks_list = &vm.state.gc.py_callbacks; let callbacks: Vec = callbacks_list.borrow_vec().to_vec(); if callbacks.is_empty() { return; diff --git a/crates/vm/src/stdlib/posix.rs b/crates/vm/src/stdlib/posix.rs index 6b91950e9..f4d5a70db 100644 --- a/crates/vm/src/stdlib/posix.rs +++ b/crates/vm/src/stdlib/posix.rs @@ -626,10 +626,17 @@ pub mod module { crate::stdlib::_imp::acquire_imp_lock_for_fork(vm); #[cfg(feature = "threading")] - vm.state.stop_the_world.stop_the_world(vm); + vm.state.stop_the_world.stop_the_world(&vm.state); } fn py_os_after_fork_child(vm: &VirtualMachine) { + // The interpreter registry is reachable from every thread, so repair it + // before anything enumerates interpreters. + #[cfg(all(unix, feature = "threading"))] + unsafe { + crate::vm::runtime::reinit_after_fork() + }; + #[cfg(feature = "threading")] vm.state.stop_the_world.reset_after_fork(); @@ -639,6 +646,12 @@ pub mod module { #[cfg(feature = "threading")] reinit_locks_after_fork(vm); + // The collector stops every interpreter, so interpreters other than the + // forking one must be repaired too; otherwise the child's first + // collection waits for threads that did not survive the fork. + #[cfg(all(unix, feature = "threading"))] + reinit_other_interpreters_after_fork(vm); + // Reinit per-object IO buffer locks on std streams. // BufferedReader/Writer/TextIOWrapper use PyThreadMutex which can be // held by dead parent threads, causing deadlocks on any IO in the child. @@ -719,17 +732,67 @@ pub mod module { // Codec registry RwLock vm.state.codec_registry.reinit_after_fork(); - // GC state (multiple Mutex + RwLock) + // GC state (multiple Mutex + RwLock), shared lists and this + // interpreter's own policy state. crate::gc_state::gc_state().reinit_after_fork(); + vm.state.gc.reinit_after_fork(); // Import lock (RawReentrantMutex) crate::stdlib::_imp::reinit_imp_lock_after_fork(); } } + /// Repair every live interpreter other than the forking one after `fork()`. + /// + /// Only the forking thread survives, so each other interpreter is left with + /// slots for threads that no longer exist (still ATTACHED if they were + /// running bytecode) and possibly locks or stop-the-world flags held by + /// them. Since a collection stops all interpreters, that state would hang + /// the child's first collection. + /// + /// # Safety + /// Must only be called after `fork()` in the child, when no other threads exist. + #[cfg(all(unix, feature = "threading"))] + fn reinit_other_interpreters_after_fork(vm: &VirtualMachine) { + use rustpython_common::lock::reinit_mutex_after_fork; + + for state in crate::vm::runtime::live_interpreter_states() { + if state.interpreter_id == vm.state.interpreter_id { + continue; + } + + unsafe { + reinit_mutex_after_fork(&state.before_forkers); + reinit_mutex_after_fork(&state.after_forkers_child); + reinit_mutex_after_fork(&state.after_forkers_parent); + reinit_mutex_after_fork(&state.atexit_funcs); + reinit_mutex_after_fork(&state.global_trace_func); + reinit_mutex_after_fork(&state.global_profile_func); + reinit_mutex_after_fork(&state.type_mutex); + reinit_mutex_after_fork(&state.monitoring); + reinit_mutex_after_fork(&state.thread_frames); + reinit_mutex_after_fork(&state.thread_handles); + reinit_mutex_after_fork(&state.shutdown_handles); + + state.codec_registry.reinit_after_fork(); + state.gc.reinit_after_fork(); + } + + state.stop_the_world.reset_after_fork(); + + // Every thread registered here belongs to the parent, including any + // slot the forking thread itself registered before the fork. + state.thread_frames.lock().clear(); + state.thread_handles.lock().clear(); + state.shutdown_handles.lock().clear(); + } + + crate::vm::thread::purge_other_interpreter_slots_after_fork(vm.state.interpreter_id); + } + fn py_os_after_fork_parent(vm: &VirtualMachine) { #[cfg(feature = "threading")] - vm.state.stop_the_world.start_the_world(vm); + vm.state.stop_the_world.start_the_world(&vm.state); #[cfg(feature = "threading")] crate::stdlib::_imp::release_imp_lock_after_fork_parent(); diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 66257806e..5ee36b450 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -669,7 +669,8 @@ pub mod sys { "_multiarch" => ctx.new_str(multiarch()), "version" => PyVersionInfo::from_data(VersionInfoData::IMPLEMENTATION, vm), "hexversion" => ctx.new_int(version::VERSION_HEX_IMPL), - "supports_isolated_interpreters" => ctx.new_bool(false), + "supports_isolated_interpreters" => + ctx.new_bool(crate::vm::runtime::SUPPORTS_ISOLATED_INTERPRETERS), }) } diff --git a/crates/vm/src/stdlib/sys/monitoring.rs b/crates/vm/src/stdlib/sys/monitoring.rs index 7e47185bb..a4be3ba5a 100644 --- a/crates/vm/src/stdlib/sys/monitoring.rs +++ b/crates/vm/src/stdlib/sys/monitoring.rs @@ -529,7 +529,7 @@ fn update_events_mask(vm: &VirtualMachine, state: &MonitoringState) { // own local events), preventing e.g. INSTRUCTION from being applied to // unrelated code objects. // Re-instrument all frames on the current thread's stack, including - // stack-allocated iframes (with_iframe path) that have no FrameObject. + // data stack frames that have no FrameObject. { let mut cur = crate::vm::thread::get_current_frame(); while !cur.is_null() { diff --git a/crates/vm/src/vm/context.rs b/crates/vm/src/vm/context.rs index 9a5456635..71d017c6d 100644 --- a/crates/vm/src/vm/context.rs +++ b/crates/vm/src/vm/context.rs @@ -54,10 +54,7 @@ pub struct Context { pub(crate) string_pool: StringPool, pub(crate) slot_new_wrapper: PyMethodDef, pub names: ConstName, - // GC module state (callbacks and garbage lists) - pub gc_callbacks: PyListRef, - pub gc_garbage: PyListRef, } macro_rules! declare_const_name { @@ -363,8 +360,6 @@ impl Context { let empty_bytes = create_object(PyBytes::from(Vec::new()), types.bytes_type); // GC callbacks and garbage lists - let gc_callbacks = PyRef::new_ref(PyList::default(), types.list_type.to_owned(), None); - let gc_garbage = PyRef::new_ref(PyList::default(), types.list_type.to_owned(), None); Self { true_value, @@ -387,9 +382,6 @@ impl Context { string_pool, slot_new_wrapper, names, - - gc_callbacks, - gc_garbage, } } @@ -442,6 +434,14 @@ impl Context { PyInt::from(i).into_ref(self) } + /// Borrow a cached small integer whose lifetime is tied to this context. + #[inline(always)] + pub(crate) fn cached_int(&self, i: i32) -> &PyIntRef { + debug_assert!(Self::INT_CACHE_POOL_RANGE.contains(&i)); + let inner_idx = (i - Self::INT_CACHE_POOL_MIN) as usize; + &self.int_cache_pool[inner_idx] + } + #[inline] pub fn new_bigint(&self, i: &BigInt) -> PyIntRef { if let Some(i) = i.to_i32() diff --git a/crates/vm/src/vm/interpreter.rs b/crates/vm/src/vm/interpreter.rs index f456e8587..c538eb32e 100644 --- a/crates/vm/src/vm/interpreter.rs +++ b/crates/vm/src/vm/interpreter.rs @@ -1,6 +1,11 @@ #[cfg(feature = "threading")] use super::StopTheWorldState; -use super::{Context, PyConfig, PyGlobalState, VirtualMachine, setting::Settings, thread}; +use super::{ + Context, PyConfig, PyGlobalState, VirtualMachine, + runtime::{self, InterpreterWhence}, + setting::Settings, + thread, +}; use crate::{ PyResult, builtins, common::rc::PyRc, frozen::FrozenModule, getpath, py_freeze, stdlib::atexit, vm::PyBaseExceptionRef, @@ -36,18 +41,34 @@ pub struct InterpreterBuilder { init_hooks: Vec, } -/// Private helper to initialize a VM with settings, context, and custom initialization. -fn initialize_main_vm( +/// Options for constructing a main or sub-interpreter VM. +struct InitializeVmOpts<'a> { settings: Settings, ctx: PyRc, module_defs: Vec<&'static builtins::PyModuleDef>, frozen_modules: Vec<(&'static str, FrozenModule)>, init_hooks: Vec, - init: F, -) -> (VirtualMachine, PyRc) + is_main: bool, + whence: InterpreterWhence, + /// When `Some`, reuse parent module_defs/frozen/config seeds for a subinterpreter. + parent_state: Option<&'a PyGlobalState>, +} + +/// Shared constructor for main and sub-interpreters. +fn initialize_vm(opts: InitializeVmOpts<'_>, init: F) -> (VirtualMachine, PyRc) where F: FnOnce(&mut VirtualMachine), { + let InitializeVmOpts { + settings, + ctx, + module_defs, + frozen_modules, + init_hooks, + is_main, + whence, + parent_state, + } = opts; use crate::codecs::CodecsRegistry; use crate::common::hash::HashSecret; use crate::common::lock::PyMutex; @@ -55,55 +76,85 @@ where use core::sync::atomic::{AtomicBool, AtomicU64}; use crossbeam_utils::atomic::AtomicCell; - let paths = getpath::init_path_config(&settings); - let config = PyConfig::new(settings, paths); + let (config, all_module_defs, frozen, hash_secret, int_max_str_digits) = + if let Some(parent) = parent_state { + // Subinterpreter: clone config and module tables from parent, fresh runtime state. + let int_max_str_digits = AtomicCell::new(parent.int_max_str_digits.load()); + ( + parent.config.clone(), + parent.module_defs.clone(), + parent.frozen.clone(), + parent.hash_secret, + int_max_str_digits, + ) + } else { + let paths = getpath::init_path_config(&settings); + let config = PyConfig::new(settings, paths); - // Build module_defs map from builtin modules + additional modules - let mut all_module_defs: BTreeMap<&'static str, &'static builtins::PyModuleDef> = - crate::stdlib::builtin_module_defs(&ctx) - .into_iter() - .chain(module_defs) - .map(|def| (def.name.as_str(), def)) - .collect(); + // Build module_defs map from builtin modules + additional modules + let mut all_module_defs: BTreeMap<&'static str, &'static builtins::PyModuleDef> = + crate::stdlib::builtin_module_defs(&ctx) + .into_iter() + .chain(module_defs) + .map(|def| (def.name.as_str(), def)) + .collect(); - // Register sysconfigdata under platform-specific name as well - if let Some(&sysconfigdata_def) = all_module_defs.get("_sysconfigdata") { - use std::sync::OnceLock; - static SYSCONFIGDATA_NAME: OnceLock<&'static str> = OnceLock::new(); - let leaked_name = *SYSCONFIGDATA_NAME.get_or_init(|| { - let name = crate::stdlib::sys::sysconfigdata_name(); - Box::leak(name.into_boxed_str()) - }); - all_module_defs.insert(leaked_name, sysconfigdata_def); - } + // Register sysconfigdata under platform-specific name as well + if let Some(&sysconfigdata_def) = all_module_defs.get("_sysconfigdata") { + use std::sync::OnceLock; + static SYSCONFIGDATA_NAME: OnceLock<&'static str> = OnceLock::new(); + let leaked_name = *SYSCONFIGDATA_NAME.get_or_init(|| { + let name = crate::stdlib::sys::sysconfigdata_name(); + Box::leak(name.into_boxed_str()) + }); + all_module_defs.insert(leaked_name, sysconfigdata_def); + } - // Create hash secret - let seed = match config.settings.hash_seed { - Some(seed) => seed, - None => super::process_hash_secret_seed(), - }; - let hash_secret = HashSecret::new(seed); + let seed = match config.settings.hash_seed { + Some(seed) => seed, + None => super::process_hash_secret_seed(), + }; + let hash_secret = HashSecret::new(seed); - // Create codec registry and warnings state + let int_max_str_digits = AtomicCell::new(match config.settings.int_max_str_digits { + -1 => 4300, + other => other, + } as usize); + + let mut frozen: std::collections::HashMap< + &'static str, + FrozenModule, + rapidhash::quality::RandomState, + > = core_frozen_inits().collect(); + frozen.extend(frozen_modules); + + ( + config, + all_module_defs, + frozen, + hash_secret, + int_max_str_digits, + ) + }; + + // Per-interpreter ephemeral state (must not be shared across interpreters). let codec_registry = CodecsRegistry::new(&ctx); let warnings = WarningsState::init_state(&ctx); - // Create int_max_str_digits - let int_max_str_digits = AtomicCell::new(match config.settings.int_max_str_digits { - -1 => 4300, - other => other, - } as usize); + let interpreter_id = runtime::alloc_interpreter_id(); - // Initialize frozen modules (core + user-provided) - let mut frozen: std::collections::HashMap< - &'static str, - FrozenModule, - rapidhash::quality::RandomState, - > = core_frozen_inits().collect(); - frozen.extend(frozen_modules); + // Process main OS thread identity is process-global; subinterpreters inherit + // it from the parent so `is_main_thread()` stays correct when running on the + // main OS thread under a subinterpreter. + #[cfg(feature = "threading")] + let main_thread_ident = AtomicCell::new(parent_state.map_or(0, |p| p.main_thread_ident.load())); - // Create PyGlobalState + // Create PyGlobalState (≈ PyInterpreterState) let global_state = PyRc::new(PyGlobalState { + gc: crate::gc_state::GcInterpreterState::new(&ctx), + interpreter_id, + whence, + is_main, config, module_defs: all_module_defs, frozen, @@ -124,7 +175,7 @@ where global_profile_func: PyMutex::default(), type_mutex: PyMutex::default(), #[cfg(feature = "threading")] - main_thread_ident: AtomicCell::new(0), + main_thread_ident, #[cfg(feature = "threading")] thread_frames: parking_lot::Mutex::new(std::collections::HashMap::new()), #[cfg(feature = "threading")] @@ -150,6 +201,13 @@ where // Call custom init function (can mutate vm.state) init(&mut vm); + // Register before `initialize()` runs any Python: it allocates GC-tracked + // objects, so a collection on another thread has to be able to stop this + // interpreter while that happens. It cannot be registered earlier — the + // hooks above take `PyRc::get_mut` on the state, which fails once the + // registry holds a weak reference to it. + runtime::register_interpreter(&vm.state); + // `initialize()` runs Python bytecode directly (e.g. importing `codecs` // and `encodings`) before any `enter_vm` scope exists, so attach this // thread for the duration so type cache reads see it as ATTACHED. @@ -271,12 +329,17 @@ impl InterpreterBuilder { /// This consumes the configuration and returns a fully initialized Interpreter. #[must_use] pub fn build(self) -> Interpreter { - let (vm, global_state) = initialize_main_vm( - self.settings, - self.ctx, - self.module_defs, - self.frozen_modules, - self.init_hooks, + let (vm, global_state) = initialize_vm( + InitializeVmOpts { + settings: self.settings, + ctx: self.ctx, + module_defs: self.module_defs, + frozen_modules: self.frozen_modules, + init_hooks: self.init_hooks, + is_main: true, + whence: InterpreterWhence::Runtime, + parent_state: None, + }, |_| {}, // No additional init needed ); Interpreter { global_state, vm } @@ -295,7 +358,13 @@ impl Default for InterpreterBuilder { } } -/// The general interface for the VM +/// One isolated Python interpreter in the process (≈ CPython `PyInterpreterState` + main tstate). +/// +/// Historically RustPython exposed a single process-level `Interpreter`. For PEP 734 +/// (multiple interpreters / subinterpreters) this type is now the owned handle for +/// **one** interpreter. Use [`Interpreter::create_subinterpreter`] to create additional +/// isolated interpreters that share the process-wide type context but not modules or +/// `PyGlobalState`. /// /// # Examples /// Runs a simple embedded hello world program. @@ -350,17 +419,113 @@ impl Interpreter { where F: FnOnce(&mut VirtualMachine), { - let (vm, global_state) = initialize_main_vm( - settings, - Context::genesis().clone(), - Vec::new(), // No module_defs - Vec::new(), // No frozen_modules - Vec::new(), // No init_hooks + let (vm, global_state) = initialize_vm( + InitializeVmOpts { + settings, + ctx: Context::genesis().clone(), + module_defs: Vec::new(), + frozen_modules: Vec::new(), + init_hooks: Vec::new(), + is_main: true, + whence: InterpreterWhence::Runtime, + parent_state: None, + }, init, ); Self { global_state, vm } } + /// Process-global interpreter id (main is [`super::MAIN_INTERPRETER_ID`]). + #[inline] + #[must_use] + pub fn id(&self) -> i64 { + self.global_state.interpreter_id + } + + /// Where this interpreter was created. + #[inline] + #[must_use] + pub fn whence(&self) -> InterpreterWhence { + self.global_state.whence + } + + /// Whether this is a top-level interpreter rather than a subinterpreter. + /// + /// Every top-level interpreter answers `true`; for *the* process main, use + /// [`Interpreter::is_process_main`]. + #[inline] + #[must_use] + pub fn is_main(&self) -> bool { + self.global_state.is_main + } + + /// Whether this is the PEP 734 process main interpreter (`get_main()`). + /// + /// Unlike [`Interpreter::is_main`], which is set for every top-level + /// interpreter, this is true for only the single first-registered main. + #[inline] + #[must_use] + pub fn is_process_main(&self) -> bool { + runtime::main_interpreter_id() == Some(self.id()) + } + + /// Create a subinterpreter and hand ownership to the runtime, returning its + /// id. The runtime keeps it alive until [`runtime::take_owned_interpreter`]. + /// + /// This is the shape `_interpreters.create()` will use: Python receives an + /// id, not an owned handle. + #[cfg(feature = "threading")] + #[must_use] + pub fn create_owned_subinterpreter(&self) -> i64 { + runtime::store_owned_interpreter(self.create_subinterpreter()) + } + + /// Create an isolated subinterpreter sharing this interpreter's type context + /// (`Context`) and module definitions, but with its own `sys.modules`, + /// builtins module instance, thread registry, and stop-the-world state. + /// + /// This is the Rust-side foundation for PEP 734 / `_interpreters.create()`. + /// It does not yet expose a Python module API. + /// + /// May be called while the parent is entered (matching CPython, where + /// `_interpreters.create()` runs under the main interpreter). When the + /// calling thread is currently attached to a VM, that attachment is + /// temporarily saved so the subinterpreter can bootstrap as an outermost + /// enter (correct thread-slot / stop-the-world state). + #[must_use] + pub fn create_subinterpreter(&self) -> Self { + // Suspend the caller's current VM attachment (if any) for the duration + // of subinterpreter initialization. Nested bootstrap would otherwise + // swap `CURRENT_THREAD_SLOT` to the new interpreter while leaving the + // outer interpreter's attach state inconsistent. Always restore, even + // if initialization panics. + #[cfg(feature = "threading")] + let _restore_parent = { + let saved = thread::current_vm_is_set().then(thread::save_current_thread); + scopeguard::guard(saved, |saved| { + if let Some(saved) = saved { + thread::restore_current_thread(saved); + } + }) + }; + + let (vm, global_state) = initialize_vm( + InitializeVmOpts { + // settings unused when parent_state is Some + settings: Settings::default(), + ctx: self.vm.ctx.clone(), + module_defs: Vec::new(), + frozen_modules: Vec::new(), + init_hooks: Vec::new(), + is_main: false, + whence: InterpreterWhence::Stdlib, + parent_state: Some(&self.global_state), + }, + |_| {}, + ); + Self { global_state, vm } + } + /// Run a function with the main virtual machine and return a PyResult of the result. /// /// To enter vm context multiple times or to avoid buffer/exception management, this function is preferred. @@ -456,7 +621,7 @@ impl Interpreter { vm.state.finalizing.store(true, Ordering::Release); // GC pass - collect cycles before module cleanup - crate::gc_state::gc_state().collect_force(2); + vm.state.gc.collect_force(2); // Module finalization: remove modules from sys.modules, GC collect // (while builtins is still available for __del__), then clear module dicts. @@ -582,8 +747,9 @@ fn core_frozen_inits() -> impl Iterator { mod tests { use super::*; use crate::{ - PyObjectRef, + AsObject, PyObjectRef, builtins::{PyStr, int}, + vm::{MAIN_INTERPRETER_ID, runtime}, }; use malachite_bigint::ToBigInt; @@ -608,4 +774,883 @@ mod tests { assert_eq!(value.as_wtf8(), "Hello Hello Hello Hello ") }) } + + /// Main interpreter is marked main with Runtime whence and is registered. + #[test] + fn main_interpreter_identity() { + let main = Interpreter::without_stdlib(Default::default()); + assert!(main.is_main()); + assert_eq!(main.whence(), InterpreterWhence::Runtime); + assert!( + runtime::list_interpreters() + .iter() + .any(|info| info.id == main.id() && info.whence == InterpreterWhence::Runtime) + ); + // When this is the sole sequential main in a quiet process, id is 0; + // under parallel tests the id is still unique and registered. + assert!(main.id() >= MAIN_INTERPRETER_ID); + } + + /// Subinterpreters get distinct ids, Stdlib whence, and appear in the registry. + #[test] + fn create_subinterpreter_registers_distinct_ids() { + let main = Interpreter::without_stdlib(Default::default()); + let sub1 = main.create_subinterpreter(); + let sub2 = main.create_subinterpreter(); + + assert!(main.is_main()); + assert!(!sub1.is_main()); + assert!(!sub2.is_main()); + assert_eq!(sub1.whence(), InterpreterWhence::Stdlib); + assert_eq!(sub2.whence(), InterpreterWhence::Stdlib); + assert_ne!(main.id(), sub1.id()); + assert_ne!(main.id(), sub2.id()); + assert_ne!(sub1.id(), sub2.id()); + + let ids: Vec = runtime::list_interpreters() + .into_iter() + .map(|i| i.id) + .collect(); + assert!(ids.contains(&main.id())); + assert!(ids.contains(&sub1.id())); + assert!(ids.contains(&sub2.id())); + } + + /// An interpreter stays looked-up-able until nothing holds its state. + /// + /// Dropping the handle is not the end of its life: `new_thread()` workers + /// hold their own reference, and a collection in progress holds one for + /// every live interpreter while the world is stopped. So the registry entry + /// goes away eventually rather than at the drop. + fn wait_until_unregistered(id: i64) { + use core::time::Duration; + use std::time::Instant; + + let deadline = Instant::now() + Duration::from_secs(30); + while runtime::lookup_interpreter(id).is_some() { + assert!( + Instant::now() < deadline, + "interpreter {id} still registered long after its last reference" + ); + std::thread::yield_now(); + } + } + + /// A collection snapshots the registry and then reads tracked objects with + /// the interpreters it found parked. An interpreter that registered inside + /// that window would be missing from the snapshot, so nothing would stop it + /// and its bootstrap would run under the scan; registration therefore waits + /// for the stop to end. + #[cfg(feature = "threading")] + #[test] + fn registering_waits_for_an_in_flight_stop() { + use core::time::Duration; + use std::sync::mpsc; + + // Stands in for a collector between its snapshot and its restart. + let admission = runtime::lock_admission_for_stop(); + + let (tx, rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let interp = Interpreter::without_stdlib(Default::default()); + tx.send(interp.id()).expect("receiver is alive"); + interp + }); + + assert!( + matches!( + rx.recv_timeout(Duration::from_millis(200)), + Err(mpsc::RecvTimeoutError::Timeout) + ), + "an interpreter registered while a stop-the-world was in flight" + ); + + drop(admission); + let id = rx + .recv_timeout(Duration::from_secs(30)) + .expect("registration proceeds once the world restarts"); + assert!(runtime::lookup_interpreter(id).is_some()); + drop(worker.join().expect("worker did not panic")); + wait_until_unregistered(id); + } + + /// Dropping a subinterpreter releases it; main remains. + #[test] + fn drop_subinterpreter_unregisters() { + let main = Interpreter::without_stdlib(Default::default()); + let sub_id = { + let sub = main.create_subinterpreter(); + let id = sub.id(); + assert!(runtime::lookup_interpreter(id).is_some()); + id + }; + wait_until_unregistered(sub_id); + assert!(runtime::lookup_interpreter(main.id()).is_some()); + } + + /// Each interpreter has its own `sys.modules` / builtins module instance. + #[test] + fn subinterpreters_isolate_modules() { + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + let (main_sys_ptr, main_builtins_ptr, main_ctx_ptr, main_state_ptr) = main.enter(|vm| { + ( + vm.sys_module.as_object() as *const _, + vm.builtins.as_object() as *const _, + PyRc::as_ptr(&vm.ctx), + PyRc::as_ptr(&vm.state), + ) + }); + let (sub_sys_ptr, sub_builtins_ptr, sub_ctx_ptr, sub_state_ptr) = sub.enter(|vm| { + ( + vm.sys_module.as_object() as *const _, + vm.builtins.as_object() as *const _, + PyRc::as_ptr(&vm.ctx), + PyRc::as_ptr(&vm.state), + ) + }); + + assert_ne!(main_sys_ptr, sub_sys_ptr); + assert_ne!(main_builtins_ptr, sub_builtins_ptr); + // Distinct per-interpreter state. + assert_ne!(main_state_ptr, sub_state_ptr); + // Shared process-wide type context (immortal / builtin types). + assert_eq!(main_ctx_ptr, sub_ctx_ptr); + } + + /// Mutations to interpreter-owned modules must not leak between interpreters. + #[test] + fn subinterpreters_behaviorally_isolate_builtins_and_sys_modules() { + const PROBE: &str = "__rustpython_subinterpreter_isolation_probe__"; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + main.enter(|vm| { + vm.builtins + .set_attr(PROBE, vm.ctx.new_int(11_i32), vm) + .unwrap(); + vm.sys_module + .get_attr("modules", vm) + .unwrap() + .set_item(PROBE, vm.ctx.new_int(12_i32).into(), vm) + .unwrap(); + }); + + sub.enter(|vm| { + assert!(vm.builtins.get_attr(PROBE, vm).is_err()); + let modules = vm.sys_module.get_attr("modules", vm).unwrap(); + assert!(modules.get_item(PROBE, vm).is_err()); + + vm.builtins + .set_attr(PROBE, vm.ctx.new_int(21_i32), vm) + .unwrap(); + modules + .set_item(PROBE, vm.ctx.new_int(22_i32).into(), vm) + .unwrap(); + }); + + main.enter(|vm| { + let builtin_probe = vm.builtins.get_attr(PROBE, vm).unwrap(); + assert_eq!(*int::get_value(&builtin_probe), 11_i32.to_bigint().unwrap()); + + let module_probe = vm + .sys_module + .get_attr("modules", vm) + .unwrap() + .get_item(PROBE, vm) + .unwrap(); + assert_eq!(*int::get_value(&module_probe), 12_i32.to_bigint().unwrap()); + }); + } + + /// Creating a subinterpreter while the parent is entered must not corrupt + /// the parent's current-VM / thread-slot state. + #[test] + fn create_subinterpreter_while_parent_entered() { + let main = Interpreter::without_stdlib(Default::default()); + main.enter(|vm| { + let before = vm.state.interpreter_id; + let sub = main.create_subinterpreter(); + assert_ne!(sub.id(), before); + // Still the parent after create returns. + assert_eq!(vm.state.interpreter_id, before); + // Can still use the parent VM. + let n: PyObjectRef = vm.ctx.new_int(7_i32).into(); + assert_eq!(int::get_value(&n), &7_i32.to_bigint().unwrap()); + // And the sub is independently usable after parent section. + drop(sub); + }); + } + + /// Sequential enter of main then sub on the same OS thread is safe. + #[test] + fn sequential_enter_main_and_sub() { + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + main.enter(|vm| { + assert!(vm.state.is_main_interpreter()); + let a: PyObjectRef = vm.ctx.new_int(1_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(2_i32).into(); + let res = vm._add(&a, &b).unwrap(); + assert_eq!(*int::get_value(&res), 3_i32.to_bigint().unwrap()); + }); + sub.enter(|vm| { + assert!(!vm.state.is_main_interpreter()); + let a: PyObjectRef = vm.ctx.new_int(10_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(5_i32).into(); + let res = vm._mul(&a, &b).unwrap(); + assert_eq!(*int::get_value(&res), 50_i32.to_bigint().unwrap()); + }); + // Re-enter main after sub. + main.enter(|vm| { + assert!(vm.state.is_main_interpreter()); + }); + } + + /// Concurrent use of main + subinterpreter on different OS threads. + #[cfg(feature = "threading")] + #[test] + fn concurrent_main_and_subinterpreter_threads() { + use alloc::sync::Arc; + use core::sync::atomic::{AtomicUsize, Ordering}; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let counter = Arc::new(AtomicUsize::new(0)); + + let c1 = Arc::clone(&counter); + let h_main = main.enter(|vm| { + let thread_vm = vm.new_thread(); + let c = Arc::clone(&c1); + std::thread::spawn(move || { + thread_vm.run(|vm| { + for _ in 0..100 { + let a: PyObjectRef = vm.ctx.new_int(1_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(1_i32).into(); + let _ = vm._add(&a, &b).unwrap(); + c.fetch_add(1, Ordering::Relaxed); + } + assert!(vm.state.is_main_interpreter()); + }); + }) + }); + + let c2 = Arc::clone(&counter); + let h_sub = sub.enter(|vm| { + let thread_vm = vm.new_thread(); + let c = Arc::clone(&c2); + std::thread::spawn(move || { + thread_vm.run(|vm| { + for _ in 0..100 { + let a: PyObjectRef = vm.ctx.new_int(2_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(3_i32).into(); + let _ = vm._mul(&a, &b).unwrap(); + c.fetch_add(1, Ordering::Relaxed); + } + assert!(!vm.state.is_main_interpreter()); + }); + }) + }); + + h_main.join().expect("main worker panicked"); + h_sub.join().expect("sub worker panicked"); + assert_eq!(counter.load(Ordering::Relaxed), 200); + } + + /// Entering one interpreter must not serialize entry into another interpreter. + #[cfg(feature = "threading")] + #[test] + fn main_and_subinterpreter_run_sections_overlap() { + use alloc::sync::Arc; + use core::time::Duration; + use std::{ + sync::{Condvar, Mutex}, + time::Instant, + }; + + #[derive(Default)] + struct OverlapState { + entered: usize, + release: bool, + } + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let state = Arc::new((Mutex::new(OverlapState::default()), Condvar::new())); + + let spawn_worker = |interpreter: &Interpreter| { + let state = Arc::clone(&state); + interpreter.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + thread_vm.run(|vm| { + let a: PyObjectRef = vm.ctx.new_int(20_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(22_i32).into(); + assert_eq!( + *int::get_value(&vm._add(&a, &b).unwrap()), + 42_i32.to_bigint().unwrap() + ); + + let (lock, ready) = &*state; + let mut state = lock.lock().unwrap(); + state.entered += 1; + ready.notify_all(); + while !state.release { + state = ready.wait(state).unwrap(); + } + }); + }) + }) + }; + + let main_worker = spawn_worker(&main); + let sub_worker = spawn_worker(&sub); + + let (lock, ready) = &*state; + let deadline = Instant::now() + Duration::from_secs(30); + let mut state_guard = lock.lock().unwrap(); + while state_guard.entered < 2 { + let now = Instant::now(); + if now >= deadline { + break; + } + let (next, _) = ready.wait_timeout(state_guard, deadline - now).unwrap(); + state_guard = next; + } + let overlapped = state_guard.entered == 2; + state_guard.release = true; + ready.notify_all(); + drop(state_guard); + + main_worker.join().expect("main worker panicked"); + sub_worker.join().expect("subinterpreter worker panicked"); + assert!( + overlapped, + "main and subinterpreter run sections were serialized" + ); + } + + /// A busy interpreter must not prevent another interpreter from making progress. + #[cfg(feature = "threading")] + #[test] + fn busy_main_interpreter_does_not_block_subinterpreter() { + use alloc::sync::Arc; + use core::{ + sync::atomic::{AtomicBool, Ordering}, + time::Duration, + }; + use std::time::Instant; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let main_started = Arc::new(AtomicBool::new(false)); + let sub_finished = Arc::new(AtomicBool::new(false)); + + let main_started_worker = Arc::clone(&main_started); + let sub_finished_worker = Arc::clone(&sub_finished); + let main_worker = main.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + thread_vm.run(|vm| { + main_started_worker.store(true, Ordering::Release); + let deadline = Instant::now() + Duration::from_secs(30); + let mut operations = 0; + while !sub_finished_worker.load(Ordering::Acquire) && Instant::now() < deadline + { + let a: PyObjectRef = vm.ctx.new_int(20_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(22_i32).into(); + let result = vm._add(&a, &b).unwrap(); + assert_eq!(*int::get_value(&result), 42_i32.to_bigint().unwrap()); + operations += 1; + std::thread::yield_now(); + } + (sub_finished_worker.load(Ordering::Acquire), operations) + }) + }) + }); + + let main_started_worker = Arc::clone(&main_started); + let sub_finished_worker = Arc::clone(&sub_finished); + let sub_worker = sub.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + while !main_started_worker.load(Ordering::Acquire) { + std::thread::yield_now(); + } + thread_vm.run(|vm| { + let a: PyObjectRef = vm.ctx.new_int(6_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(7_i32).into(); + let result = vm._mul(&a, &b).unwrap(); + assert_eq!(*int::get_value(&result), 42_i32.to_bigint().unwrap()); + sub_finished_worker.store(true, Ordering::Release); + }); + }) + }); + + let (sub_progressed_while_main_was_busy, main_operations) = + main_worker.join().expect("main worker panicked"); + sub_worker.join().expect("subinterpreter worker panicked"); + + assert!(main_operations > 0); + assert!( + sub_progressed_while_main_was_busy, + "subinterpreter made no progress until the busy main interpreter exited" + ); + } + + /// `new_thread` on a subinterpreter shares that subinterpreter's state, not main's. + #[cfg(feature = "threading")] + #[test] + fn subinterpreter_new_thread_shares_sub_state() { + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let sub_id = sub.id(); + + let handle = sub.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + thread_vm.run(|vm| { + assert_eq!(vm.state.interpreter_id, sub_id); + assert!(!vm.state.is_main_interpreter()); + }); + }) + }); + handle.join().expect("thread panicked"); + } + + /// Multiple subinterpreters can each run bytecode via compile+exec. + #[cfg(feature = "rustpython-compiler")] + #[test] + fn subinterpreter_runs_python_code() { + use crate::compiler::Mode; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + sub.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + let source = "x = 40 + 2\n"; + let code = vm + .compile(source, Mode::Exec, "") + .map_err(|err| err.into_pyexception(vm, Some(source))) + .unwrap(); + vm.run_code_obj(code, scope.clone()).unwrap(); + let x = scope.globals.get_item("x", vm).unwrap(); + assert_eq!(*int::get_value(&x), 42_i32.to_bigint().unwrap()); + }); + } + + /// Subclassing a shared type records the subclass on an object every + /// interpreter reaches, but only the interpreter that created it lists it. + fn run(vm: &VirtualMachine, scope: &crate::scope::Scope, source: &str) { + let code = vm + .compile(source, crate::compiler::Mode::Exec, "") + .map_err(|err| err.into_pyexception(vm, Some(source))) + .unwrap(); + vm.run_code_obj(code, scope.clone()).unwrap(); + } + + #[test] + fn subinterpreter_subclasses_are_scoped_to_their_interpreter() { + use crate::scope::Scope; + + fn lists_subclass(vm: &VirtualMachine, scope: &Scope, name: &str) -> bool { + run( + vm, + scope, + &format!("found = any(c.__name__ == {name:?} for c in int.__subclasses__())\n"), + ); + let found = scope.globals.get_item("found", vm).unwrap(); + found.try_to_bool(vm).unwrap() + } + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + // The scopes are what keep the classes alive; a subclass list holds + // only weak references, so both must outlive every assertion below. + let main_scope = main.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + run(vm, &scope, "class MainOnly(int): pass\n"); + scope + }); + let sub_scope = sub.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + run(vm, &scope, "class SubOnly(int): pass\n"); + scope + }); + + main.enter(|vm| { + assert!(lists_subclass(vm, &main_scope, "MainOnly")); + assert!(!lists_subclass(vm, &main_scope, "SubOnly")); + // A subclass built before either interpreter existed belongs to the + // shared context, so it stays visible to both. + assert!(lists_subclass(vm, &main_scope, "bool")); + }); + sub.enter(|vm| { + assert!(lists_subclass(vm, &sub_scope, "SubOnly")); + assert!(!lists_subclass(vm, &sub_scope, "MainOnly")); + assert!(lists_subclass(vm, &sub_scope, "bool")); + }); + + main.enter(|_| drop(main_scope)); + sub.enter(|_| drop(sub_scope)); + } + + /// A cycle allocated in one interpreter is not the parent's to collect. + #[test] + fn collections_only_reach_the_collecting_interpreter() { + use core::time::Duration; + use std::time::Instant; + + const CYCLE: &str = "class Node:\n pass\n\ + a = Node()\n\ + b = Node()\n\ + a.other = b\n\ + b.other = a\n\ + del a\n\ + del b\n"; + + fn live_nodes(vm: &VirtualMachine) -> usize { + vm.state + .gc + .get_objects(None) + .iter() + .filter(|obj| &*obj.class().name() == "Node") + .count() + } + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + let sub_scope = sub.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + run(vm, &scope, CYCLE); + assert_eq!(live_nodes(vm), 2); + scope + }); + + // A collection in the parent walks its own tracked objects and leaves + // the sub's cycle where it is. Collections are serialized process-wide + // by a `try_lock`, so one running elsewhere in the suite makes + // `collect_force` a no-op; retry until this one gets to run. Each retry + // waits outside `enter`, since a thread that is entered but not running + // bytecode never reaches a safepoint, and the collection this is + // waiting for cannot stop it. + let deadline = Instant::now() + Duration::from_secs(30); + while !main.enter(|vm| vm.state.gc.collect_force(2).candidates > 0) { + assert!( + Instant::now() < deadline, + "no collection ran in the parent interpreter" + ); + std::thread::sleep(Duration::from_millis(5)); + } + sub.enter(|vm| assert_eq!(live_nodes(vm), 2)); + + sub.enter(|_| drop(sub_scope)); + } + + /// And it is not the parent's to enumerate either. + #[test] + fn get_objects_only_reports_the_calling_interpreter() { + fn tracks_class(vm: &VirtualMachine, name: &str) -> bool { + vm.state + .gc + .get_objects(None) + .iter() + .any(|obj| &*obj.class().name() == name) + } + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + let main_scope = main.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + run(vm, &scope, "class MainNode:\n pass\nkeep = MainNode()\n"); + scope + }); + let sub_scope = sub.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + run(vm, &scope, "class SubNode:\n pass\nkeep = SubNode()\n"); + scope + }); + + main.enter(|vm| { + assert!(tracks_class(vm, "MainNode")); + assert!(!tracks_class(vm, "SubNode")); + }); + sub.enter(|vm| { + assert!(tracks_class(vm, "SubNode")); + assert!(!tracks_class(vm, "MainNode")); + }); + + main.enter(|_| drop(main_scope)); + sub.enter(|_| drop(sub_scope)); + } + + /// The runtime can own a subinterpreter by id and hand it back on destroy. + #[cfg(feature = "threading")] + #[test] + fn runtime_owned_interpreter_lifecycle() { + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let id = sub.id(); + + assert_eq!(runtime::store_owned_interpreter(sub), id); + assert!(runtime::is_owned_interpreter(id)); + assert!(runtime::lookup_interpreter(id).is_some()); + // The owned table is process-global and other tests store into it in + // parallel, so only this entry's own membership is deterministic. + assert!(runtime::owned_interpreter_count() >= 1); + + // Reclaiming removes ownership but keeps the interpreter alive while the + // returned handle is held. + let reclaimed = runtime::take_owned_interpreter(id).expect("owned by runtime"); + assert_eq!(reclaimed.id(), id); + assert!(!runtime::is_owned_interpreter(id)); + assert!(runtime::lookup_interpreter(id).is_some()); + assert!(runtime::take_owned_interpreter(id).is_none()); + + // Dropping the reclaimed handle releases it. + drop(reclaimed); + wait_until_unregistered(id); + } + + /// `create_owned_subinterpreter` stores the sub and returns only its id. + #[cfg(feature = "threading")] + #[test] + fn create_owned_subinterpreter_returns_id() { + let main = Interpreter::without_stdlib(Default::default()); + let id = main.create_owned_subinterpreter(); + assert!(runtime::is_owned_interpreter(id)); + assert_ne!(id, main.id()); + + let sub = runtime::take_owned_interpreter(id).expect("owned by runtime"); + assert_eq!(sub.id(), id); + assert!(!sub.is_main()); + } + + /// A collection must stop every interpreter, not just the collecting one: + /// the generation lists are process-global, so the reachability walk reads + /// objects owned by other interpreters while their threads would otherwise + /// still be mutating them. + #[cfg(all(feature = "threading", feature = "rustpython-compiler"))] + #[test] + fn gc_collect_is_safe_while_another_interpreter_runs() { + use crate::compiler::Mode; + use alloc::sync::Arc; + use core::{ + sync::atomic::{AtomicBool, Ordering}, + time::Duration, + }; + use std::time::Instant; + + // Each interpreter churns reference cycles so both contribute tracked + // objects to the shared generation lists. + const CHURN: &str = "\ +for _ in range(40): + a = {} + b = {'peer': a} + a['peer'] = b +"; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let stop = Arc::new(AtomicBool::new(false)); + + let run_source = |vm: &VirtualMachine, source: &str| { + let scope = vm.new_scope_with_builtins(); + let code = vm + .compile(source, Mode::Exec, "") + .map_err(|err| err.into_pyexception(vm, Some(source))) + .unwrap(); + vm.run_code_obj(code, scope).unwrap(); + }; + + // Subinterpreter thread: allocate cycles continuously. + let stop_worker = Arc::clone(&stop); + let churner = sub.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + thread_vm.run(|vm| { + while !stop_worker.load(Ordering::Acquire) { + run_source(vm, CHURN); + } + }); + }) + }); + + // Main interpreter: force collections while the sub keeps mutating. + main.enter(|vm| { + run_source(vm, CHURN); + let deadline = Instant::now() + Duration::from_secs(2); + let mut collections = 0; + while Instant::now() < deadline && collections < 20 { + vm.state.gc.collect_force(2); + collections += 1; + } + assert!(collections > 0); + }); + + stop.store(true, Ordering::Release); + churner.join().expect("churn worker panicked"); + } + + /// A thread entered in one interpreter can park another interpreter's + /// threads. This is what makes a collection safe: the generation lists are + /// process-global, so the collector must be able to stop every interpreter, + /// not only its own. + #[cfg(all(feature = "threading", feature = "rustpython-compiler"))] + #[test] + fn stop_the_world_parks_threads_of_another_interpreter() { + use crate::compiler::Mode; + use alloc::sync::Arc; + use core::{ + sync::atomic::{AtomicBool, AtomicU64, Ordering}, + time::Duration, + }; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let sub_state = sub.enter(|vm| vm.state.clone()); + + let progress = Arc::new(AtomicU64::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + + // Sub-interpreter worker: runs bytecode (so it reaches safepoints) and + // reports progress every iteration. + let progress_worker = Arc::clone(&progress); + let stop_worker = Arc::clone(&stop); + let worker = sub.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + thread_vm.run(|vm| { + let source = "x = 1 + 1\n"; + let code = vm + .compile(source, Mode::Exec, "") + .map_err(|err| err.into_pyexception(vm, Some(source))) + .unwrap(); + while !stop_worker.load(Ordering::Acquire) { + let scope = vm.new_scope_with_builtins(); + vm.run_code_obj(code.clone(), scope).unwrap(); + progress_worker.fetch_add(1, Ordering::Release); + } + }); + }) + }); + + // Wait until the worker is actually running. + while progress.load(Ordering::Acquire) == 0 { + std::thread::yield_now(); + } + + main.enter(|_vm| { + // Stop the *subinterpreter* from a thread whose current interpreter + // is main — the cross-interpreter stop a collection performs. + sub_state.stop_the_world.stop_the_world(&sub_state); + + let parked_at = progress.load(Ordering::Acquire); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!( + progress.load(Ordering::Acquire), + parked_at, + "subinterpreter thread kept running while its world was stopped" + ); + + sub_state.stop_the_world.start_the_world(&sub_state); + }); + + // After restart the worker makes progress again. + let resumed_from = progress.load(Ordering::Acquire); + while progress.load(Ordering::Acquire) == resumed_from { + std::thread::yield_now(); + } + + stop.store(true, Ordering::Release); + worker.join().expect("worker panicked"); + } + + /// Entering a subinterpreter from inside the parent's `enter` must attach + /// the subinterpreter's thread slot (and detach the parent's). Otherwise the + /// thread runs the sub's bytecode with a DETACHED slot, and a collector + /// stopping that interpreter force-parks the slot and wrongly concludes the + /// world is stopped while this thread keeps mutating objects. + #[cfg(all(feature = "threading", feature = "rustpython-compiler"))] + #[test] + fn nested_enter_of_subinterpreter_is_stoppable() { + use crate::compiler::Mode; + use alloc::sync::Arc; + use core::{ + sync::atomic::{AtomicBool, AtomicU64, Ordering}, + time::Duration, + }; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let sub_state = sub.enter(|vm| vm.state.clone()); + + let progress = Arc::new(AtomicU64::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + + // Worker runs the SUB nested inside an active MAIN section. + let progress_worker = Arc::clone(&progress); + let stop_worker = Arc::clone(&stop); + let main_vm = main.enter(|vm| vm.new_thread()); + let sub_vm = sub.enter(|vm| vm.new_thread()); + let worker = std::thread::spawn(move || { + main_vm.run(|_main| { + sub_vm.run(|vm| { + let source = "x = 1 + 1\n"; + let code = vm + .compile(source, Mode::Exec, "") + .map_err(|err| err.into_pyexception(vm, Some(source))) + .unwrap(); + while !stop_worker.load(Ordering::Acquire) { + let scope = vm.new_scope_with_builtins(); + vm.run_code_obj(code.clone(), scope).unwrap(); + progress_worker.fetch_add(1, Ordering::Release); + } + }); + }); + }); + + while progress.load(Ordering::Acquire) == 0 { + std::thread::yield_now(); + } + + sub_state.stop_the_world.stop_the_world(&sub_state); + let parked_at = progress.load(Ordering::Acquire); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!( + progress.load(Ordering::Acquire), + parked_at, + "nested subinterpreter thread kept running while the sub's world was stopped" + ); + sub_state.stop_the_world.start_the_world(&sub_state); + + let resumed_from = progress.load(Ordering::Acquire); + while progress.load(Ordering::Acquire) == resumed_from { + std::thread::yield_now(); + } + + stop.store(true, Ordering::Release); + worker.join().expect("nested worker panicked"); + } + + /// The process main id is recorded once and is stable across later creates. + #[test] + fn process_main_id_recorded_and_stable() { + // At least one main exists by now (this one, if not an earlier test), so + // `get_main()` is populated. + let main = Interpreter::without_stdlib(Default::default()); + let recorded = runtime::main_interpreter_id().expect("a process main exists"); + + // Recording is once-only: further interpreters do not displace it. + let _sub = main.create_subinterpreter(); + let _main2 = Interpreter::without_stdlib(Default::default()); + assert_eq!(runtime::main_interpreter_id(), Some(recorded)); + } } diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 7c7d017c1..c3861797b 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -13,6 +13,7 @@ mod interpreter; mod method; #[cfg(feature = "rustpython-compiler")] mod python_run; +pub mod runtime; mod setting; pub mod thread; mod vm_new; @@ -61,16 +62,22 @@ use std::{ pub use context::Context; pub use interpreter::{Interpreter, InterpreterBuilder}; pub(crate) use method::PyMethod; +pub use runtime::{InterpreterInfo, InterpreterWhence, MAIN_INTERPRETER_ID}; pub use setting::{CheckHashPycsMode, Paths, PyConfig, Settings}; pub const MAX_MEMORY_SIZE: usize = isize::MAX as usize; // Objects are live when they are on stack, or referenced by a name (for now) -/// Top level container of a python virtual machine. In theory you could -/// create more instances of this struct and have them operate fully isolated. +/// Per-thread execution context for a single interpreter (≈ CPython `PyThreadState`). /// -/// To construct this, please refer to the [`Interpreter`] +/// A `VirtualMachine` holds thread-local eval state (exceptions, recursion, frames, +/// datastack) plus shared references to interpreter-owned data (`state`, +/// `builtins`, `sys_module`, `ctx`). Multiple VMs may share the same +/// [`PyGlobalState`] via `VirtualMachine::new_thread`; distinct interpreters +/// each have their own `PyGlobalState` (see [`Interpreter::create_subinterpreter`]). +/// +/// To construct the main VM of an interpreter, use [`Interpreter`]. pub struct VirtualMachine { pub builtins: PyRef, pub sys_module: PyRef, @@ -110,11 +117,11 @@ pub struct VirtualMachine { /// pointer here before returning `ExecutionResult::TailCall`. /// Access only via `set_pending_tailcall` / `take_pending_tailcall`. pending_tailcall_frame: Cell>, - /// Owned references that keep callee raw pointers valid during TailCall. - /// Set by `tailcall_prepare_frame`, drained by the trampoline into - /// its local `owned_refs` Vec. Uses UnsafeCell because the VM is - /// per-thread and this field is only accessed on the owning thread. - pub(crate) pending_tailcall_refs: core::cell::UnsafeCell>, + /// Owned reference that keeps callee raw pointers valid during TailCall. + /// Set by the exact-call handlers and moved into the trampoline's + /// `SuspendedFrame`. Uses UnsafeCell because the VM is per-thread and this + /// field is only accessed on the owning thread. + pending_tailcall_owner: core::cell::UnsafeCell>, } /// Non-owning frame pointer for the non-unix threading frames stack. @@ -257,9 +264,9 @@ impl StopTheWorldState { } #[inline] - fn init_thread_countdown(&self, vm: &VirtualMachine) -> i64 { + fn init_thread_countdown(&self, state: &PyGlobalState) -> i64 { let requester = self.requester.load(Ordering::Relaxed); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); // Keep requested/count initialization serialized with thread-slot // registration (which also takes this lock), matching the // HEAD_LOCK-guarded stop-the-world bookkeeping. @@ -288,10 +295,10 @@ impl StopTheWorldState { /// Try to CAS detached threads directly to SUSPENDED and check whether /// stop countdown reached zero after parking detached threads. - fn park_detached_threads(&self, vm: &VirtualMachine) -> bool { + fn park_detached_threads(&self, state: &PyGlobalState) -> bool { use thread::{THREAD_ATTACHED, THREAD_DETACHED, THREAD_SUSPENDED}; let requester = self.requester.load(Ordering::Relaxed); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); let mut attached_seen = 0u64; let mut forced_parks = 0u64; @@ -413,23 +420,23 @@ impl StopTheWorldState { /// Takes the shared exclusion first so at most one requester (fork or GC) /// drives the stop→start span at a time; it is released by /// `start_the_world`/`reset_after_fork`. - pub fn stop_the_world(&self, vm: &VirtualMachine) { + pub fn stop_the_world(&self, state: &PyGlobalState) { self.acquire_exclusion(); let start = std::time::Instant::now(); let requester_ident = crate::stdlib::_thread::get_ident(); self.requester.store(requester_ident, Ordering::Relaxed); self.stats_stop_calls.fetch_add(1, Ordering::Relaxed); - let initial_countdown = self.init_thread_countdown(vm); + let initial_countdown = self.init_thread_countdown(state); stw_trace(format_args!("stop begin requester={requester_ident}")); // Park detached threads and set stop bits, then confirm every other // thread is SUSPENDED. The completion condition is level-triggered // (`all_non_requester_suspended`) so an already-suspended thread that // was counted but will not notify again cannot stall the stop. - self.park_detached_threads(vm); - if initial_countdown == 0 || self.all_non_requester_suspended(vm) { + self.park_detached_threads(state); + if initial_countdown == 0 || self.all_non_requester_suspended(state) { self.world_stopped.store(true, Ordering::Release); #[cfg(debug_assertions)] - self.debug_assert_all_non_requester_suspended(vm); + self.debug_assert_all_non_requester_suspended(state); stw_trace(format_args!( "stop end requester={requester_ident} wait_ns=0 polls=0" )); @@ -438,8 +445,8 @@ impl StopTheWorldState { let mut polls = 0u64; loop { - self.park_detached_threads(vm); - if self.all_non_requester_suspended(vm) { + self.park_detached_threads(state); + if self.all_non_requester_suspended(state) { break; } polls = polls.saturating_add(1); @@ -447,7 +454,7 @@ impl StopTheWorldState { // Re-check under the wait mutex first to avoid a lost-wake race: // a thread may have suspended and notified right before we enter wait. let guard = self.notify_mutex.lock().unwrap(); - if self.all_non_requester_suspended(vm) { + if self.all_non_requester_suspended(state) { drop(guard); break; } @@ -476,18 +483,18 @@ impl StopTheWorldState { } self.world_stopped.store(true, Ordering::Release); #[cfg(debug_assertions)] - self.debug_assert_all_non_requester_suspended(vm); + self.debug_assert_all_non_requester_suspended(state); stw_trace(format_args!( "stop end requester={requester_ident} wait_ns={wait_ns} polls={polls}" )); } /// Resume all suspended threads (`start_the_world`). - pub fn start_the_world(&self, vm: &VirtualMachine) { + pub fn start_the_world(&self, state: &PyGlobalState) { use thread::{THREAD_DETACHED, THREAD_SUSPENDED}; let requester = self.requester.load(Ordering::Relaxed); stw_trace(format_args!("start begin requester={requester}")); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); // Clear the request flag BEFORE waking threads. Otherwise a thread // returning from allow_threads → attach_thread could observe // `requested == true`, re-suspend itself, and stay parked forever. @@ -521,7 +528,7 @@ impl StopTheWorldState { self.thread_countdown.store(0, Ordering::Release); self.requester.store(0, Ordering::Relaxed); #[cfg(debug_assertions)] - self.debug_assert_all_non_requester_detached(vm); + self.debug_assert_all_non_requester_detached(state); // Release the exclusion last, ending the stop→start span so the next // requester (fork or GC) can proceed. self.release_exclusion(); @@ -604,10 +611,10 @@ impl StopTheWorldState { /// lost-decrement race under rapid back-to-back stops: a thread that is /// already SUSPENDED when a new stop counts it neither notifies nor is /// force-parked again, so an edge-based countdown could never reach zero. - fn all_non_requester_suspended(&self, vm: &VirtualMachine) -> bool { + fn all_non_requester_suspended(&self, state: &PyGlobalState) -> bool { use thread::THREAD_SUSPENDED; let requester = self.requester.load(Ordering::Relaxed); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); #[expect( clippy::iter_over_hash_type, @@ -625,10 +632,10 @@ impl StopTheWorldState { } #[cfg(debug_assertions)] - fn debug_assert_all_non_requester_suspended(&self, vm: &VirtualMachine) { + fn debug_assert_all_non_requester_suspended(&self, state: &PyGlobalState) { use thread::THREAD_SUSPENDED; let requester = self.requester.load(Ordering::Relaxed); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); #[expect( clippy::iter_over_hash_type, @@ -648,10 +655,10 @@ impl StopTheWorldState { } #[cfg(debug_assertions)] - fn debug_assert_all_non_requester_detached(&self, vm: &VirtualMachine) { + fn debug_assert_all_non_requester_detached(&self, state: &PyGlobalState) { use thread::THREAD_SUSPENDED; let requester = self.requester.load(Ordering::Relaxed); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); #[expect( clippy::iter_over_hash_type, @@ -732,7 +739,20 @@ pub(crate) struct CallableCache { pub builtin_any: Option, } +/// Per-interpreter shared state (≈ CPython `PyInterpreterState`). +/// +/// Not process-global: each [`Interpreter`] (main or subinterpreter) owns its own +/// `PyGlobalState`. Process-wide pieces live elsewhere (`Context::genesis`, +/// GC, the interpreter registry in [`runtime`]). pub struct PyGlobalState { + /// Unique process-global interpreter id (main is [`MAIN_INTERPRETER_ID`]). + pub interpreter_id: i64, + /// How this interpreter was created. + pub whence: runtime::InterpreterWhence, + /// True for every top-level (non-sub) interpreter, each of which keeps its + /// own signal and main-thread bookkeeping. Only the first one registered + /// becomes *the* process main — see [`runtime::main_interpreter_id`]. + pub is_main: bool, pub config: PyConfig, pub module_defs: BTreeMap<&'static str, &'static builtins::PyModuleDef>, pub frozen: HashMap<&'static str, FrozenModule, rapidhash::quality::RandomState>, @@ -777,6 +797,16 @@ pub struct PyGlobalState { /// Stop-the-world state for pre-fork thread suspension #[cfg(feature = "threading")] pub stop_the_world: StopTheWorldState, + /// This interpreter's garbage collector policy and results. + pub gc: crate::gc_state::GcInterpreterState, +} + +impl PyGlobalState { + #[inline] + #[must_use] + pub fn is_main_interpreter(&self) -> bool { + self.is_main + } } pub fn process_hash_secret_seed() -> u32 { @@ -828,11 +858,12 @@ pub(crate) struct IframeEntryState { struct SuspendedFrame { iframe: *mut crate::frame::InterpreterFrame, entry_state: IframeEntryState, - /// Owned references that keep callee's raw pointers (code, globals, - /// builtins borrowed from PyFunction) valid. Drained from - /// `vm.pending_tailcall_refs` when the callee's TailCall is consumed. - /// Dropped when this SuspendedFrame is popped (after callee returns/errors). - owned_refs: Vec, + /// Function that owns the callee's raw pointers (code, globals, builtins, + /// closure, and func_obj). Moved from `vm.pending_tailcall_owner` when the + /// callee's TailCall is consumed. + /// Dropped as soon as this SuspendedFrame is popped — the callee has + /// returned or raised and its frame is already released by then. + callee_owner: PyObjectRef, /// True for the initial frame passed into the trampoline by the caller. /// The caller owns the datastack allocation for the entry frame, so the /// trampoline must NOT release it — only callee-allocated frames are @@ -865,6 +896,13 @@ impl VirtualMachine { unsafe { (*self.datastack.get()).push(size) } } + /// Bump-allocate a full frame, returning whether the same cleared LIFO + /// block and size were reused. + #[inline(always)] + pub(crate) fn datastack_push_frame(&self, size: usize) -> (*mut u8, bool) { + unsafe { (*self.datastack.get()).push_frame(size) } + } + /// Check whether the thread data stack currently has room for `size` bytes. #[inline(always)] pub(crate) fn datastack_has_space(&self, size: usize) -> bool { @@ -881,6 +919,12 @@ impl VirtualMachine { unsafe { (*self.datastack.get()).pop(base) } } + /// Pop a full frame after its localsplus slots have been cleared. + #[inline(always)] + pub(crate) unsafe fn datastack_pop_frame(&self, base: *mut u8, size: usize) { + unsafe { (*self.datastack.get()).pop_frame(base, size) } + } + /// Temporarily detach the current thread (ATTACHED → DETACHED) while /// running `f`, then re-attach afterwards. Allows `stop_the_world` to /// park this thread during blocking syscalls. @@ -955,7 +999,7 @@ impl VirtualMachine { callable_cache: CallableCache::default(), audit_hooks: RefCell::new(vec![]), pending_tailcall_frame: Cell::new(None), - pending_tailcall_refs: core::cell::UnsafeCell::new(Vec::with_capacity(2)), + pending_tailcall_owner: core::cell::UnsafeCell::new(None), }; if vm.state.hash_secret.hash_str("") @@ -1083,9 +1127,12 @@ impl VirtualMachine { assert!(!self.initialized, "Double Initialize Error"); - // Initialize main thread ident before any threading operations + // Process main-thread identity is owned by the main interpreter only + // (used for signal handling / `_thread._is_main_interpreter` helpers). #[cfg(feature = "threading")] - stdlib::_thread::init_main_thread_ident(self); + if self.state.is_main_interpreter() { + stdlib::_thread::init_main_thread_ident(self); + } stdlib::builtins::init_module(self, &self.builtins); let callable_cache_init = self.init_callable_cache(); @@ -1406,6 +1453,22 @@ impl VirtualMachine { .set(Some(PendingFrame(core::ptr::NonNull::from(iframe)))); } + /// Store the function that owns the fields borrowed by the pending callee. + #[inline(always)] + pub(crate) fn set_pending_tailcall_owner(&self, owner: PyObjectRef) { + let slot = unsafe { &mut *self.pending_tailcall_owner.get() }; + debug_assert!(slot.is_none(), "pending TailCall owner was not consumed"); + *slot = Some(owner); + } + + /// Take the pending callee owner, resetting the side channel. + #[inline(always)] + fn take_pending_tailcall_owner(&self) -> PyObjectRef { + unsafe { &mut *self.pending_tailcall_owner.get() } + .take() + .expect("TailCall without pending owner") + } + /// Take the pending tailcall frame pointer, resetting the side channel. #[inline(always)] fn take_pending_tailcall(&self) -> *mut crate::frame::InterpreterFrame { @@ -1466,18 +1529,11 @@ impl VirtualMachine { } let initial_ptr = self.take_pending_tailcall(); - // Drain the refs that keep the initial callee's raw pointers alive. - #[allow( - clippy::drain_collect, - reason = "`pending_tailcall_refs`'s allocation is intentionally reused" - )] - let initial_refs = unsafe { &mut *self.pending_tailcall_refs.get() } - .drain(..) - .collect(); + let initial_owner = self.take_pending_tailcall_owner(); frame_stack.push(SuspendedFrame { iframe: iframe as *mut crate::frame::InterpreterFrame, entry_state, - owned_refs: initial_refs, + callee_owner: initial_owner, is_entry: true, }); let mut action = Action::EnterCallee(initial_ptr); @@ -1490,8 +1546,8 @@ impl VirtualMachine { Ok(state) => state, Err(exc) => { unsafe { - if let Some(base) = callee.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = callee.release_datastack_frame() { + self.datastack_pop_frame(base, size); } } action = Action::Unwind(exc); @@ -1502,17 +1558,11 @@ impl VirtualMachine { let result = crate::frame::run_iframe(callee, self); match result { Ok(ExecutionResult::TailCall) => { - #[allow( - clippy::drain_collect, - reason = "`pending_tailcall_refs`'s allocation is intentionally reused" - )] - let refs = unsafe { &mut *self.pending_tailcall_refs.get() } - .drain(..) - .collect(); + let callee_owner = self.take_pending_tailcall_owner(); frame_stack.push(SuspendedFrame { iframe: callee_ptr, entry_state: callee_entry, - owned_refs: refs, + callee_owner, is_entry: false, }); action = Action::EnterCallee(self.take_pending_tailcall()); @@ -1520,8 +1570,8 @@ impl VirtualMachine { Ok(ExecutionResult::Return(value)) => { self.exit_iframe(callee_entry); unsafe { - if let Some(base) = callee.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = callee.release_datastack_frame() { + self.datastack_pop_frame(base, size); } } action = Action::ReturnValue(value); @@ -1530,8 +1580,8 @@ impl VirtualMachine { Err(exc) => { self.exit_iframe(callee_entry); unsafe { - if let Some(base) = callee.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = callee.release_datastack_frame() { + self.datastack_pop_frame(base, size); } } action = Action::Unwind(exc); @@ -1547,38 +1597,38 @@ impl VirtualMachine { let SuspendedFrame { iframe: caller_iframe_ptr, entry_state: caller_entry, - owned_refs: _caller_refs, + callee_owner, is_entry: caller_is_entry, } = caller; + // The callee's frame was released before this action was + // formed, and a materialized frame object holds its own + // references, so nothing borrows the callee's function any + // more. Release it here, at the callee's return, rather than + // holding it across the caller's next stretch of bytecode. + drop(callee_owner); let caller_iframe = unsafe { &mut *caller_iframe_ptr }; caller_iframe.localsplus.push_stack(value); let result = crate::frame::run_iframe(caller_iframe, self); match result { Ok(ExecutionResult::TailCall) => { - #[allow( - clippy::drain_collect, - reason = "`pending_tailcall_refs`'s allocation is intentionally reused" - )] - let refs = unsafe { &mut *self.pending_tailcall_refs.get() } - .drain(..) - .collect(); - drop(_caller_refs); + let next_callee_owner = self.take_pending_tailcall_owner(); frame_stack.push(SuspendedFrame { iframe: caller_iframe_ptr, entry_state: caller_entry, - owned_refs: refs, + callee_owner: next_callee_owner, is_entry: caller_is_entry, }); action = Action::EnterCallee(self.take_pending_tailcall()); } Ok(ExecutionResult::Return(value)) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop_frame(base, size); } } } @@ -1586,12 +1636,13 @@ impl VirtualMachine { } Ok(ExecutionResult::Yield(_)) => panic!("Yield in non-generator frame"), Err(exc) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop_frame(base, size); } } } @@ -1607,9 +1658,13 @@ impl VirtualMachine { let SuspendedFrame { iframe: caller_iframe_ptr, entry_state: caller_entry, - owned_refs: _caller_refs, + callee_owner, is_entry: caller_is_entry, } = caller; + // Released at the callee's return, for the same reason as + // in `ReturnValue`: the exception carries owned references + // through its traceback, not borrows into the callee frame. + drop(callee_owner); let caller_iframe = unsafe { &mut *caller_iframe_ptr }; let handled = @@ -1621,31 +1676,23 @@ impl VirtualMachine { let result = crate::frame::run_iframe(caller_iframe, self); match result { Ok(ExecutionResult::TailCall) => { - #[allow( - clippy::drain_collect, - reason = "`pending_tailcall_refs`'s allocation is intentionally reused" - )] - let refs = unsafe { &mut *self.pending_tailcall_refs.get() } - .drain(..) - .collect(); - drop(_caller_refs); + let next_callee_owner = self.take_pending_tailcall_owner(); frame_stack.push(SuspendedFrame { iframe: caller_iframe_ptr, entry_state: caller_entry, - owned_refs: refs, + callee_owner: next_callee_owner, is_entry: caller_is_entry, }); action = Action::EnterCallee(self.take_pending_tailcall()); } Ok(ExecutionResult::Return(value)) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = + if let Some((base, size)) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + self.datastack_pop_frame(base, size); } } } @@ -1655,14 +1702,13 @@ impl VirtualMachine { panic!("Yield in non-generator frame") } Err(new_exc) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = + if let Some((base, size)) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + self.datastack_pop_frame(base, size); } } } @@ -1671,12 +1717,13 @@ impl VirtualMachine { } } Ok(Some(ExecutionResult::Return(value))) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop_frame(base, size); } } } @@ -1686,12 +1733,13 @@ impl VirtualMachine { panic!("Unexpected execution result in trampoline unwind") } Err(new_exc) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop_frame(base, size); } } } @@ -1783,14 +1831,14 @@ impl VirtualMachine { // Phase 4: GC collect — modules removed from sys.modules are freed, // exposing cycles (e.g., dict ↔ function.__globals__). GC collects // these and calls __del__ while module dicts are still intact. - crate::gc_state::gc_state().collect_force(2); + self.state.gc.collect_force(2); // Phase 5: Clear module dicts in reverse import order using 2-pass algorithm. // Skip builtins and sys — those are cleared last. self.finalize_clear_module_dicts(&module_weakrefs); // Phase 6: GC collect — pick up anything freed by dict clearing. - crate::gc_state::gc_state().collect_force(2); + self.state.gc.collect_force(2); // Phase 7: Clear sys and builtins dicts last self.finalize_clear_sys_builtins_dict(); @@ -2148,7 +2196,7 @@ impl VirtualMachine { self.restore_exception(saved_exc); } // Clear previous before popping — it may point to a stack-allocated - // iframe that will be freed when the caller's with_iframe exits. + // iframe that will be freed when the caller releases its frame. { #[allow(unused_imports)] use rustpython_common::atomic::Radium; @@ -2261,6 +2309,9 @@ impl VirtualMachine { core::sync::atomic::Ordering::Relaxed, ); } + // The slots above are the last write this thread makes into + // the frame object, so it is now readable from anywhere. + fo.iframe().detach(); if !old_chain.is_null() { let prev_iframe = unsafe { &*old_chain }; let back_fo = prev_iframe.materialize_chain(self); @@ -2277,7 +2328,7 @@ impl VirtualMachine { self.restore_exception(saved_exc); } // Clear previous before popping — it may point to a stack-allocated - // iframe that will be freed when the caller's with_iframe exits. + // iframe that will be freed when the caller releases its frame. { #[allow(unused_imports)] use rustpython_common::atomic::Radium; @@ -2300,8 +2351,10 @@ impl VirtualMachine { if mat_ptr != 0 { let fo = unsafe { &*(mat_ptr as *const crate::Py) }; unsafe { - crate::gc_state::gc_state() - .track_object(core::ptr::NonNull::from(fo.as_object())); + crate::gc_state::gc_state().track_object( + core::ptr::NonNull::from(fo.as_object()), + crate::gc_state::current_owner(), + ); let live_iframe = &*iframe_ptr; live_iframe.cold().temporary_refs.lock().clear(); } @@ -2309,20 +2362,6 @@ impl VirtualMachine { } } - pub fn with_iframe( - &self, - iframe: &mut crate::frame::InterpreterFrame, - f: impl FnOnce(&mut crate::frame::InterpreterFrame) -> PyResult, - ) -> PyResult { - let state = self.enter_iframe(iframe)?; - // Ensure exit_iframe runs even if f(iframe) panics. - let guard = scopeguard::guard(state, |s| self.exit_iframe(s)); - let result = f(iframe); - let state = scopeguard::ScopeGuard::into_inner(guard); - self.exit_iframe(state); - result - } - /// FrameObject execution for generator/coroutine resume. /// Pushes a new exc_info slot (gi_exc_state) onto the chain, /// linking the generator's saved handled-exception. @@ -2364,7 +2403,7 @@ impl VirtualMachine { frame.iframe().owner.store(old_owner, core::sync::atomic::Ordering::Release); self.pop_exception(); // Clear previous before popping — it may point to a stack-allocated - // iframe that will be freed when the caller's with_iframe exits. + // iframe that will be freed when the caller releases its frame. { #[allow(unused_imports)] use rustpython_common::atomic::Radium; @@ -2780,7 +2819,7 @@ impl VirtualMachine { #[cfg(feature = "threading")] pub(crate) fn run_scheduled_gc(&self) { if crate::signal::take_gc_scheduled() { - crate::gc_state::gc_state().collect(0); + self.state.gc.collect(0); } } diff --git a/crates/vm/src/vm/runtime.rs b/crates/vm/src/vm/runtime.rs new file mode 100644 index 000000000..7c5168c45 --- /dev/null +++ b/crates/vm/src/vm/runtime.rs @@ -0,0 +1,326 @@ +//! Process-global runtime support for multiple interpreters (PEP 734 preparation). +//! +//! CPython maps roughly as: +//! - this module ≈ `_PyRuntimeState.interpreters` + ID allocation +//! - [`crate::vm::PyGlobalState`] ≈ `PyInterpreterState` +//! - [`crate::VirtualMachine`] ≈ `PyThreadState` (plus shared refs to interpreter state) +//! +//! Multiple [`crate::Interpreter`] instances can coexist in one process. Each owns +//! an isolated `PyGlobalState` (modules, codecs, thread registry, stop-the-world, …) +//! while sharing the process-wide [`crate::Context`] (builtin types / immortals). + +use crate::common::rc::PyRc; +use crate::vm::PyGlobalState; +use core::sync::atomic::{AtomicI64, Ordering}; +use parking_lot::Mutex; +use std::collections::HashMap; + +/// Where an interpreter state came from (mirrors CPython `_PyInterpreterState_GetWhence`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(i32)] +pub enum InterpreterWhence { + /// Unknown / not recorded. + Unknown = 0, + /// Created as the process main interpreter at runtime init. + Runtime = 1, + /// Legacy C-API creation path (reserved for C-API parity). + LegacyCapi = 2, + /// Modern C-API creation path (reserved for C-API parity). + Capi = 3, + /// Cross-interpreter C-API (reserved). + Xi = 4, + /// Created via the stdlib / Rust subinterpreter API (PEP 734). + Stdlib = 5, +} + +impl InterpreterWhence { + #[must_use] + pub const fn as_i32(self) -> i32 { + self as i32 + } +} + +/// Snapshot of a registered interpreter for enumeration APIs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InterpreterInfo { + pub id: i64, + pub whence: InterpreterWhence, +} + +struct RegistryEntry { + whence: InterpreterWhence, + /// Weak handle so the registry does not keep interpreters alive. + /// Type matches `PyRc` (Arc when threading, Rc otherwise). + #[cfg(feature = "threading")] + state: alloc::sync::Weak, + #[cfg(not(feature = "threading"))] + state: alloc::rc::Weak, +} + +/// `main_id` value before any main interpreter has been registered. +const NO_MAIN_INTERPRETER: i64 = -1; + +struct InterpreterRegistry { + next_id: AtomicI64, + /// Id of the first registered `is_main` interpreter (PEP 734 `get_main()`), + /// or [`NO_MAIN_INTERPRETER`]. + main_id: AtomicI64, + /// id → entry. Main interpreter is always id 0 when created first. + entries: Mutex>, +} + +impl InterpreterRegistry { + fn new() -> Self { + Self { + // Monotonic ids starting at 0. Concurrent Interpreter construction + // (e.g. cargo test threads) must never share an id. + next_id: AtomicI64::new(0), + main_id: AtomicI64::new(NO_MAIN_INTERPRETER), + entries: Mutex::new(HashMap::new()), + } + } +} + +/// The interpreter registry. +/// +/// With `threading` this is one process-global table. Without it, `PyRc` is +/// `Rc` and each OS thread owns an independent `Context::genesis()` and +/// `GcState`, so the registry is thread-local for the same reason `gc_state()` +/// is: an `Rc` handle must never be reachable from another thread. +/// `static_cell!` provides exactly that split. +fn registry() -> &'static InterpreterRegistry { + rustpython_common::static_cell! { + static REGISTRY: InterpreterRegistry; + } + REGISTRY.get_or_init(InterpreterRegistry::new) +} + +/// Conventional id of the first process main interpreter when allocation is +/// sequential (CPython parity). Concurrent construction may assign other ids; +/// use [`PyGlobalState::is_main`] / [`crate::Interpreter::is_main`] to identify +/// a main interpreter, not this constant alone. +pub const MAIN_INTERPRETER_ID: i64 = 0; + +/// Backs `sys.implementation.supports_isolated_interpreters`. +/// +/// The Rust substrate already isolates interpreters (`PyGlobalState` per +/// interpreter, per-interpreter thread slots / stop-the-world). This stays +/// `false` until the Python-facing `_interpreters` module is wired up; flip it +/// in the commit that lands `_interpreters`. +pub const SUPPORTS_ISOLATED_INTERPRETERS: bool = false; + +/// Id of the main interpreter (PEP 734 `get_main()`), or `None` before any +/// interpreter has been created. +/// +/// This is distinct from [`PyGlobalState::is_main`]: every top-level (non-sub) +/// interpreter carries `is_main` for its own signal / main-thread bookkeeping, +/// but only the first one registered becomes *the* main. +#[must_use] +pub fn main_interpreter_id() -> Option { + match registry().main_id.load(Ordering::Acquire) { + NO_MAIN_INTERPRETER => None, + id => Some(id), + } +} + +/// Allocate a unique interpreter id. +/// +/// Ids are strictly monotonic and never reused for the lifetime of the +/// registry, so concurrent `Interpreter` construction (parallel unit tests, +/// multi-threaded embedding) never shares an id. Without `threading` the +/// registry — like `Context::genesis()` and the GC state — is per OS thread, so +/// ids are unique within a thread rather than across the process. +pub(crate) fn alloc_interpreter_id() -> i64 { + registry().next_id.fetch_add(1, Ordering::Relaxed) +} + +/// Gate between registering an interpreter and a collection's stop-the-world. +/// +/// A collection snapshots the registry, stops every interpreter in the +/// snapshot, and then reads tracked objects with those threads parked. An +/// interpreter that registered after the snapshot was taken would not be in it, +/// so nothing would stop it, and its bootstrap — which runs Python and mutates +/// the shared generation lists — would run underneath that scan. Registration +/// therefore waits for an in-flight stop to end; the next collection's snapshot +/// then contains the new interpreter. +fn admission() -> &'static Mutex<()> { + static ADMISSION: std::sync::OnceLock> = std::sync::OnceLock::new(); + ADMISSION.get_or_init(|| Mutex::new(())) +} + +/// Take the admission gate for the duration of a stop-the-world. +#[cfg(feature = "threading")] +pub(crate) fn lock_admission_for_stop() -> parking_lot::MutexGuard<'static, ()> { + admission().lock() +} + +/// Add the registry entry, behind the admission gate. +/// +/// Only ever called with this thread detached, because the gate is held across +/// a stop-the-world: an attached thread waiting here, or re-attaching while +/// holding the gate, would leave that stop no safepoint to complete at. Nothing +/// under the gate blocks or allocates a tracked object, so this cannot re-enter +/// the collection it waits for. +fn insert_registry_entry(state: &PyRc) { + let _admission = admission().lock(); + let mut entries = registry().entries.lock(); + // Entries are weak and an interpreter's lifetime is decided by its last + // `PyRc` — which outlives the `Interpreter` handle whenever + // `new_thread()` workers are still running — so nothing removes them at a + // fixed point. Reap the dead ones here to bound the table instead. + entries.retain(|_, entry| entry.state.strong_count() > 0); + entries.insert( + state.interpreter_id, + RegistryEntry { + whence: state.whence, + state: PyRc::downgrade(state), + }, + ); +} + +/// Register an interpreter state in the registry. +pub(crate) fn register_interpreter(state: &PyRc) { + let id = state.interpreter_id; + if state.is_main { + // First `is_main` interpreter defines the main for `get_main()`. + // Additional top-level Interpreters (embedding) keep their own `is_main` + // flag but do not displace the recorded main. + let _ = registry().main_id.compare_exchange( + NO_MAIN_INTERPRETER, + id, + Ordering::AcqRel, + Ordering::Relaxed, + ); + } + // A subinterpreter is registered by a thread that is running its parent, so + // detach for the whole insert rather than only for the wait. + let detached = crate::vm::thread::try_with_current_vm(|vm| { + vm.allow_threads(|| insert_registry_entry(state)) + }); + if detached.is_none() { + insert_registry_entry(state); + } +} + +/// Look up a live interpreter state by id. +#[must_use] +pub fn lookup_interpreter(id: i64) -> Option> { + let entries = registry().entries.lock(); + entries.get(&id).and_then(|e| e.state.upgrade()) +} + +/// List all currently registered (still-alive) interpreters. +#[must_use] +pub fn list_interpreters() -> Vec { + let entries = registry().entries.lock(); + let mut out: Vec = entries + .iter() + .filter_map(|(&id, entry)| { + // Drop dead weak refs from the listing. + if entry.state.strong_count() == 0 { + return None; + } + Some(InterpreterInfo { + id, + whence: entry.whence, + }) + }) + .collect(); + out.sort_by_key(|info| info.id); + out +} + +/// Number of registered interpreters that are still alive. +#[must_use] +pub fn interpreter_count() -> usize { + list_interpreters().len() +} + +/// Reset the registry's locks after `fork()`. +/// +/// The tables are reachable from every thread, so a thread that died in the +/// fork may have left one locked; the child would then deadlock the first time +/// it enumerates interpreters (which the collector now does on every stop). +/// +/// # Safety +/// Must only be called after `fork()` in the child process, when no other +/// threads exist and the calling thread holds none of these locks. +#[cfg(all(unix, feature = "threading"))] +pub unsafe fn reinit_after_fork() { + unsafe { + crate::common::lock::reinit_mutex_after_fork(®istry().entries); + crate::common::lock::reinit_mutex_after_fork(owned_interpreters()); + crate::common::lock::reinit_mutex_after_fork(admission()); + } +} + +/// All live interpreter states, ordered by id. +/// +/// Used by the cyclic collector, which must stop every interpreter's threads +/// (not just the collecting one) because GC-tracked objects from all +/// interpreters share one object graph. Ordering is deterministic so that +/// multiple stop-the-world requesters always take exclusions in the same order. +#[must_use] +pub fn live_interpreter_states() -> Vec> { + let entries = registry().entries.lock(); + let mut states: Vec<(i64, PyRc)> = entries + .iter() + .filter_map(|(&id, entry)| entry.state.upgrade().map(|state| (id, state))) + .collect(); + drop(entries); + states.sort_by_key(|(id, _)| *id); + states.into_iter().map(|(_, state)| state).collect() +} + +/// Runtime-owned interpreters (the ownership anchor for the Python +/// `_interpreters` API). +/// +/// A Rust [`crate::Interpreter`] handle is normally owned by its Rust caller. +/// For PEP 734, `_interpreters.create()` returns only an id and the runtime +/// must keep the interpreter alive until `_interpreters.destroy(id)`. These +/// functions hold that ownership, keyed by interpreter id, while the weak +/// [`registry`] above still drives enumeration and lookup. +/// +/// Only available with the `threading` feature: a runtime-owned interpreter is +/// reachable from other OS threads, which requires `Interpreter: Send` (true +/// only when `PyObjectRef` is `Arc`-backed). +#[cfg(feature = "threading")] +fn owned_interpreters() -> &'static Mutex> { + use std::sync::OnceLock; + static OWNED: OnceLock>> = OnceLock::new(); + OWNED.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Transfer ownership of `interp` to the runtime, returning its id. +#[cfg(feature = "threading")] +pub fn store_owned_interpreter(interp: crate::Interpreter) -> i64 { + let id = interp.id(); + // Ids are strictly monotonic, so this never displaces (and drops) an + // existing entry under the lock. + owned_interpreters().lock().insert(id, interp); + id +} + +/// Reclaim a runtime-owned interpreter, removing it from the owner table. +/// +/// The returned handle is dropped by the caller *outside* the owner lock; its +/// `Drop` unregisters the interpreter from the weak [`registry`]. +#[cfg(feature = "threading")] +#[must_use] +pub fn take_owned_interpreter(id: i64) -> Option { + owned_interpreters().lock().remove(&id) +} + +/// Whether `id` refers to a runtime-owned interpreter. +#[cfg(feature = "threading")] +#[must_use] +pub fn is_owned_interpreter(id: i64) -> bool { + owned_interpreters().lock().contains_key(&id) +} + +/// Number of runtime-owned interpreters currently alive. +#[cfg(feature = "threading")] +#[must_use] +pub fn owned_interpreter_count() -> usize { + owned_interpreters().lock().len() +} diff --git a/crates/vm/src/vm/setting.rs b/crates/vm/src/vm/setting.rs index 7298c95ab..3c42ca0b6 100644 --- a/crates/vm/src/vm/setting.rs +++ b/crates/vm/src/vm/setting.rs @@ -25,6 +25,7 @@ pub struct Paths { /// Combined configuration: user settings + computed paths /// CPython directly exposes every fields under both of them. /// We separate them to maintain better ownership discipline. +#[derive(Clone)] pub struct PyConfig { pub settings: Settings, pub paths: Paths, @@ -39,6 +40,7 @@ impl PyConfig { /// User-configurable settings for the python vm. #[non_exhaustive] +#[derive(Clone)] pub struct Settings { /// -I pub isolated: bool, diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 1bab539a0..3b378acd6 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -8,11 +8,6 @@ use alloc::sync::Arc; #[cfg(all(unix, feature = "threading"))] use crate::frame::FrameObject; use crate::frame::InterpreterFrame; -#[cfg(all(unix, feature = "threading"))] -use crate::{AsObject, Py, PyObject, VirtualMachine}; -#[cfg(all(not(unix), feature = "threading"))] -use crate::{AsObject, PyObject, VirtualMachine}; -#[cfg(not(feature = "threading"))] use crate::{AsObject, PyObject, VirtualMachine}; #[cfg(all(unix, feature = "threading"))] use core::sync::atomic::AtomicPtr; @@ -22,6 +17,8 @@ use core::{ sync::atomic::{AtomicUsize, Ordering}, }; use itertools::Itertools; +#[cfg(feature = "threading")] +use std::collections::HashMap; use std::thread_local; // Thread states for stop-the-world support. @@ -90,7 +87,17 @@ thread_local! { pub(crate) static COROUTINE_ORIGIN_TRACKING_DEPTH: Cell = const { Cell::new(0) }; - /// Current thread's slot for sys._current_frames() and sys._current_exceptions() + /// Per-interpreter thread slots for this OS thread (PEP 734 multi-interpreter). + /// + /// CPython keeps a `PyThreadState` per (thread, interpreter) pair. RustPython + /// mirrors that: each interpreter's `PyGlobalState.thread_frames` gets its own + /// [`ThreadSlot`] for this OS thread. `CURRENT_THREAD_SLOT` always points at + /// the slot for the currently entered interpreter. + #[cfg(feature = "threading")] + static INTERP_THREAD_SLOTS: RefCell> = + RefCell::new(HashMap::new()); + + /// Current thread's slot for the currently entered interpreter. #[cfg(feature = "threading")] static CURRENT_THREAD_SLOT: RefCell> = const { RefCell::new(None) }; @@ -110,6 +117,21 @@ thread_local! { static CURRENT_TOP_FRAME_SLOT: Cell<*const AtomicPtr> = const { Cell::new(core::ptr::null()) }; + /// Cached pointer to this thread's `ThreadSlot::top_iframe` for the hot + /// light-frame push/pop path. The slot's Arc keeps the pointee alive. + #[cfg(feature = "threading")] + static CURRENT_TOP_IFRAME_SLOT: Cell<*const AtomicUsize> = + const { Cell::new(core::ptr::null()) }; + + /// Cached pointer to this thread's `ThreadSlot::stop_requested`, for the + /// safepoint the dispatch loop takes once per instruction. Reading it + /// through `CURRENT_THREAD_SLOT` costs a `RefCell` borrow — two stores to + /// thread-local memory — where this costs one relaxed load. The slot's Arc + /// keeps the pointee alive, as with the frame pointers above. + #[cfg(feature = "threading")] + static CURRENT_STOP_REQUESTED: Cell<*const core::sync::atomic::AtomicBool> = + const { Cell::new(core::ptr::null()) }; + } #[must_use] @@ -131,15 +153,42 @@ pub fn with_current_vm(f: impl FnOnce(&VirtualMachine) -> R) -> R { } fn set_current_vm(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { + // Attach to this VM's interpreter, detaching the enclosing one if this is a + // switch between interpreters on the same OS thread. + #[cfg(feature = "threading")] + let switched = begin_interpreter_section(vm); + VM_STACK.with(|vms| { vms.borrow_mut().push(vm.into()); scopeguard::defer! { vms.borrow_mut().pop(); + #[cfg(feature = "threading")] + end_interpreter_section(switched); } f() }) } +/// Pointer to the GC state of the interpreter running on this thread. +/// +/// The pointee belongs to the `PyGlobalState` of the VM on top of `VM_STACK`, +/// which is borrowed for the whole `set_current_vm` scope — so the pointer stays +/// valid as long as the caller remains inside that scope. +pub(crate) fn current_gc_state() -> Option> { + // Reached from every tracked allocation, including ones a thread-local + // destructor makes while the VM stack is being torn down, so neither a + // destroyed key nor an outstanding borrow may panic here. + VM_STACK + .try_with(|vms| { + let vm = vms.try_borrow().ok()?.last().copied()?; + // SAFETY: entries in VM_STACK either borrow a VM for the dynamic + // scope of a set_current_vm()/enter_vm() call or point at GILSTATE_VM. + Some(NonNull::from(&unsafe { vm.as_ref() }.state.gc)) + }) + .ok() + .flatten() +} + pub fn try_with_current_vm(f: impl FnOnce(&VirtualMachine) -> R) -> Option { VM_STACK.with(|vms| { let vm = vms.borrow().last().copied()?; @@ -150,27 +199,8 @@ pub fn try_with_current_vm(f: impl FnOnce(&VirtualMachine) -> R) -> Option } pub fn enter_vm(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { - // Outermost enter_vm: transition DETACHED → ATTACHED - #[cfg(feature = "threading")] - let was_outermost = !current_vm_is_set(); - - // Initialize thread slot for this thread if not already done - #[cfg(feature = "threading")] - init_thread_slot_if_needed(vm); - - #[cfg(feature = "threading")] - if was_outermost { - attach_thread(vm); - } - - scopeguard::defer! { - // Outermost exit: transition ATTACHED → DETACHED - #[cfg(feature = "threading")] - if was_outermost { - detach_thread(); - } - } - + // Attach/detach is handled by `set_current_vm`, which pairs it with the + // VM_STACK push so that switching interpreters mid-stack stays consistent. set_current_vm(vm, f) } @@ -188,29 +218,19 @@ pub fn enter_vm(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { #[must_use] pub(crate) struct VmBootstrapGuard { #[cfg(feature = "threading")] - was_outermost: bool, + switched: bool, } impl VmBootstrapGuard { pub(crate) fn new(vm: &VirtualMachine) -> Self { - // Outermost: transition DETACHED → ATTACHED #[cfg(feature = "threading")] - let was_outermost = !current_vm_is_set(); - - // Initialize thread slot for this thread if not already done - #[cfg(feature = "threading")] - init_thread_slot_if_needed(vm); - - #[cfg(feature = "threading")] - if was_outermost { - attach_thread(vm); - } + let switched = begin_interpreter_section(vm); VM_STACK.with(|vms| vms.borrow_mut().push(vm.into())); Self { #[cfg(feature = "threading")] - was_outermost, + switched, } } } @@ -221,11 +241,8 @@ impl Drop for VmBootstrapGuard { vms.borrow_mut().pop(); }); - // Outermost exit: transition ATTACHED → DETACHED #[cfg(feature = "threading")] - if self.was_outermost { - detach_thread(); - } + end_interpreter_section(self.switched); } } @@ -287,7 +304,13 @@ pub fn restore_current_thread(state: SavedThreadState) { // SAFETY: borrowed VMs remain alive for the dynamic save/restore scope, // while an owned GILState VM was restored above before this dereference. - attach_thread(unsafe { vm.as_ref() }); + let vm = unsafe { vm.as_ref() }; + // Point CURRENT_THREAD_SLOT at the restored interpreter before attach. + // After subinterpreter bootstrap, CURRENT may still refer to the temporary + // subinterpreter slot (DETACHED); attaching that would leave the parent + // slot detached and later confuse outermost detach. + init_thread_slot_if_needed(vm); + attach_thread(vm); VM_STACK.with(|vms| *vms.borrow_mut() = vm_stack); } @@ -340,43 +363,130 @@ pub fn release_current_thread(state: CurrentVmAttachState) { detach_thread(); } -/// Initialize thread slot for current thread if not already initialized. -/// Called automatically by enter_vm(). +/// Ensure this OS thread has a [`ThreadSlot`] registered with `vm`'s interpreter +/// and make it the current slot. +/// +/// Called automatically by `enter_vm()` / `VmBootstrapGuard` whenever a VM +/// becomes current. Switching between interpreters on the same OS thread swaps +/// `CURRENT_THREAD_SLOT` to that interpreter's slot (creating one if needed). #[cfg(feature = "threading")] fn init_thread_slot_if_needed(vm: &VirtualMachine) { - CURRENT_THREAD_SLOT.with(|slot| { - if slot.borrow().is_none() { - let thread_id = crate::stdlib::_thread::get_ident(); - let mut registry = vm.state.thread_frames.lock(); - let new_slot = Arc::new(ThreadSlot { - #[cfg(unix)] - top_frame: AtomicPtr::new(core::ptr::null_mut()), - top_iframe: AtomicUsize::new(0), - #[cfg(not(unix))] - frames: parking_lot::Mutex::new(Vec::new()), - exception: crate::PyAtomicRef::from(None::), - state: core::sync::atomic::AtomicI32::new( - if vm.state.stop_the_world.requested.load(Ordering::Acquire) { - // Match init_threadstate(): new thread-state starts - // suspended while stop-the-world is active. - THREAD_SUSPENDED - } else { - THREAD_DETACHED - }, - ), - stop_requested: core::sync::atomic::AtomicBool::new(false), - thread: std::thread::current(), - qsbr: crate::object::qsbr::QSBR.register(), - }); - registry.insert(thread_id, new_slot.clone()); - drop(registry); - #[cfg(all(unix, feature = "threading"))] - CURRENT_TOP_FRAME_SLOT.with(|c| c.set(&new_slot.top_frame)); - *slot.borrow_mut() = Some(new_slot); + let slot = ensure_thread_slot(vm); + set_current_thread_slot(slot); +} + +/// Look up (creating if needed) this thread's [`ThreadSlot`] for `vm`'s +/// interpreter, without making it the current slot. +#[cfg(feature = "threading")] +fn ensure_thread_slot(vm: &VirtualMachine) -> CurrentFrameSlot { + let interp_id = vm.state.interpreter_id; + INTERP_THREAD_SLOTS.with(|slots| { + let mut slots = slots.borrow_mut(); + if let Some(existing) = slots.get(&interp_id) { + return existing.clone(); } + + let thread_id = crate::stdlib::_thread::get_ident(); + let mut registry = vm.state.thread_frames.lock(); + let new_slot = Arc::new(ThreadSlot { + #[cfg(unix)] + top_frame: AtomicPtr::new(core::ptr::null_mut()), + top_iframe: AtomicUsize::new(0), + #[cfg(not(unix))] + frames: parking_lot::Mutex::new(Vec::new()), + exception: crate::PyAtomicRef::from(None::), + state: core::sync::atomic::AtomicI32::new( + if vm.state.stop_the_world.requested.load(Ordering::Acquire) { + // Match init_threadstate(): new thread-state starts + // suspended while stop-the-world is active. + THREAD_SUSPENDED + } else { + THREAD_DETACHED + }, + ), + stop_requested: core::sync::atomic::AtomicBool::new(false), + thread: std::thread::current(), + qsbr: crate::object::qsbr::QSBR.register(), + }); + registry.insert(thread_id, new_slot.clone()); + drop(registry); + slots.insert(interp_id, new_slot.clone()); + new_slot + }) +} + +/// Make `slot` the current thread slot (and the cached top-frame pointer). +#[cfg(feature = "threading")] +fn set_current_thread_slot(slot: CurrentFrameSlot) { + #[cfg(unix)] + CURRENT_TOP_FRAME_SLOT.with(|c| c.set(&slot.top_frame)); + CURRENT_TOP_IFRAME_SLOT.with(|c| c.set(&slot.top_iframe)); + CURRENT_STOP_REQUESTED.with(|c| c.set(&slot.stop_requested)); + CURRENT_THREAD_SLOT.with(|current| { + *current.borrow_mut() = Some(slot); }); } +/// Whether the current thread slot is ATTACHED. +#[cfg(feature = "threading")] +fn current_slot_is_attached() -> bool { + CURRENT_THREAD_SLOT.with(|slot| { + slot.borrow() + .as_ref() + .is_some_and(|s| s.state.load(Ordering::Acquire) == THREAD_ATTACHED) + }) +} + +/// Attach this thread to `vm`'s interpreter for the duration of a section, +/// detaching whichever interpreter it was attached to (≈ `_PyThreadState_Swap`). +/// +/// A thread must never be ATTACHED to two interpreters at once: stop-the-world +/// treats an ATTACHED slot as "running this interpreter's bytecode" and a +/// DETACHED slot as parkable without cooperation, so running interpreter B's +/// code while B's slot is DETACHED would let a collector conclude B is stopped +/// while this thread keeps mutating the (process-global) object graph. +/// +/// Returns whether the attachment changed, i.e. whether the matching +/// [`end_interpreter_section`] must undo it. +#[cfg(feature = "threading")] +fn begin_interpreter_section(vm: &VirtualMachine) -> bool { + let target = ensure_thread_slot(vm); + let already_current = CURRENT_THREAD_SLOT.with(|slot| { + slot.borrow() + .as_ref() + .is_some_and(|s| Arc::ptr_eq(s, &target)) + }); + if already_current && current_slot_is_attached() { + // Nested section in the same interpreter: already attached. + return false; + } + if !already_current && current_slot_is_attached() { + detach_thread(); + } + set_current_thread_slot(target); + attach_thread(vm); + true +} + +/// Undo [`begin_interpreter_section`]: detach this interpreter and re-attach the +/// enclosing one, if any. Call after the VM has been popped from `VM_STACK`. +#[cfg(feature = "threading")] +fn end_interpreter_section(switched: bool) { + if !switched { + return; + } + if current_slot_is_attached() { + detach_thread(); + } + // The enclosing section, if any, is the VM now on top of the stack. + if let Some(vm_ptr) = VM_STACK.with(|vms| vms.borrow().last().copied()) { + // SAFETY: entries on VM_STACK are valid for their enter/set_current_vm scope. + let vm = unsafe { vm_ptr.as_ref() }; + set_current_thread_slot(ensure_thread_slot(vm)); + attach_thread(vm); + } +} + /// Transition DETACHED → ATTACHED. Blocks if the thread was SUSPENDED by /// a stop-the-world request (like `_PyThreadState_Attach` + `tstate_wait_attach`). #[cfg(feature = "threading")] @@ -598,10 +708,12 @@ fn do_suspend(stw: &super::StopTheWorldState) { #[inline] #[must_use] pub fn stop_requested_for_current_thread() -> bool { - CURRENT_THREAD_SLOT.with(|slot| { - slot.borrow() - .as_ref() - .is_some_and(|s| s.stop_requested.load(Ordering::Relaxed)) + CURRENT_STOP_REQUESTED.with(|cached| { + let flag = cached.get(); + // SAFETY: the pointer is non-null only while `CURRENT_THREAD_SLOT` + // holds the `Arc` that owns the flag; both are cleared + // together in `cleanup_current_thread_frames`. + !flag.is_null() && unsafe { &*flag }.load(Ordering::Relaxed) }) } @@ -692,27 +804,28 @@ pub fn set_current_frame(frame: *const InterpreterFrame) -> *const InterpreterFr // sys._current_frames). #[cfg(feature = "threading")] { - CURRENT_THREAD_SLOT.with(|slot| { - if let Some(s) = slot.borrow().as_ref() { - if !frame.is_null() { - #[cfg(unix)] - { - let frame_obj = unsafe { (*frame).frame_obj() }; - let fo_ptr = match frame_obj { - Some(py) => { - py as *const Py as *const FrameObject - as *mut FrameObject - } - None => core::ptr::null_mut(), - }; - s.top_frame.store(fo_ptr, Ordering::Relaxed); - } - s.top_iframe.store(frame as usize, Ordering::Relaxed); + CURRENT_TOP_IFRAME_SLOT.with(|slot| { + let slot = slot.get(); + if !slot.is_null() { + unsafe { &*slot }.store(frame as usize, Ordering::Relaxed); + } + }); + #[cfg(unix)] + CURRENT_TOP_FRAME_SLOT.with(|slot| { + let slot = slot.get(); + if !slot.is_null() { + let fo_ptr = if frame.is_null() { + core::ptr::null_mut() } else { - #[cfg(unix)] - s.top_frame.store(core::ptr::null_mut(), Ordering::Relaxed); - s.top_iframe.store(0, Ordering::Relaxed); - } + let frame_obj = unsafe { (*frame).frame_obj() }; + // The payload address, which is what the cross-thread + // reader hands to `Py::from_payload_ptr`. The `Py` address + // would be off by the object header. + frame_obj.map_or(core::ptr::null_mut(), |py| { + core::ptr::from_ref::(py).cast_mut() + }) + }; + unsafe { &*slot }.store(fo_ptr, Ordering::Relaxed); } }); } @@ -759,16 +872,21 @@ pub fn get_all_current_exceptions(vm: &VirtualMachine) -> Vec<(u64, Option registry.remove(&thread_id), @@ -789,7 +907,6 @@ pub fn cleanup_current_thread_frames(vm: &VirtualMachine) { None }; - #[cfg(feature = "threading")] if let Some(slot) = &_removed && vm.state.stop_the_world.requested.load(Ordering::Acquire) && thread_id != vm.state.stop_the_world.requester_ident() @@ -799,12 +916,23 @@ pub fn cleanup_current_thread_frames(vm: &VirtualMachine) { // Unblock requester countdown progress. vm.state.stop_the_world.notify_thread_gone(); } - // Clear the cached top-frame pointer before dropping the slot Arc so no - // later `set_current_frame` dereferences freed slot memory. - #[cfg(all(unix, feature = "threading"))] - CURRENT_TOP_FRAME_SLOT.with(|c| c.set(core::ptr::null())); + + // If CURRENT pointed at the cleaned slot, clear it (and top-frame cache). CURRENT_THREAD_SLOT.with(|s| { - *s.borrow_mut() = None; + let clear = match (s.borrow().as_ref(), slot_to_clean.as_ref()) { + (Some(cur), Some(cleaned)) => Arc::ptr_eq(cur, cleaned), + (Some(_), None) => false, + (None, _) => false, + }; + if clear { + *s.borrow_mut() = None; + #[cfg(all(unix, feature = "threading"))] + CURRENT_TOP_FRAME_SLOT.with(|c| c.set(core::ptr::null())); + #[cfg(feature = "threading")] + CURRENT_TOP_IFRAME_SLOT.with(|c| c.set(core::ptr::null())); + #[cfg(feature = "threading")] + CURRENT_STOP_REQUESTED.with(|c| c.set(core::ptr::null())); + } }); } @@ -843,7 +971,7 @@ pub fn reinit_frame_slot_after_fork(vm: &VirtualMachine) { core::ptr::null_mut() } else { match unsafe { (*top_iframe).frame_obj() } { - Some(fo) => fo as *const Py as *const FrameObject as *mut FrameObject, + Some(fo) => core::ptr::from_ref::(fo).cast_mut(), None => core::ptr::null_mut(), } } @@ -865,6 +993,10 @@ pub fn reinit_frame_slot_after_fork(vm: &VirtualMachine) { }); #[cfg(all(unix, feature = "threading"))] CURRENT_TOP_FRAME_SLOT.with(|c| c.set(&new_slot.top_frame)); + #[cfg(feature = "threading")] + CURRENT_TOP_IFRAME_SLOT.with(|c| c.set(&new_slot.top_iframe)); + #[cfg(feature = "threading")] + CURRENT_STOP_REQUESTED.with(|c| c.set(&new_slot.stop_requested)); // Lock is safe: reinit_locks_after_fork() already reset it to unlocked. let mut registry = vm.state.thread_frames.lock(); @@ -873,7 +1005,23 @@ pub fn reinit_frame_slot_after_fork(vm: &VirtualMachine) { drop(registry); CURRENT_THREAD_SLOT.with(|s| { - *s.borrow_mut() = Some(new_slot); + *s.borrow_mut() = Some(new_slot.clone()); + }); + INTERP_THREAD_SLOTS.with(|slots| { + slots.borrow_mut().insert(vm.state.interpreter_id, new_slot); + }); +} + +/// Drop this thread's cached slots for every interpreter except `keep_id`. +/// +/// After `fork()` only the calling thread survives, and the other +/// interpreters' registries are cleared; a cached slot would otherwise stay +/// current for an interpreter that no longer lists it, hiding the thread from +/// that interpreter's stop-the-world. The next enter builds a fresh slot. +#[cfg(feature = "threading")] +pub fn purge_other_interpreter_slots_after_fork(keep_id: i64) { + INTERP_THREAD_SLOTS.with(|slots| { + slots.borrow_mut().retain(|&id, _| id == keep_id); }); } @@ -1023,7 +1171,7 @@ impl VirtualMachine { callable_cache: self.callable_cache.clone(), audit_hooks: RefCell::new(vec![]), pending_tailcall_frame: Cell::new(None), - pending_tailcall_refs: core::cell::UnsafeCell::new(Vec::with_capacity(2)), + pending_tailcall_owner: core::cell::UnsafeCell::new(None), }; ThreadedVirtualMachine { vm } } From c603eb7c9ad763e72cbec7b323e4cb331aee7e81 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:32:29 +0900 Subject: [PATCH 86/94] Implement PEP 688 and rework the buffer protocol around managed exports (#8523) * Implement PEP 688 __buffer__ and __release_buffer__ A Python class could not export a buffer: the slot machinery had no bf_getbuffer or bf_releasebuffer, and every consumer acquired buffers as PyBUF_FULL_RO through a module of PyBUF_* constants. Add both slots. PyBuffer::release now runs a Python __release_buffer__ before the exporter's own release, once per acquisition, which PyBuffer tracks with an `acquired` flag that clones do not inherit. An export made by a Python __buffer__ is held by a _buffer_wrapper payload that counts its exports and drops the returned memoryview with the last one, and the view handed to __release_buffer__ is a _buffer_window that owns no export, so releasing it inside the hook is inert instead of re-entering it. Replace the PyBUF_* constants with a BufferFlags bitflags type whose composite requests are supersets of the simpler ones, so `contains` answers the REQ_* questions, and pass the request to PyBuffer::from_object. Each consumer now asks for what its counterpart asks for: y* arguments for SIMPLE, w* for WRITABLE, BytesIO.write for CONTIG_RO, bytes(), bytearray() and memoryview() for FULL_RO. memoryview checks the request in memory_getbuf, and array.array and mmap.mmap expose __release_buffer__. Test buffer support with PyObject::check_buffer (PyObject_CheckBuffer) instead of attempting an acquisition, so an exception raised by __buffer__ is no longer reported as the object not being bytes-like, and a __buffer__ with side effects runs once. PyBytesInner becomes a y* conversion as a result: bytes and bytearray methods no longer accept iterables of ints, and find, index, count and __contains__ take the arguments parse_args_finds_byte and bytes_contains describe. A view exports its start offset in the descriptor rather than in its window, which fixes a panic when collecting from a negative-stride view. BytesIO.write rechecks closed after acquiring its buffer, which __buffer__ can close in between. Assisted-by: Claude Code:claude-opus-5 * Rework the buffer protocol around managed exports and view offsets Give `PyBuffer` the `_PyManagedBufferObject` shape: one `bf_getbuffer` acquisition is shared by every handle taken from it, cloning takes another share instead of re-acquiring, and the exporter's release runs once when the last share goes away. Remove `retain`, the unsafe `drop_without_release`, the three `impl Drop`s and the `ManuallyDrop` that stood in for this. Add `abort_acquisition` so a failed request does not run `bf_releasebuffer`. Move the view start into `BufferDescriptor::offset`, the `Py_buffer.buf` analogue, and drop the separate `start` fields on `PyMemoryView` and `PyBufferWrapper`. Slicing goes through `SaturatedSlice::adjust_indices_start`, which reproduces `PySlice_AdjustIndices` and keeps the adjusted start. Fix `zip_eq` to take its contiguous fast path only when both last dimensions are contiguous, and make `for_each_segment` and `zip_eq` handle zero-length and zero-dimensional views. Add `BufferDescriptor::projected` so a request without `PyBUF_ND`, `PyBUF_STRIDES` or `PyBUF_FORMAT` receives a correspondingly reduced descriptor, and reject a request without `PyBUF_INDIRECT` against an exporter that has suboffsets. Copy the source first in `memoryview` slice assignment when both sides reach the same root exporter. Hold the export across the resize in `bytearray.extend`, take `y*` in `marshal.loads`, stop probing the buffer protocol in `FsPath`, rewrite `ord` over the concrete string types, fold `array`'s buffer slot into one `slot_as_buffer`, take `w*`/`y*` in `_overlapped`, and thread the new `offset` field through the `_ctypes` descriptors. Assisted-by: Claude * Re-check a memoryview after a conversion and tighten cast and release `pack_single` and `unpack_single` addressed the buffer with a position taken before `__index__` ran, so releasing the view from that conversion read or wrote outside the exporter's storage, panicking when it had also shrunk. Check the released flag again once the conversion is done, as `CHECK_RELEASED_AGAIN` does. Reject a cast to a format that is not a single native format character with an optional `@` in front of it. An empty format reached a division by its item size of zero. get_native_fmtchar Report a second `__release_buffer__` on the same view as a `ValueError` rather than accepting it, and check that the view belongs to the object first; the silent case is a view that exports nothing. wrap_releasebuffer Compare against another memoryview by reading its view where it lies instead of acquiring a buffer from it, so the restricted view handed to `__release_buffer__` compares equal rather than unequal in one direction only. memory_richcompare Name the type in the unraisable an exception from `__release_buffer__` reports, as `releasebuffer_call_python` does, instead of reporting the exporter object. Assisted-by: Claude * Re-check the destination of a memoryview slice assignment Acquiring the source runs `__buffer__`, which can release the destination view, so check the released flag again once the source is in hand and before the structures are compared, as `copy_single` does. Build the sliced destination as a view that counts as no export, the way a `Py_buffer dest = *view` copy does. Holding one kept the exporter unresizable for the length of the assignment, so a source that released the view and then resized the exporter met a `BufferError` instead of the assignment reporting the released view. Assisted-by: Claude * Answer the memoryview contiguity getsets with the layout `f_contiguous` reported the row-major answer for one dimension and False for any more, so a view laid out both ways, such as one of shape (1, 8), reported False while `check_buffer_request` accepted a `F_CONTIGUOUS` request on it. `contiguous` answered row-major order alone. Move the Fortran-order test from `PyMemoryView` to `BufferDescriptor`, next to the row-major one it differs from only in iteration order, and answer all three getsets with it. Assisted-by: Claude --- .cspell.json | 1 + Lib/test/test_buffer.py | 11 - Lib/test/test_collections.py | 1 - Lib/test/test_memoryio.py | 8 - Lib/test/test_memoryview.py | 1 - Lib/test/test_struct.py | 2 - crates/derive-impl/src/pyclass.rs | 9 +- crates/stdlib/src/array.rs | 52 +- crates/stdlib/src/mmap.rs | 2 + crates/stdlib/src/overlapped.rs | 26 +- crates/stdlib/src/ssl.rs | 36 +- crates/vm/src/anystr.rs | 30 +- crates/vm/src/builtins/bytearray.rs | 68 +- crates/vm/src/builtins/bytes.rs | 38 +- crates/vm/src/builtins/descriptor.rs | 37 ++ crates/vm/src/builtins/int.rs | 16 +- crates/vm/src/builtins/memory.rs | 725 +++++++++++++++------ crates/vm/src/builtins/str.rs | 29 +- crates/vm/src/builtins/type.rs | 13 - crates/vm/src/byte.rs | 11 +- crates/vm/src/bytes_inner.rs | 79 ++- crates/vm/src/cformat.rs | 39 +- crates/vm/src/function/buffer.rs | 47 +- crates/vm/src/function/fspath.rs | 12 +- crates/vm/src/function/mod.rs | 4 +- crates/vm/src/protocol/buffer.rs | 411 +++++++++++- crates/vm/src/protocol/mod.rs | 4 +- crates/vm/src/sliceable.rs | 44 ++ crates/vm/src/stdlib/_ctypes/array.rs | 1 + crates/vm/src/stdlib/_ctypes/base.rs | 4 +- crates/vm/src/stdlib/_ctypes/function.rs | 1 + crates/vm/src/stdlib/_ctypes/pointer.rs | 4 +- crates/vm/src/stdlib/_ctypes/simple.rs | 1 + crates/vm/src/stdlib/_ctypes/structure.rs | 1 + crates/vm/src/stdlib/_ctypes/union.rs | 1 + crates/vm/src/stdlib/_imp.rs | 11 +- crates/vm/src/stdlib/_io.rs | 10 +- crates/vm/src/stdlib/_sre.rs | 8 +- crates/vm/src/stdlib/builtins.rs | 40 +- crates/vm/src/stdlib/marshal.rs | 13 +- crates/vm/src/stdlib/winsound.rs | 6 +- crates/vm/src/types/slot.rs | 94 ++- crates/vm/src/types/slot_defs.rs | 64 +- crates/vm/src/vm/context.rs | 2 + extra_tests/snippets/builtin_memoryview.py | 424 ++++++++++++ 45 files changed, 1973 insertions(+), 468 deletions(-) diff --git a/.cspell.json b/.cspell.json index 2889a5184..57f7baab3 100644 --- a/.cspell.json +++ b/.cspell.json @@ -59,6 +59,7 @@ "alnum", "csock", "coro", + "contig", "Crnl", "dedentations", "dedents", diff --git a/Lib/test/test_buffer.py b/Lib/test/test_buffer.py index bc09329e6..19582e757 100644 --- a/Lib/test/test_buffer.py +++ b/Lib/test/test_buffer.py @@ -4471,7 +4471,6 @@ class TestBufferProtocol(unittest.TestCase): class TestPythonBufferProtocol(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON def test_basic(self): class MyBuffer: def __buffer__(self, flags): @@ -4500,7 +4499,6 @@ class TestPythonBufferProtocol(unittest.TestCase): self.assertRaises(TypeError, memoryview, WrongArity()) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_release_buffer(self): class WhatToRelease: def __init__(self): @@ -4523,7 +4521,6 @@ class TestPythonBufferProtocol(unittest.TestCase): self.assertEqual(mv.tobytes(), b"hello") self.assertFalse(wr.held) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_same_buffer_returned(self): class WhatToRelease: def __init__(self): @@ -4549,7 +4546,6 @@ class TestPythonBufferProtocol(unittest.TestCase): self.assertEqual(mv.tobytes(), b"hello") self.assertFalse(wr.held) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_buffer_flags(self): class PossiblyMutable: def __init__(self, data, mutable) -> None: @@ -4589,7 +4585,6 @@ class TestPythonBufferProtocol(unittest.TestCase): mv[0] = ord(b'x') self.assertEqual(mv.tobytes(), b"hello") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_call_builtins(self): ba = bytearray(b"hello") mv = ba.__buffer__(0) @@ -4651,7 +4646,6 @@ class TestPythonBufferProtocol(unittest.TestCase): mv = memoryview(a) self.assertEqual(mv.tobytes(), b"hello") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_inheritance_releasebuffer(self): rb_call_count = 0 class B(bytearray): @@ -4668,7 +4662,6 @@ class TestPythonBufferProtocol(unittest.TestCase): self.assertEqual(rb_call_count, 0) self.assertEqual(rb_call_count, 1) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_inherit_but_return_something_else(self): class A(bytearray): def __buffer__(self, flags): @@ -4708,7 +4701,6 @@ class TestPythonBufferProtocol(unittest.TestCase): with memoryview(c) as mv: self.assertEqual(mv.tobytes(), b"hello") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_release_saves_reference(self): smuggled_buffer = None @@ -4736,7 +4728,6 @@ class TestPythonBufferProtocol(unittest.TestCase): with self.assertRaises(ValueError): smuggled_buffer.tobytes() - @unittest.expectedFailure # TODO: RUSTPYTHON def test_release_saves_reference_no_subclassing(self): ba = bytearray(b"hello") @@ -4757,7 +4748,6 @@ class TestPythonBufferProtocol(unittest.TestCase): c.buffer.release() ba.clear() - @unittest.expectedFailure # TODO: RUSTPYTHON def test_multiple_inheritance_buffer_last(self): class A: def __buffer__(self, flags): @@ -4817,7 +4807,6 @@ class TestPythonBufferProtocol(unittest.TestCase): c.clear() self.assertIs(c.buffer, None) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_release_buffer_with_exception_set(self): class A: def __buffer__(self, flags): diff --git a/Lib/test/test_collections.py b/Lib/test/test_collections.py index b5d3411c7..c1dadc4e2 100644 --- a/Lib/test/test_collections.py +++ b/Lib/test/test_collections.py @@ -1956,7 +1956,6 @@ class TestCollectionABCs(ABCTestCase): # No metaclass conflict class Z(ByteString, Awaitable): pass - @unittest.expectedFailure # TODO: RUSTPYTHON; Need to implement __buffer__ and __release_buffer__ (https://docs.python.org/3.13/reference/datamodel.html#emulating-buffer-types) def test_Buffer(self): for sample in [bytes, bytearray, memoryview]: self.assertIsInstance(sample(b"x"), Buffer) diff --git a/Lib/test/test_memoryio.py b/Lib/test/test_memoryio.py index 1683a71fc..7f57d20f2 100644 --- a/Lib/test/test_memoryio.py +++ b/Lib/test/test_memoryio.py @@ -587,7 +587,6 @@ class PyBytesIOTest(MemoryTestMixin, MemorySeekTestMixin, unittest.TestCase): self.ioclass(initial_bytes=buf) self.assertRaises(TypeError, self.ioclass, buf, foo=None) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: a bytes-like object is required, not 'B' def test_write_concurrent_close(self): class B: def __buffer__(self, flags): @@ -601,7 +600,6 @@ class PyBytesIOTest(MemoryTestMixin, MemorySeekTestMixin, unittest.TestCase): # concurrently mutates (e.g., closes or exports) 'memio'. # See: https://github.com/python/cpython/issues/143378. - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: a bytes-like object is required, not 'B' def test_writelines_concurrent_close(self): class B: def __buffer__(self, flags): @@ -611,7 +609,6 @@ class PyBytesIOTest(MemoryTestMixin, MemorySeekTestMixin, unittest.TestCase): memio = self.ioclass() self.assertRaises(ValueError, memio.writelines, [B()]) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: a bytes-like object is required, not 'B' def test_write_concurrent_export(self): class B: buf = None @@ -622,7 +619,6 @@ class PyBytesIOTest(MemoryTestMixin, MemorySeekTestMixin, unittest.TestCase): memio = self.ioclass() self.assertRaises(BufferError, memio.write, B()) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: a bytes-like object is required, not 'B' def test_writelines_concurrent_export(self): class B: buf = None @@ -633,7 +629,6 @@ class PyBytesIOTest(MemoryTestMixin, MemorySeekTestMixin, unittest.TestCase): memio = self.ioclass() self.assertRaises(BufferError, memio.writelines, [B()]) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: a bytes-like object is required, not 'B' def test_write_mutating_buffer(self): # Test that buffer is exported only once during write(). # See: https://github.com/python/cpython/issues/143602. @@ -930,9 +925,6 @@ class CBytesIOTest(PyBytesIOTest): def test_flags(self): return super().test_flags() - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ValueError not raised by write - def test_write(self): - return super().test_write() class CStringIOTest(PyStringIOTest): ioclass = io.StringIO diff --git a/Lib/test/test_memoryview.py b/Lib/test/test_memoryview.py index 12e3504e4..707540f29 100644 --- a/Lib/test/test_memoryview.py +++ b/Lib/test/test_memoryview.py @@ -797,7 +797,6 @@ class OtherTest(unittest.TestCase): m[0] = MyBool() self.assertEqual(ba[:8], b'\0'*8) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'memoryview' object has no attribute '__buffer__' def test_buffer_reference_loop(self): m = memoryview(b'abc').__buffer__(0) o = MyObject() diff --git a/Lib/test/test_struct.py b/Lib/test/test_struct.py index 31d2e58b1..c76639809 100644 --- a/Lib/test/test_struct.py +++ b/Lib/test/test_struct.py @@ -498,12 +498,10 @@ class StructTest(ComplexesAreIdenticalMixin, unittest.TestCase): with self.assertRaises((IndexError, OverflowError)): pack_into(writable_buf, -2**1000, test_string) - @unittest.expectedFailure # TODO: RUSTPYTHON; BufferError: non-contiguous buffer is not a bytes-like object def test_pack_into(self): s = struct.Struct('21s') self._test_pack_into(s.pack_into) - @unittest.expectedFailure # TODO: RUSTPYTHON; BufferError: non-contiguous buffer is not a bytes-like object def test_pack_into_fn(self): pack_into = lambda *args: struct.pack_into('21s', *args) self._test_pack_into(pack_into) diff --git a/crates/derive-impl/src/pyclass.rs b/crates/derive-impl/src/pyclass.rs index 809d3164b..94bb445fe 100644 --- a/crates/derive-impl/src/pyclass.rs +++ b/crates/derive-impl/src/pyclass.rs @@ -1162,13 +1162,16 @@ where let slot_ident = Ident::new(&slot_ident.to_string().to_lowercase(), slot_ident.span()); let slot_name = slot_ident.to_string(); let tokens = { - const NON_ATOMIC_SLOTS: &[&str] = &["as_buffer"]; const POINTER_SLOTS: &[&str] = &["as_sequence", "as_mapping"]; const STATIC_GEN_SLOTS: &[&str] = &["as_number"]; - if NON_ATOMIC_SLOTS.contains(&slot_name.as_str()) { + if slot_name == "as_buffer" { + // bf_releasebuffer is not a separate function in RustPython; the + // exporter's BufferMethods already release. Only its presence is + // observable, and AsBuffer declares that. quote_spanned! { span => - slots.#slot_ident = Some(Self::#ident as _); + slots.#slot_ident.store(Some(Self::#ident as _)); + slots.has_release_buffer.store(Self::RELEASE_BUFFER); } } else if POINTER_SLOTS.contains(&slot_name.as_str()) { quote_spanned! { span => diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index 094e69066..68e7aab25 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -27,8 +27,8 @@ pub mod array { ArgBytesLike, ArgIntoFloat, ArgIterable, KwArgs, OptionalArg, PyComparisonValue, }, protocol::{ - BufferDescriptor, BufferMethods, BufferResizeGuard, PyBuffer, PyIterReturn, - PyMappingMethods, PySequenceMethods, + BufferDescriptor, BufferFlags, BufferMethods, BufferResizeGuard, PyBuffer, + PyIterReturn, PyMappingMethods, PySequenceMethods, }, sequence::{OptionalRangeArgs, SequenceExt, SequenceMutExt}, sliceable::{ @@ -732,12 +732,12 @@ pub mod array { } } else if init.downcastable::() || init.downcastable::() { init.try_bytes_like(vm, |x| array.frombytes(x))?; - } else if let Ok(iter) = ArgIterable::try_from_object(vm, init.clone()) { + } else { + // Everything else is taken item by item, buffer or not. + let iter = ArgIterable::try_from_object(vm, init)?; for obj in iter.iter(vm)? { array.push(obj?, vm)?; } - } else { - init.try_bytes_like(vm, |x| array.frombytes(x))?; } } @@ -1291,20 +1291,42 @@ pub mod array { } } + impl PyArray { + fn buffer_desc(&self) -> BufferDescriptor { + let array = self.read(); + BufferDescriptor::format( + array.len() * array.itemsize(), + false, + array.itemsize(), + array.typecode_str().into(), + ) + } + } + impl AsBuffer for PyArray { + const RELEASE_BUFFER: bool = true; + + // array_buffer_getbuf, which reports the type code only when the request + // asked for a format. + fn slot_as_buffer( + zelf: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { + let zelf = zelf + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("unexpected payload for as_buffer"))?; + let desc = zelf.buffer_desc().projected(flags); + flags.check_writable(desc.readonly, "Object is not writable.", vm)?; + Ok(PyBuffer::new(zelf.to_owned().into(), desc, &BUFFER_METHODS)) + } + fn as_buffer(zelf: &Py, _vm: &VirtualMachine) -> PyResult { - let array = zelf.read(); - let buf = PyBuffer::new( + Ok(PyBuffer::new( zelf.to_owned().into(), - BufferDescriptor::format( - array.len() * array.itemsize(), - false, - array.itemsize(), - array.typecode_str().into(), - ), + zelf.buffer_desc(), &BUFFER_METHODS, - ); - Ok(buf) + )) } } diff --git a/crates/stdlib/src/mmap.rs b/crates/stdlib/src/mmap.rs index 91d4058a7..b5dec9765 100644 --- a/crates/stdlib/src/mmap.rs +++ b/crates/stdlib/src/mmap.rs @@ -611,6 +611,8 @@ mod mmap { }; impl AsBuffer for PyMmap { + const RELEASE_BUFFER: bool = true; + fn as_buffer(zelf: &Py, _vm: &VirtualMachine) -> PyResult { let readonly = matches!(zelf.access, AccessMode::Read); let buf = PyBuffer::new( diff --git a/crates/stdlib/src/overlapped.rs b/crates/stdlib/src/overlapped.rs index 86ac24e3a..6cdd00146 100644 --- a/crates/stdlib/src/overlapped.rs +++ b/crates/stdlib/src/overlapped.rs @@ -12,7 +12,7 @@ mod _overlapped { builtins::{PyBaseExceptionRef, PyBytesRef, PyModule, PyStrRef, PyTupleRef, PyType}, common::lock::PyMutex, convert::{ToPyException, ToPyObject}, - function::OptionalArg, + function::{ArgBytesLike, ArgMemoryBuffer, OptionalArg}, object::{Traverse, TraverseFn}, protocol::PyBuffer, types::{Constructor, Destructor}, @@ -428,12 +428,14 @@ mod _overlapped { fn ReadFileInto( zelf: &Py, handle: isize, - buf: PyBuffer, + // w*, as _overlapped.Overlapped.ReadFileInto takes + buf: ArgMemoryBuffer, vm: &VirtualMachine, ) -> PyResult { use host_winapi::{ ERROR_BROKEN_PIPE, ERROR_IO_PENDING, ERROR_MORE_DATA, ERROR_SUCCESS, }; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { @@ -530,13 +532,15 @@ mod _overlapped { fn WSARecvInto( zelf: &Py, handle: isize, - buf: PyBuffer, + // w*, as _overlapped.Overlapped.WSARecvInto takes + buf: ArgMemoryBuffer, flags: u32, vm: &VirtualMachine, ) -> PyResult { use host_winapi::{ ERROR_BROKEN_PIPE, ERROR_IO_PENDING, ERROR_MORE_DATA, ERROR_SUCCESS, }; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { @@ -583,10 +587,12 @@ mod _overlapped { fn WriteFile( zelf: &Py, handle: isize, - buf: PyBuffer, + // y*, as _overlapped.Overlapped.WriteFile takes + buf: ArgBytesLike, vm: &VirtualMachine, ) -> PyResult { use host_winapi::{ERROR_IO_PENDING, ERROR_SUCCESS}; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { @@ -629,11 +635,13 @@ mod _overlapped { fn WSASend( zelf: &Py, handle: isize, - buf: PyBuffer, + // y*, as _overlapped.Overlapped.WSASend takes + buf: ArgBytesLike, flags: u32, vm: &VirtualMachine, ) -> PyResult { use host_winapi::{ERROR_IO_PENDING, ERROR_SUCCESS}; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { @@ -870,12 +878,14 @@ mod _overlapped { fn WSASendTo( zelf: &Py, handle: isize, - buf: PyBuffer, + // y*, as _overlapped.Overlapped.WSASendTo takes + buf: ArgBytesLike, flags: u32, address: PyTupleRef, vm: &VirtualMachine, ) -> PyResult { use host_winapi::{ERROR_IO_PENDING, ERROR_SUCCESS}; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { @@ -1001,7 +1011,8 @@ mod _overlapped { fn WSARecvFromInto( zelf: &Py, handle: isize, - buf: PyBuffer, + // w*, as _overlapped.Overlapped.WSARecvFromInto takes + buf: ArgMemoryBuffer, size: u32, flags: OptionalArg, vm: &VirtualMachine, @@ -1009,6 +1020,7 @@ mod _overlapped { use host_winapi::{ ERROR_BROKEN_PIPE, ERROR_IO_PENDING, ERROR_MORE_DATA, ERROR_SUCCESS, }; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 18d171a85..b942e27fc 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -1158,19 +1158,19 @@ mod _ssl { let pwd_result = callable.call((), vm)?; // Convert callable result to string - let password_from_callable = if let Ok(pwd_str) = - PyUtf8StrRef::try_from_object(vm, pwd_result.clone()) - { - pwd_str.as_str().to_owned() - } else if let Ok(pwd_bytes_like) = ArgBytesLike::try_from_object(vm, pwd_result) { - String::from_utf8(pwd_bytes_like.borrow_buf().to_vec()).map_err(|_| { - vm.new_type_error("password callback returned invalid UTF-8 bytes") - })? - } else { - return Err( - vm.new_type_error("password callback must return a string or bytes") - ); - }; + let password_from_callable = + if let Ok(pwd_str) = PyUtf8StrRef::try_from_object(vm, pwd_result.clone()) { + pwd_str.as_str().to_owned() + } else if pwd_result.check_buffer() { + let pwd_bytes_like = ArgBytesLike::try_from_object(vm, pwd_result)?; + String::from_utf8(pwd_bytes_like.borrow_buf().to_vec()).map_err(|_| { + vm.new_type_error("password callback returned invalid UTF-8 bytes") + })? + } else { + return Err( + vm.new_type_error("password callback must return a string or bytes") + ); + }; // Validate callable password length if password_from_callable.len() > PEM_BUFSIZE { @@ -1808,7 +1808,8 @@ mod _ssl { // Validate filepath is str or bytes let path_str = if let Ok(s) = PyUtf8StrRef::try_from_object(vm, filepath.clone()) { s.as_str().to_owned() - } else if let Ok(b) = ArgBytesLike::try_from_object(vm, filepath) { + } else if filepath.check_buffer() { + let b = ArgBytesLike::try_from_object(vm, filepath)?; String::from_utf8(b.borrow_buf().to_vec()) .map_err(|_| vm.new_value_error("Invalid path encoding"))? } else { @@ -1863,7 +1864,8 @@ mod _ssl { // Validate name is str or bytes let curve_name = if let Ok(s) = PyUtf8StrRef::try_from_object(vm, name.clone()) { s.as_str().to_owned() - } else if let Ok(b) = ArgBytesLike::try_from_object(vm, name) { + } else if name.check_buffer() { + let b = ArgBytesLike::try_from_object(vm, name)?; String::from_utf8(b.borrow_buf().to_vec()) .map_err(|_| vm.new_value_error("Invalid curve name encoding"))? } else { @@ -2106,8 +2108,8 @@ mod _ssl { Ok((Some(pwd_str.as_str().to_owned()), None)) } // Try bytes-like - else if let Ok(pwd_bytes_like) = ArgBytesLike::try_from_object(vm, p.clone()) - { + else if p.check_buffer() { + let pwd_bytes_like = ArgBytesLike::try_from_object(vm, p.clone())?; let pwd = String::from_utf8(pwd_bytes_like.borrow_buf().to_vec()) .map_err(|_| vm.new_type_error("password bytes must be valid UTF-8"))?; Ok((Some(pwd), None)) diff --git a/crates/vm/src/anystr.rs b/crates/vm/src/anystr.rs index 45a69dbfe..0f187f6d4 100644 --- a/crates/vm/src/anystr.rs +++ b/crates/vm/src/anystr.rs @@ -4,7 +4,7 @@ use num_traits::{cast::ToPrimitive, sign::Signed}; use rustpython_unicode::case; use crate::{ - Py, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, + AsObject, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, builtins::{PyIntRef, PyTuple}, convert::TryFromBorrowedObject, function::OptionalOption, @@ -485,19 +485,25 @@ where F: Fn(T) -> PyResult, M: Fn(&PyObject) -> String, { - if let Ok(single) = obj.try_to_value::(vm) { - (predicate)(single) - } else { - let tuple: &Py = obj - .try_to_value(vm) - .map_err(|_| vm.new_type_error((message)(obj)))?; - - for obj in tuple { - if single_or_tuple_any(obj, predicate, message, vm)? { + // _Py_bytes_tailmatch: a tuple is taken apart before anything is converted, and + // each item is converted on its own terms, so a tuple of tuples is not an affix. + if let Some(tuple) = obj.downcast_ref::() { + for item in tuple { + if (predicate)(item.try_to_value::(vm)?)? { return Ok(true); } } - - Ok(false) + return Ok(false); } + + // Only the argument simply being the wrong kind of object is reported as such; + // whatever the conversion itself raised belongs to the caller. + let single = obj.try_to_value::(vm).map_err(|exc| { + if exc.fast_isinstance(vm.ctx.exceptions.type_error) { + vm.new_type_error((message)(obj)) + } else { + exc + } + })?; + (predicate)(single) } diff --git a/crates/vm/src/builtins/bytearray.rs b/crates/vm/src/builtins/bytearray.rs index 793b269d1..9be51a370 100644 --- a/crates/vm/src/builtins/bytearray.rs +++ b/crates/vm/src/builtins/bytearray.rs @@ -1,7 +1,7 @@ //! Implementation of the python bytearray object. use super::{ - PositionIterInternal, PyBytes, PyDictRef, PyGenericAlias, PyIntRef, PyStrRef, PyTuple, - PyTupleRef, PyType, PyTypeRef, iter::builtins_iter, + PositionIterInternal, PyBytes, PyDictRef, PyGenericAlias, PyStrRef, PyTuple, PyTupleRef, + PyType, PyTypeRef, iter::builtins_iter, }; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, @@ -11,7 +11,8 @@ use crate::{ byte::{bytes_from_object, value_from_object}, bytes_inner::{ ByteInnerFindOptions, ByteInnerHexOptions, ByteInnerNewOptions, ByteInnerPaddingOptions, - ByteInnerSplitOptions, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, bytes_decode, + ByteInnerSplitOptions, ByteInnerSub, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, + bytes_decode, }, class::PyClassImpl, common::{ @@ -23,10 +24,10 @@ use crate::{ }, convert::{ToPyObject, ToPyResult}, function::{ - ArgBytesLike, ArgIterable, ArgSize, Either, OptionalArg, OptionalOption, PyComparisonValue, + ArgBytesLike, ArgIterable, ArgSize, OptionalArg, OptionalOption, PyComparisonValue, }, protocol::{ - BufferDescriptor, BufferMethods, BufferResizeGuard, PyBuffer, PyIterReturn, + BufferDescriptor, BufferFlags, BufferMethods, BufferResizeGuard, PyBuffer, PyIterReturn, PyMappingMethods, PyNumberMethods, PySequenceMethods, }, sliceable::{SequenceIndex, SliceableSequenceMutOp, SliceableSequenceOp}, @@ -228,11 +229,8 @@ impl PyByteArray { self.inner().add(&other.borrow_buf()).into() } - fn __contains__( - &self, - needle: Either, - vm: &VirtualMachine, - ) -> PyResult { + fn __contains__(&self, needle: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let needle = ByteInnerSub::from_contains_arg(needle, vm)?; self.inner().contains(needle, vm) } @@ -613,12 +611,34 @@ impl Py { #[pymethod] fn extend(&self, object: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { if self.is(&object) { - PyByteArray::irepeat(self, 2, vm) - } else { - let items = bytes_from_object(vm, &object)?; - self.try_resizable(vm)?.elements.extend(items); - Ok(()) + return PyByteArray::irepeat(self, 2, vm); } + // bytearray_setslice keeps the export alive across the resize, so a value + // looking at this bytearray is what stops it from growing. + let buffer = object + .check_buffer() + .then(|| { + PyBuffer::from_object(vm, &object, BufferFlags::SIMPLE).map_err(|_| { + // What an exporter refuses to hand out leaves the value simply + // not usable here, whatever the exporter's own complaint was. + vm.new_type_error(format!( + "can't set bytearray slice from {}", + object.class().name() + )) + }) + }) + .transpose()?; + let items = match &buffer { + Some(buffer) => buffer + .as_contiguous() + .ok_or_else(|| { + vm.new_buffer_error("non-contiguous buffer is not a bytes-like object") + })? + .to_vec(), + None => bytes_from_object(vm, &object)?, + }; + self.try_resizable(vm)?.elements.extend(items); + Ok(()) } #[pymethod] @@ -731,6 +751,20 @@ static BUFFER_METHODS: BufferMethods = BufferMethods { }; impl AsBuffer for PyByteArray { + const RELEASE_BUFFER: bool = true; + + fn slot_as_buffer( + zelf: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { + let zelf = zelf + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("unexpected payload for as_buffer"))?; + flags.fill_info_check(false, vm)?; + Self::as_buffer(zelf, vm) + } + fn as_buffer(zelf: &Py, _vm: &VirtualMachine) -> PyResult { Ok(PyBuffer::new( zelf.to_owned().into(), @@ -801,9 +835,7 @@ impl AsSequence for PyByteArray { } }), contains: atomic_func!(|seq, other, vm| { - let other = - >::try_from_object(vm, other.to_owned())?; - PyByteArray::sequence_downcast(seq).__contains__(other, vm) + PyByteArray::sequence_downcast(seq).__contains__(other.to_owned(), vm) }), inplace_concat: atomic_func!(|seq, other, vm| { let other = ArgBytesLike::try_from_object(vm, other.to_owned())?; diff --git a/crates/vm/src/builtins/bytes.rs b/crates/vm/src/builtins/bytes.rs index bb514b84c..d62b873bc 100644 --- a/crates/vm/src/builtins/bytes.rs +++ b/crates/vm/src/builtins/bytes.rs @@ -1,27 +1,28 @@ use super::{ - PositionIterInternal, PyDictRef, PyGenericAlias, PyIntRef, PyStrRef, PyTuple, PyTupleRef, - PyType, PyTypeRef, iter::builtins_iter, + PositionIterInternal, PyDictRef, PyGenericAlias, PyStrRef, PyTuple, PyTupleRef, PyType, + PyTypeRef, iter::builtins_iter, }; use crate::common::lock::LazyLock; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, - TryFromBorrowedObject, TryFromObject, VirtualMachine, + TryFromBorrowedObject, VirtualMachine, anystr::{self, AnyStr}, atomic_func, bytes_inner::{ ByteInnerFindOptions, ByteInnerHexOptions, ByteInnerNewOptions, ByteInnerPaddingOptions, - ByteInnerSplitOptions, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, bytes_decode, + ByteInnerSplitOptions, ByteInnerSub, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, + bytes_decode, }, class::PyClassImpl, common::{hash::PyHash, lock::PyMutex}, convert::{ToPyObject, ToPyResult}, function::{ - ArgBytesLike, ArgIndex, ArgIterable, Either, FuncArgs, OptionalArg, OptionalOption, + ArgBytesLike, ArgIndex, ArgIterable, FuncArgs, OptionalArg, OptionalOption, PyComparisonValue, }, protocol::{ - BufferDescriptor, BufferMethods, PyBuffer, PyIterReturn, PyMappingMethods, PyNumberMethods, - PySequenceMethods, + BufferDescriptor, BufferFlags, BufferMethods, PyBuffer, PyIterReturn, PyMappingMethods, + PyNumberMethods, PySequenceMethods, }, sliceable::{SequenceIndex, SliceableSequenceOp}, types::{ @@ -246,11 +247,8 @@ impl PyBytes { self.inner.add(&other.borrow_buf()) } - fn __contains__( - &self, - needle: Either, - vm: &VirtualMachine, - ) -> PyResult { + fn __contains__(&self, needle: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let needle = ByteInnerSub::from_contains_arg(needle, vm)?; self.inner.contains(needle, vm) } @@ -627,6 +625,18 @@ static BUFFER_METHODS: BufferMethods = BufferMethods { }; impl AsBuffer for PyBytes { + fn slot_as_buffer( + zelf: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { + let zelf = zelf + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("unexpected payload for as_buffer"))?; + flags.fill_info_check(true, vm)?; + Self::as_buffer(zelf, vm) + } + fn as_buffer(zelf: &Py, _vm: &VirtualMachine) -> PyResult { let buf = PyBuffer::new( zelf.to_owned().into(), @@ -673,9 +683,7 @@ impl AsSequence for PyBytes { .map(|x| vm.ctx.new_bytes(vec![x]).into()) }), contains: atomic_func!(|seq, other, vm| { - let other = - >::try_from_object(vm, other.to_owned())?; - PyBytes::sequence_downcast(seq).__contains__(other, vm) + PyBytes::sequence_downcast(seq).__contains__(other.to_owned(), vm) }), ..PySequenceMethods::NOT_IMPLEMENTED }); diff --git a/crates/vm/src/builtins/descriptor.rs b/crates/vm/src/builtins/descriptor.rs index 5c0662e9f..50bdc841a 100644 --- a/crates/vm/src/builtins/descriptor.rs +++ b/crates/vm/src/builtins/descriptor.rs @@ -542,6 +542,10 @@ pub enum SlotFunc { NumBinaryRight(PyNumberBinaryFunc), // __radd__, __rsub__, etc. (swapped args) NumTernary(PyNumberTernaryFunc), // __pow__ NumTernaryRight(PyNumberTernaryFunc), // __rpow__ (swapped first two args) + + // Buffer protocol + GetBuffer(crate::types::AsBufferFunc), // __buffer__ + ReleaseBuffer, // __release_buffer__ } impl core::fmt::Debug for SlotFunc { @@ -582,6 +586,8 @@ impl core::fmt::Debug for SlotFunc { Self::NumBinaryRight(_) => write!(f, "SlotFunc::NumBinaryRight(...)"), Self::NumTernary(_) => write!(f, "SlotFunc::NumTernary(...)"), Self::NumTernaryRight(_) => write!(f, "SlotFunc::NumTernaryRight(...)"), + Self::GetBuffer(_) => write!(f, "SlotFunc::GetBuffer(...)"), + Self::ReleaseBuffer => write!(f, "SlotFunc::ReleaseBuffer"), } } } @@ -758,10 +764,41 @@ impl SlotFunc { let z = z.unwrap_or_else(|| vm.ctx.none()); func(&y, &obj, &z, vm) // Swapped: y ** obj % z } + // Buffer protocol + Self::GetBuffer(func) => { + let (flags_obj,): (PyObjectRef,) = args.bind(vm)?; + let buffer = func(&obj, parse_buffer_flags(flags_obj, vm)?, vm)?; + crate::builtins::PyMemoryView::from_buffer(buffer, vm) + .map(|mv| mv.into_pyobject(vm)) + } + Self::ReleaseBuffer => { + let (mv_obj,): (PyObjectRef,) = args.bind(vm)?; + let mv = mv_obj + .downcast::() + .map_err(|_| vm.new_type_error("expected a memoryview object"))?; + crate::builtins::memory::release_buffer_from_python(&obj, mv, vm)?; + Ok(vm.ctx.none()) + } } } } +/// Parse the `flags` argument of `__buffer__`. wrap_buffer +fn parse_buffer_flags( + arg: PyObjectRef, + vm: &VirtualMachine, +) -> PyResult { + use num_traits::ToPrimitive; + let idx = arg.try_index(vm)?; + let flags = idx + .as_bigint() + .to_isize() + .ok_or_else(|| vm.new_overflow_error("cannot fit 'int' into an index-sized integer"))?; + let flags = + i32::try_from(flags).map_err(|_| vm.new_overflow_error("buffer flags out of range"))?; + Ok(crate::protocol::BufferFlags::from_bits_retain(flags as u32)) +} + /// wrapper_descriptor: wraps a slot function as a Python method // = PyWrapperDescrObject #[pyclass(name = "wrapper_descriptor", module = false)] diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index 134617ab7..60463ed0d 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -3,7 +3,7 @@ use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, TryFromBorrowedObject, VirtualMachine, builtins::PyUtf8StrRef, - bytes_inner::PyBytesInner, + byte::bytes_from_object, class::PyClassImpl, common::{ format::FormatSpec, @@ -572,13 +572,13 @@ impl PyInt { vm: &VirtualMachine, ) -> PyResult> { let signed = args.signed.map_or(false, Into::into); + // PyObject_Bytes, so an iterable of ints is as good as a buffer + let bytes = bytes_from_object(vm, &args.bytes)?; let value = match (args.byteorder, signed) { - (ArgByteOrder::Big, true) => BigInt::from_signed_bytes_be(args.bytes.as_bytes()), - (ArgByteOrder::Big, false) => BigInt::from_bytes_be(Sign::Plus, args.bytes.as_bytes()), - (ArgByteOrder::Little, true) => BigInt::from_signed_bytes_le(args.bytes.as_bytes()), - (ArgByteOrder::Little, false) => { - BigInt::from_bytes_le(Sign::Plus, args.bytes.as_bytes()) - } + (ArgByteOrder::Big, true) => BigInt::from_signed_bytes_be(&bytes), + (ArgByteOrder::Big, false) => BigInt::from_bytes_be(Sign::Plus, &bytes), + (ArgByteOrder::Little, true) => BigInt::from_signed_bytes_le(&bytes), + (ArgByteOrder::Little, false) => BigInt::from_bytes_le(Sign::Plus, &bytes), }; Self::with_value(cls, value, vm) } @@ -802,7 +802,7 @@ pub(crate) struct IntOptions { #[derive(FromArgs)] struct IntFromByteArgs { - bytes: PyBytesInner, + bytes: PyObjectRef, #[pyarg(any, default = ArgByteOrder::Big)] byteorder: ArgByteOrder, #[pyarg(named, optional)] diff --git a/crates/vm/src/builtins/memory.rs b/crates/vm/src/builtins/memory.rs index 9f8312a07..31f997157 100644 --- a/crates/vm/src/builtins/memory.rs +++ b/crates/vm/src/builtins/memory.rs @@ -8,7 +8,7 @@ use crate::{ TryFromBorrowedObject, TryFromObject, VirtualMachine, atomic_func, buffer::FormatSpec, bytes_inner::{ByteInnerHexOptions, bytes_to_hex}, - class::PyClassImpl, + class::{PyClassImpl, StaticType}, common::{ borrow::{BorrowedValue, BorrowedValueMut}, hash::PyHash, @@ -16,9 +16,9 @@ use crate::{ }, convert::ToPyObject, function::Either, - function::{FuncArgs, OptionalArg, PyComparisonValue}, + function::{ArgIndex, FuncArgs, OptionalArg, PyComparisonValue}, protocol::{ - BufferDescriptor, BufferMethods, PyBuffer, PyIterReturn, PyMappingMethods, + BufferDescriptor, BufferFlags, BufferMethods, PyBuffer, PyIterReturn, PyMappingMethods, PySequenceMethods, VecBuffer, }, sliceable::SequenceIndexOp, @@ -27,7 +27,7 @@ use crate::{ PyComparisonOp, Representable, SelfIter, }, }; -use core::{cmp::Ordering, fmt::Debug, mem::ManuallyDrop, ops::Range}; +use core::{cmp::Ordering, fmt::Debug, ops::Range}; use crossbeam_utils::atomic::AtomicCell; use itertools::Itertools; use rustpython_common::lock::PyMutex; @@ -37,18 +37,24 @@ pub struct PyMemoryViewNewArgs { object: PyObjectRef, } +#[derive(FromArgs)] +struct PyMemoryViewFromFlagsArgs { + object: PyObjectRef, + flags: ArgIndex, +} + #[pyclass(module = false, name = "memoryview")] #[derive(Debug)] pub struct PyMemoryView { - // avoid double release when memoryview had released the buffer before drop - buffer: ManuallyDrop, + /// One share of the acquisition this view is looking at, given up when the + /// view is released or dropped. + buffer: PyBuffer, // the released memoryview does not mean the buffer is destroyed // because the possible another memoryview is viewing from it released: AtomicCell, - // start does NOT mean the bytes before start will not be visited, - // it means the point we starting to get the absolute position via - // the needle - start: usize, + /// Forbids handing out anything that outlives this view, for the window + /// passed to `__release_buffer__`. + restricted: AtomicCell, format_spec: FormatSpec, // memoryview's options could be different from buffer's options desc: BufferDescriptor, @@ -71,13 +77,54 @@ impl PyMemoryView { FormatSpec::parse(format.as_bytes(), vm) } + /// The single native format character a cast is allowed to name, with an + /// optional `@` in front of it. get_native_fmtchar + fn native_fmtchar(format: &str) -> Option { + let format = format.strip_prefix('@').unwrap_or(format); + let [c] = *format.as_bytes() else { + return None; + }; + matches!( + c, + b'c' | b'b' + | b'B' + | b'h' + | b'H' + | b'i' + | b'I' + | b'l' + | b'L' + | b'q' + | b'Q' + | b'n' + | b'N' + | b'f' + | b'd' + | b'e' + | b'?' + | b'P' + ) + .then_some(c) + } + /// this should be the main entrance to create the memoryview /// to avoid the chained memoryview pub fn from_object(obj: &PyObject, vm: &VirtualMachine) -> PyResult { + Self::from_object_with_flags(obj, BufferFlags::FULL_RO, vm) + } + + // PyMemoryView_FromObjectAndFlags + pub fn from_object_with_flags( + obj: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { if let Some(other) = obj.downcast_ref::() { + other.try_not_released(vm)?; + other.try_not_restricted(vm)?; Ok(other.new_view()) } else { - let buffer = PyBuffer::try_from_borrowed_object(vm, obj)?; + let buffer = PyBuffer::from_object(vm, obj, flags)?; Self::from_buffer(buffer, vm) } } @@ -93,9 +140,9 @@ impl PyMemoryView { let desc = buffer.desc.clone(); Ok(Self { - buffer: ManuallyDrop::new(buffer), + buffer, released: AtomicCell::new(false), - start: 0, + restricted: AtomicCell::new(false), format_spec, desc, hash: OnceCell::new(), @@ -120,16 +167,29 @@ impl PyMemoryView { /// this should be the only way to create a memoryview from another memoryview. #[must_use] pub fn new_view(&self) -> Self { - let zelf = Self { + Self { buffer: self.buffer.clone(), released: AtomicCell::new(false), - start: self.start, + restricted: AtomicCell::new(false), format_spec: self.format_spec.clone(), desc: self.desc.clone(), hash: OnceCell::new(), - }; - zelf.buffer.retain(); - zelf + } + } + + /// A view for a temporary that never reaches Python. It counts as no export, + /// so the exporter stays exactly as resizable as it already was, the way a + /// `Py_buffer dest = *view` copy does. + #[must_use] + fn borrowed_view(&self) -> Self { + Self { + buffer: self.buffer.detached(), + released: AtomicCell::new(false), + restricted: AtomicCell::new(false), + format_spec: self.format_spec.clone(), + desc: self.desc.clone(), + hash: OnceCell::new(), + } } fn try_not_released(&self, vm: &VirtualMachine) -> PyResult<()> { @@ -140,22 +200,83 @@ impl PyMemoryView { } } + fn try_not_restricted(&self, vm: &VirtualMachine) -> PyResult<()> { + if self.restricted.load() { + Err(vm.new_value_error("cannot create new view on restricted memoryview")) + } else { + Ok(()) + } + } + + fn try_usable(&self, vm: &VirtualMachine) -> PyResult<()> { + self.try_not_released(vm)?; + self.try_not_restricted(vm) + } + + /// Reject a request this view cannot serve. memory_getbuf + fn check_buffer_request(&self, flags: BufferFlags, vm: &VirtualMachine) -> PyResult<()> { + let c_contiguous = self.desc.is_contiguous(); + flags.check_writable( + self.desc.readonly, + "memoryview: underlying buffer is not writable", + vm, + )?; + if flags.contains(BufferFlags::C_CONTIGUOUS) && !c_contiguous { + return Err(vm.new_buffer_error("memoryview: underlying buffer is not C-contiguous")); + } + if flags.contains(BufferFlags::F_CONTIGUOUS) && !self.desc.is_fortran_contiguous() { + return Err( + vm.new_buffer_error("memoryview: underlying buffer is not Fortran contiguous") + ); + } + if flags.contains(BufferFlags::ANY_CONTIGUOUS) + && !c_contiguous + && !self.desc.is_fortran_contiguous() + { + return Err(vm.new_buffer_error("memoryview: underlying buffer is not contiguous")); + } + // No exporter here produces a suboffset, so this is a guard rather than a + // reachable rejection. + if !flags.contains(BufferFlags::INDIRECT) && self.desc.has_suboffsets() { + return Err(vm.new_buffer_error("memoryview: underlying buffer requires suboffsets")); + } + if !flags.contains(BufferFlags::STRIDES) && !c_contiguous { + return Err(vm.new_buffer_error("memoryview: underlying buffer is not C-contiguous")); + } + if !flags.contains(BufferFlags::ND) && flags.intersects(BufferFlags::FORMAT) { + return Err(vm.new_buffer_error( + "memoryview: cannot cast to unsigned bytes if the format flag is present", + )); + } + Ok(()) + } + + /// The descriptor this view exports for `flags`, or an error if it cannot + /// serve the request. memory_getbuf + fn requested_desc( + &self, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { + self.check_buffer_request(flags, vm)?; + Ok(self.desc.projected(flags)) + } + fn getitem_by_idx(&self, i: isize, vm: &VirtualMachine) -> PyResult { if self.desc.ndim() != 1 { return Err( vm.new_not_implemented_error("multi-dimensional sub-views are not implemented") ); } - let (shape, stride, suboffset) = self.desc.dim_desc[0]; + let (shape, _, _) = self.desc.dim_desc[0]; let index = i .wrapped_at(shape) .ok_or_else(|| vm.new_index_error("index out of range"))?; - let index = index as isize * stride + suboffset; - let pos = (index + self.start as isize) as usize; - self.unpack_single(pos, vm) + self.unpack_single(self.desc.fast_position(&[index]) as usize, vm) } fn getitem_by_slice(&self, slice: &PySlice, vm: &VirtualMachine) -> PyResult { + self.try_not_restricted(vm)?; let mut other = self.new_view(); other.init_slice(slice, 0, vm)?; other.init_len(); @@ -166,20 +287,22 @@ impl PyMemoryView { fn getitem_by_multi_idx(&self, indexes: &[isize], vm: &VirtualMachine) -> PyResult { let pos = self.pos_from_multi_index(indexes, vm)?; let bytes = self.buffer.obj_bytes(); - format_unpack(&self.format_spec, &bytes[pos..pos + self.desc.itemsize], vm) + format_unpack( + &self.format_spec, + &bytes[pos..pos + self.format_spec.size()], + vm, + ) } fn setitem_by_idx(&self, i: isize, value: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { if self.desc.ndim() != 1 { return Err(vm.new_not_implemented_error("sub-views are not implemented")); } - let (shape, stride, suboffset) = self.desc.dim_desc[0]; + let (shape, _, _) = self.desc.dim_desc[0]; let index = i .wrapped_at(shape) .ok_or_else(|| vm.new_index_error("index out of range"))?; - let index = index as isize * stride + suboffset; - let pos = (index + self.start as isize) as usize; - self.pack_single(pos, value, vm) + self.pack_single(self.desc.fast_position(&[index]) as usize, value, vm) } fn setitem_by_multi_idx( @@ -193,7 +316,9 @@ impl PyMemoryView { } fn pack_single(&self, pos: usize, value: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - let mut bytes = self.buffer.obj_bytes_mut(); + // The value is converted before the destination is borrowed, because the + // conversion runs `__index__` or `__float__`, which can read or write the + // same buffer. // TODO: Optimize let data = self.format_spec.pack(vec![value], vm).map_err(|_| { vm.new_type_error(format!( @@ -201,15 +326,23 @@ impl PyMemoryView { self.desc.format )) })?; - bytes[pos..pos + self.desc.itemsize].copy_from_slice(&data); + // The conversion, and the index that produced `pos`, could have released + // the view; `pos` addresses a buffer that is no longer there. + // CHECK_RELEASED_INT_AGAIN + self.try_not_released(vm)?; + let mut bytes = self.buffer.obj_bytes_mut(); + bytes[pos..pos + self.format_spec.size()].copy_from_slice(&data); Ok(()) } fn unpack_single(&self, pos: usize, vm: &VirtualMachine) -> PyResult { + // The index that produced `pos` could have released the view. + // CHECK_RELEASED_AGAIN + self.try_not_released(vm)?; let bytes = self.buffer.obj_bytes(); // TODO: Optimize self.format_spec - .unpack(&bytes[pos..pos + self.desc.itemsize], vm) + .unpack(&bytes[pos..pos + self.format_spec.size()], vm) .map(|x| { if x.len() == 1 { x[0].to_owned() @@ -234,9 +367,7 @@ impl PyMemoryView { Ordering::Equal => (), } - let pos = self.desc.position(indexes, vm)?; - let pos = (pos + self.start as isize) as usize; - Ok(pos) + Ok(self.desc.position(indexes, vm)? as usize) } fn init_len(&mut self) { @@ -244,50 +375,38 @@ impl PyMemoryView { self.desc.len = product * self.desc.itemsize; } + /// Move this view by `delta` bytes. The offset moves, unless a dimension + /// outside `dim` is reached through a pointer, in which case its suboffset + /// does. + fn adjust_position(&mut self, dim: usize, delta: isize) { + match self.desc.dim_desc[..dim] + .iter() + .rposition(|&(_, _, suboffset)| suboffset != 0) + { + Some(n) => self.desc.dim_desc[n].2 += delta, + None => self.desc.offset += delta, + } + } + fn init_range(&mut self, range: Range, dim: usize) { let (shape, stride, _) = self.desc.dim_desc[dim]; debug_assert!(shape >= range.len()); - let mut is_adjusted = false; - for (_, _, suboffset) in self.desc.dim_desc.iter_mut().rev() { - if *suboffset != 0 { - *suboffset += stride * range.start as isize; - is_adjusted = true; - break; - } - } - if !is_adjusted { - // no suboffset set, stride must be positive - self.start += stride as usize * range.start; - } - let new_len = range.len(); - self.desc.dim_desc[dim].0 = new_len; + self.adjust_position(dim, stride * range.start as isize); + self.desc.dim_desc[dim].0 = range.len(); } + // init_slice fn init_slice(&mut self, slice: &PySlice, dim: usize, vm: &VirtualMachine) -> PyResult<()> { let (shape, stride, _) = self.desc.dim_desc[dim]; let slice = slice.to_saturated(vm)?; - let (range, step, slice_len) = slice.adjust_indices(shape); + let (start, slice_len) = slice.adjust_indices_start(shape); - let mut is_adjusted_suboffset = false; - for (_, _, suboffset) in self.desc.dim_desc.iter_mut().rev() { - if *suboffset != 0 { - *suboffset += stride * range.start as isize; - is_adjusted_suboffset = true; - break; - } - } - if !is_adjusted_suboffset { - // no suboffset set, stride must be positive - self.start += stride as usize - * if step.is_negative() { - range.end - 1 - } else { - range.start - }; - } + // Repeated slicing multiplies the stride by the step every time, which + // overflows after about twenty rounds; C wraps there and so does this. + self.adjust_position(dim, stride.wrapping_mul(start)); self.desc.dim_desc[dim].0 = slice_len; - self.desc.dim_desc[dim].1 *= step; + self.desc.dim_desc[dim].1 = stride.wrapping_mul(slice.step()); Ok(()) } @@ -303,10 +422,12 @@ impl PyMemoryView { if dim + 1 == self.desc.ndim() { let mut v = Vec::with_capacity(shape); for _ in 0..shape { - let pos = index + suboffset; - let pos = (pos + self.start as isize) as usize; - let obj = - format_unpack(&self.format_spec, &bytes[pos..pos + self.desc.itemsize], vm)?; + let pos = (index + suboffset) as usize; + let obj = format_unpack( + &self.format_spec, + &bytes[pos..pos + self.format_spec.size()], + vm, + )?; v.push(obj); index += stride; } @@ -330,29 +451,42 @@ impl PyMemoryView { return Ok(false); } - if let Some(other) = other.downcast_ref::() - && other.released.load() - { - return Ok(false); - } - - let other = match PyBuffer::try_from_borrowed_object(vm, other) { - Ok(buf) => buf, - Err(_) => return Ok(false), + let other = if let Some(mv) = other.downcast_ref::() { + if mv.released.load() { + return Ok(false); + } + // Another view's buffer is read where it lies rather than acquired, + // so that a restricted view still compares. memory_richcompare + let mut view = mv.buffer.detached(); + view.desc = mv.desc.clone(); + view + } else { + match PyBuffer::try_from_borrowed_object(vm, other) { + Ok(buf) => buf, + Err(_) => return Ok(false), + } }; if !is_equiv_shape(&zelf.desc, &other.desc) { return Ok(false); } - let a_itemsize = zelf.desc.itemsize; - let b_itemsize = other.desc.itemsize; let a_format_spec = &zelf.format_spec; let b_format_spec = &Self::parse_format(&other.desc.format, vm)?; + // An element is as wide as its format, which a projected descriptor can + // make narrower than the item size it steps by. + let a_itemsize = a_format_spec.size(); + let b_itemsize = b_format_spec.size(); if zelf.desc.ndim() == 0 { - let a_val = format_unpack(a_format_spec, &zelf.buffer.obj_bytes()[..a_itemsize], vm)?; - let b_val = format_unpack(b_format_spec, &other.obj_bytes()[..b_itemsize], vm)?; + let a_pos = zelf.desc.offset as usize; + let b_pos = other.desc.offset as usize; + let a_bytes = zelf.buffer.obj_bytes(); + let a_val = format_unpack(a_format_spec, &a_bytes[a_pos..a_pos + a_itemsize], vm)?; + drop(a_bytes); + let b_bytes = other.obj_bytes(); + let b_val = format_unpack(b_format_spec, &b_bytes[b_pos..b_pos + b_itemsize], vm)?; + drop(b_bytes); return vm.bool_eq(&a_val, &b_val); } @@ -361,9 +495,8 @@ impl PyMemoryView { let a_bytes = zelf.buffer.obj_bytes(); let b_bytes = other.obj_bytes(); zelf.desc.zip_eq(&other.desc, false, |a_range, b_range| { - let a_range = (a_range.start + zelf.start as isize) as usize - ..(a_range.end + zelf.start as isize) as usize; - let b_range = b_range.start as usize..b_range.end as usize; + let a_range = a_range.start as usize..a_range.start as usize + a_itemsize; + let b_range = b_range.start as usize..b_range.start as usize + b_itemsize; let a_val = match format_unpack(a_format_spec, &a_bytes[a_range], vm) { Ok(val) => val, Err(e) => { @@ -384,39 +517,17 @@ impl PyMemoryView { ret } - fn obj_bytes(&self) -> BorrowedValue<'_, [u8]> { - if self.desc.is_contiguous() { - BorrowedValue::map(self.buffer.obj_bytes(), |x| { - &x[self.start..self.start + self.desc.len] - }) - } else { - BorrowedValue::map(self.buffer.obj_bytes(), |x| &x[self.start..]) - } - } - - fn obj_bytes_mut(&self) -> BorrowedValueMut<'_, [u8]> { - if self.desc.is_contiguous() { - BorrowedValueMut::map(self.buffer.obj_bytes_mut(), |x| { - &mut x[self.start..self.start + self.desc.len] - }) - } else { - BorrowedValueMut::map(self.buffer.obj_bytes_mut(), |x| &mut x[self.start..]) - } - } - fn as_contiguous(&self) -> Option> { self.desc.is_contiguous().then(|| { - BorrowedValue::map(self.buffer.obj_bytes(), |x| { - &x[self.start..self.start + self.desc.len] - }) + let range = self.desc.contiguous_range(); + BorrowedValue::map(self.buffer.obj_bytes(), |x| &x[range]) }) } fn _as_contiguous_mut(&self) -> Option> { self.desc.is_contiguous().then(|| { - BorrowedValueMut::map(self.buffer.obj_bytes_mut(), |x| { - &mut x[self.start..self.start + self.desc.len] - }) + let range = self.desc.contiguous_range(); + BorrowedValueMut::map(self.buffer.obj_bytes_mut(), |x| &mut x[range]) }) } @@ -427,9 +538,7 @@ impl PyMemoryView { buf.reserve(self.desc.len); let bytes = &*self.buffer.obj_bytes(); self.desc.for_each_segment(true, |range| { - let start = (range.start + self.start as isize) as usize; - let end = (range.end + self.start as isize) as usize; - buf.extend_from_slice(&bytes[start..end]); + buf.extend_from_slice(&bytes[range.start as usize..range.end as usize]); }) } } @@ -454,27 +563,7 @@ impl PyMemoryView { let mut data = vec![]; self.append_to(&mut data); - if self.desc.ndim() == 0 { - return VecBuffer::from(data) - .into_ref(&vm.ctx) - .into_pybuffer_with_descriptor(self.desc.clone()); - } - - let mut dim_desc = self.desc.dim_desc.clone(); - dim_desc.last_mut().unwrap().1 = self.desc.itemsize as isize; - dim_desc.last_mut().unwrap().2 = 0; - for i in (0..dim_desc.len() - 1).rev() { - dim_desc[i].1 = dim_desc[i + 1].1 * dim_desc[i + 1].0 as isize; - dim_desc[i].2 = 0; - } - - let desc = BufferDescriptor { - len: self.desc.len, - readonly: self.desc.readonly, - itemsize: self.desc.itemsize, - format: self.desc.format.clone(), - dim_desc, - }; + let desc = self.desc.contiguous(); VecBuffer::from(data) .into_ref(&vm.ctx) @@ -493,7 +582,7 @@ impl Py { return Err(vm.new_not_implemented_error("sub-view are not implemented")); } - let mut dest = self.new_view(); + let mut dest = self.borrowed_view(); dest.init_slice(slice, 0, vm)?; dest.init_len(); @@ -508,15 +597,11 @@ impl Py { }; }; - let src = if let Some(src) = src.downcast_ref::() { - if self.buffer.obj.is(&src.buffer.obj) { - src.to_contiguous(vm) - } else { - AsBuffer::as_buffer(src, vm)? - } - } else { - PyBuffer::try_from_object(vm, src)? - }; + // PyObject_GetBuffer(value, &src, PyBUF_FULL_RO) + let src = PyBuffer::try_from_object(vm, src)?; + // Acquiring the source ran `__buffer__`, which can release this view. + // copy_single: CHECK_RELEASED_INT_AGAIN + self.try_not_released(vm)?; if !is_equiv_structure(&src.desc, &dest.desc) { return Err(vm.new_value_error( @@ -524,11 +609,21 @@ impl Py { )); } + // copy_buffer reads the source as it stood before the copy began, which an + // overlapping assignment depends on and which also keeps the two borrows + // below off the same storage. + let src = if root_exporter(&src).is(&root_exporter(&dest.buffer)) { + let owned = src.to_contiguous(vm); + drop(src); + owned + } else { + src + }; + let mut bytes_mut = dest.buffer.obj_bytes_mut(); let src_bytes = src.obj_bytes(); dest.desc.zip_eq(&src.desc, true, |a_range, b_range| { - let a_range = (a_range.start + dest.start as isize) as usize - ..(a_range.end + dest.start as isize) as usize; + let a_range = a_range.start as usize..a_range.end as usize; let b_range = b_range.start as usize..b_range.end as usize; bytes_mut[a_range].copy_from_slice(&src_bytes[b_range]); false @@ -562,6 +657,17 @@ impl PyMemoryView { PyGenericAlias::from_args(cls, args, vm) } + #[pyclassmethod] + fn _from_flags( + _cls: PyTypeRef, + args: PyMemoryViewFromFlagsArgs, + vm: &VirtualMachine, + ) -> PyResult> { + let flags = + BufferFlags::from_bits_retain(args.flags.as_ref().try_to_primitive::(vm)? as u32); + Self::from_object_with_flags(&args.object, flags, vm).map(|mv| mv.into_ref(&vm.ctx)) + } + #[pymethod] pub fn release(&self) { if self.released.compare_exchange(false, true).is_ok() { @@ -571,7 +677,14 @@ impl PyMemoryView { #[pygetset] fn obj(&self, vm: &VirtualMachine) -> PyResult { - self.try_not_released(vm).map(|_| self.buffer.obj.clone()) + self.try_not_released(vm)?; + // A window over a buffer being released exposes no exporter, like a + // Py_buffer whose obj is NULL. + Ok(if self.buffer.obj.downcastable::() { + vm.ctx.none() + } else { + self.buffer.obj.clone() + }) } #[pygetset] @@ -647,7 +760,8 @@ impl PyMemoryView { #[pygetset] fn contiguous(&self, vm: &VirtualMachine) -> PyResult { - self.try_not_released(vm).map(|_| self.desc.is_contiguous()) + self.try_not_released(vm) + .map(|_| self.desc.is_contiguous() || self.desc.is_fortran_contiguous()) } #[pygetset] @@ -657,9 +771,8 @@ impl PyMemoryView { #[pygetset] fn f_contiguous(&self, vm: &VirtualMachine) -> PyResult { - // TODO: column-major order self.try_not_released(vm) - .map(|_| self.desc.ndim() <= 1 && self.desc.is_contiguous()) + .map(|_| self.desc.is_fortran_contiguous()) } #[pymethod] @@ -682,7 +795,7 @@ impl PyMemoryView { if let Some(tuple) = needle.downcast_ref::() && tuple.is_empty() { - return zelf.unpack_single(0, vm); + return zelf.unpack_single(zelf.desc.offset as usize, vm); } return Err(vm.new_type_error("invalid indexing of 0-dim memory")); } @@ -721,22 +834,26 @@ impl PyMemoryView { } #[pymethod] - fn tolist(&self, vm: &VirtualMachine) -> PyResult { + // memory_tolist + fn tolist(&self, vm: &VirtualMachine) -> PyResult { self.try_not_released(vm)?; let bytes = self.buffer.obj_bytes(); if self.desc.ndim() == 0 { - return Ok(vm.ctx.new_list(vec![format_unpack( + // A 0-dim view holds one element, which is what it unpacks to. + let pos = self.desc.offset as usize; + return format_unpack( &self.format_spec, - &bytes[..self.desc.itemsize], + &bytes[pos..pos + self.format_spec.size()], vm, - )?])); + ); } - self._to_list(&bytes, 0, 0, vm) + self._to_list(&bytes, self.desc.offset, 0, vm) + .map(Into::into) } #[pymethod] fn toreadonly(&self, vm: &VirtualMachine) -> PyResult> { - self.try_not_released(vm)?; + self.try_usable(vm)?; let mut other = self.new_view(); other.desc.readonly = true; Ok(other.into_ref(&vm.ctx)) @@ -808,31 +925,38 @@ impl PyMemoryView { fn cast_to_1d(&self, format: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { let format_str = format.as_str(); + if Self::native_fmtchar(format_str).is_none() { + return Err(vm.new_value_error( + "memoryview: destination format must be a native single character format prefixed with an optional '@'", + )); + } let format_spec = Self::parse_format(format_str, vm)?; let itemsize = format_spec.size(); if !self.desc.len.is_multiple_of(itemsize) { return Err(vm.new_type_error("memoryview: length is not a multiple of itemsize")); } - Ok(Self { + let zelf = Self { buffer: self.buffer.clone(), released: AtomicCell::new(false), - start: self.start, + restricted: AtomicCell::new(false), format_spec, desc: BufferDescriptor { len: self.desc.len, + offset: self.desc.offset, readonly: self.desc.readonly, itemsize, format: format_str.to_owned().into(), dim_desc: vec![(self.desc.len / itemsize, itemsize as isize, 0)], }, hash: OnceCell::new(), - }) + }; + Ok(zelf) } #[pymethod] fn cast(&self, args: CastArgs, vm: &VirtualMachine) -> PyResult> { - self.try_not_released(vm)?; + self.try_usable(vm)?; if !self.desc.is_contiguous() { return Err(vm.new_type_error("memoryview: casts are restricted to C-contiguous views")); } @@ -872,6 +996,11 @@ impl PyMemoryView { // 0 ndim is single item if shape_ndim == 0 { + if itemsize != other.desc.len { + return Err( + vm.new_type_error("memoryview: product(shape) * itemsize != buffer size") + ); + } other.desc.dim_desc = vec![]; other.desc.len = itemsize; return Ok(other.into_ref(&vm.ctx)); @@ -929,11 +1058,11 @@ impl Py { if self.desc.ndim() == 0 { // TODO: merge branches when we got conditional if let if needle.is(&vm.ctx.ellipsis) { - return self.pack_single(0, value, vm); + return self.pack_single(self.desc.offset as usize, value, vm); } else if let Some(tuple) = needle.downcast_ref::() && tuple.is_empty() { - return self.pack_single(0, value, vm); + return self.pack_single(self.desc.offset as usize, value, vm); } return Err(vm.new_type_error("invalid indexing of 0-dim memory")); } @@ -1002,33 +1131,43 @@ impl TryFromObject for SubscriptNeedle { } static BUFFER_METHODS: BufferMethods = BufferMethods { - obj_bytes: |buffer| buffer.obj_as::().obj_bytes(), - obj_bytes_mut: |buffer| buffer.obj_as::().obj_bytes_mut(), - release: |buffer| buffer.obj_as::().buffer.release(), - retain: |buffer| buffer.obj_as::().buffer.retain(), + obj_bytes: |buffer| buffer.obj_as::().buffer.obj_bytes(), + obj_bytes_mut: |buffer| buffer.obj_as::().buffer.obj_bytes_mut(), + // memory_releasebuf / memory_getbuf: a consumer's export of this view is a + // share of the acquisition the view is looking at. + release: |buffer| buffer.obj_as::().buffer.release_share(), + retain: |buffer| buffer.obj_as::().buffer.retain_share(), }; impl AsBuffer for PyMemoryView { - fn as_buffer(zelf: &Py, vm: &VirtualMachine) -> PyResult { - if zelf.released.load() { - Err(vm.new_value_error("operation forbidden on released memoryview object")) - } else { - Ok(PyBuffer::new( - zelf.to_owned().into(), - zelf.desc.clone(), - &BUFFER_METHODS, - )) - } - } -} + const RELEASE_BUFFER: bool = true; -impl Drop for PyMemoryView { - fn drop(&mut self) { - if self.released.load() { - unsafe { self.buffer.drop_without_release() }; - } else { - unsafe { ManuallyDrop::drop(&mut self.buffer) }; - } + // memory_getbuf + fn slot_as_buffer( + zelf: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { + let zelf = zelf + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("unexpected payload for as_buffer"))?; + zelf.try_usable(vm)?; + Ok(PyBuffer::new( + zelf.to_owned().into(), + zelf.requested_desc(flags, vm)?, + &BUFFER_METHODS, + )) + } + + fn as_buffer(zelf: &Py, vm: &VirtualMachine) -> PyResult { + zelf.try_usable(vm)?; + // memory_getbuf: *view = *base — the descriptor already says where the + // view starts. + Ok(PyBuffer::new( + zelf.to_owned().into(), + zelf.desc.clone(), + &BUFFER_METHODS, + )) } } @@ -1103,6 +1242,11 @@ impl Hashable for PyMemoryView { if !zelf.desc.readonly { return Err(vm.new_value_error("cannot hash writable memoryview object")); } + if !matches!(&*zelf.desc.format, "B" | "b" | "c") { + return Err( + vm.new_value_error("memoryview: hashing is restricted to formats 'B', 'b' or 'c'") + ); + } let val = zelf.contiguous_or_collect(|bytes| vm.state.hash_secret.hash_bytes(bytes)); let _ = zelf.hash.set(val); Ok(*zelf.hash.get().unwrap()) @@ -1131,6 +1275,211 @@ impl Representable for PyMemoryView { pub(crate) fn init(ctx: &'static Context) { PyMemoryView::extend_class(ctx, ctx.types.memoryview_type); PyMemoryViewIterator::extend_class(ctx, ctx.types.memoryviewiterator_type); + let wrapper_type = PyBufferWrapper::init_builtin_type(); + // bufferwrapper_as_buffer: bf_releasebuffer and no bf_getbuffer, so the type + // has `__release_buffer__` but no `__buffer__`. + wrapper_type.slots.has_release_buffer.store(true); + PyBufferWrapper::extend_class(ctx, wrapper_type); + PyBufferWindow::extend_class(ctx, PyBufferWindow::init_builtin_type()); +} + +#[pyclass(module = false, name = "_buffer_wrapper")] +#[derive(Debug)] +struct PyBufferWrapper { + // bw->obj: the object whose `__buffer__` produced the view + exporter: PyObjectRef, + // bw->mv: the memoryview `__buffer__` returned, dropped with the last export + returned_mv: PyMutex>>, + /// Memory of `returned_mv`, held on behalf of every live export. The wrapper + /// forwards shares of it rather than owning one. + view: PyBuffer, + /// Exports handed out for this wrapper; the wrapper is spent at zero. + exports: AtomicCell, +} + +impl PyPayload for PyBufferWrapper { + fn class(_ctx: &Context) -> &'static Py { + Self::static_type() + } +} + +#[pyclass(flags(DISALLOW_INSTANTIATION))] +impl PyBufferWrapper {} + +static BUFFER_WRAPPER_METHODS: BufferMethods = BufferMethods { + obj_bytes: |buffer| buffer.obj_as::().view.obj_bytes(), + obj_bytes_mut: |buffer| buffer.obj_as::().view.obj_bytes_mut(), + retain: |buffer| { + let wrapper = buffer.obj_as::(); + wrapper.exports.fetch_add(1); + wrapper.view.retain_share(); + }, + // bufferwrapper_releasebuf + release: |buffer| { + let wrapper = buffer.obj_as::(); + wrapper.view.release_share(); + if wrapper.exports.fetch_sub(1) != 1 { + return; + } + let Some(mv) = wrapper.returned_mv.lock().take() else { + return; + }; + // A native release runs when the memoryview itself is torn down; only a + // Python-level hook on a foreign exporter has to be called here. + if !mv.buffer.obj.is(&wrapper.exporter) + && wrapper.exporter.class().slots.python_release_buffer.load() + { + call_python_release_buffer(&wrapper.exporter, mv.clone()); + } + // Py_CLEAR(bw->mv): the view outlives this only if user code kept it. + drop(mv); + }, +}; + +// Read-only window over an exporter, handed to `__release_buffer__`. It owns no +// export, like a `Py_buffer` whose `obj` is NULL, so releasing it is inert and +// cannot recurse back into the hook. +#[pyclass(module = false, name = "_buffer_window")] +#[derive(Debug)] +struct PyBufferWindow { + source: PyBuffer, +} + +impl PyPayload for PyBufferWindow { + fn class(_ctx: &Context) -> &'static Py { + Self::static_type() + } +} + +#[pyclass(flags(DISALLOW_INSTANTIATION))] +impl PyBufferWindow {} + +static BUFFER_WINDOW_METHODS: BufferMethods = BufferMethods { + obj_bytes: |buffer| buffer.obj_as::().source.obj_bytes(), + obj_bytes_mut: |buffer| buffer.obj_as::().source.obj_bytes_mut(), + retain: |_buffer| {}, + release: |_buffer| {}, +}; + +/// The object that ultimately owns the bytes a buffer reads, seen through the +/// payloads that only forward to another export: a view, the wrapper holding what +/// a `__buffer__` returned, and the window handed to `__release_buffer__`. +/// +/// Two buffers that resolve to the same object address the same storage, so +/// borrowing one for writing while the other is borrowed for reading would +/// deadlock on it. +fn root_exporter(buffer: &PyBuffer) -> PyObjectRef { + let mut obj = buffer.obj.clone(); + loop { + let next = if let Some(view) = obj.downcast_ref::() { + view.buffer.obj.clone() + } else if let Some(wrapper) = obj.downcast_ref::() { + wrapper.view.obj.clone() + } else if let Some(window) = obj.downcast_ref::() { + window.source.obj.clone() + } else { + return obj; + }; + obj = next; + } +} + +// slot_bf_getbuffer +pub(crate) fn buffer_from_python_getbuffer( + obj: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, +) -> PyResult { + let flags_obj = vm.ctx.new_int(flags.bits() as i32); + let ret = vm.call_special_method(obj, identifier!(vm, __buffer__), (flags_obj,))?; + let mv = ret + .downcast::() + .map_err(|_| vm.new_type_error("__buffer__ returned non-memoryview object"))?; + + // PyObject_GetBuffer(ret, buffer, flags): the returned view has to satisfy + // the request in its own right. + mv.try_usable(vm)?; + let desc = mv.requested_desc(flags, vm)?; + let wrapper = PyBufferWrapper { + exporter: obj.to_owned(), + view: mv.buffer.detached(), + returned_mv: PyMutex::new(Some(mv)), + exports: AtomicCell::new(0), + } + .into_pyobject(vm); + + // PyBuffer::new retains once through BUFFER_WRAPPER_METHODS. + Ok(PyBuffer::new(wrapper, desc, &BUFFER_WRAPPER_METHODS)) +} + +// wrap_releasebuffer +pub(crate) fn release_buffer_from_python( + obj: &PyObject, + mv: PyRef, + vm: &VirtualMachine, +) -> PyResult<()> { + let view_obj = &mv.buffer.obj; + if view_obj.downcastable::() { + // A window exports nothing, so there is nothing left to release, as for + // a `Py_buffer` whose `obj` is NULL. + return Ok(()); + } + let exports_obj = view_obj.is(obj) + || view_obj + .downcast_ref::() + .is_some_and(|wrapper| wrapper.exporter.is(obj)); + if !exports_obj { + return Err(vm.new_value_error("memoryview's buffer is not this object")); + } + if mv.released.load() { + return Err(vm.new_value_error("memoryview's buffer has already been released")); + } + mv.release(); + Ok(()) +} + +// releasebuffer_call_python, for a buffer acquired from a native exporter +pub(crate) fn release_buffer_call_python(buffer: &PyBuffer) { + crate::vm::thread::try_with_current_vm(|vm| { + let exporter = buffer.obj.clone(); + let window = PyBufferWindow { + source: buffer.detached(), + } + .into_pyobject(vm); + let window = PyBuffer::new(window, buffer.desc.clone(), &BUFFER_WINDOW_METHODS); + let mv = match PyMemoryView::from_buffer(window, vm) { + Ok(mv) => mv, + Err(exc) => { + let msg = format!( + "Exception ignored in bf_releasebuffer of {}", + exporter.class().name() + ); + return vm.run_unraisable(exc, Some(msg), vm.ctx.none()); + } + }; + // Restricted, so user code cannot keep anything addressing the memory + // that is about to go away. + mv.restricted.store(true); + let mv = mv.into_ref(&vm.ctx); + call_python_release_buffer(&exporter, mv.clone()); + // The window does not outlive the release it was made for. + mv.release(); + }); +} + +fn call_python_release_buffer(exporter: &PyObject, mv: PyRef) { + crate::vm::thread::try_with_current_vm(|vm| { + let method = vm.get_special_method(exporter, identifier!(vm, __release_buffer__)); + if let Ok(Some(method)) = method + && let Err(exc) = method.invoke((mv,), vm) + { + let msg = format!( + "Exception ignored in __release_buffer__ of {}", + exporter.class().name() + ); + vm.run_unraisable(exc, Some(msg), vm.ctx.none()); + } + }); } fn format_unpack( diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index f3655d892..8a95dcb64 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -23,7 +23,9 @@ use crate::{ function::{ArgIterable, ArgSize, FuncArgs, OptionalArg, OptionalOption, PyComparisonValue}, intern::PyInterned, object::{MaybeTraverse, Traverse, TraverseFn}, - protocol::{PyIterReturn, PyMappingMethods, PyNumberMethods, PySequenceMethods}, + protocol::{ + BufferFlags, PyBuffer, PyIterReturn, PyMappingMethods, PyNumberMethods, PySequenceMethods, + }, sequence::SequenceExt, sliceable::{SequenceIndex, SliceableSequenceOp}, types::{ @@ -441,15 +443,24 @@ impl Constructor for PyStr { if input.fast_isinstance(vm.ctx.types.str_type) { return Err(vm.new_type_error("decoding str is not supported")); } - if !input.fast_isinstance(vm.ctx.types.bytes_type) - && !input.fast_isinstance(vm.ctx.types.bytearray_type) - && crate::protocol::PyBuffer::try_from_borrowed_object(vm, &input).is_err() + let input = if input.fast_isinstance(vm.ctx.types.bytes_type) + || input.fast_isinstance(vm.ctx.types.bytearray_type) { - return Err(vm.new_type_error(format!( - "decoding to str: need a bytes-like object, {} found", - input.class().name() - ))); - } + input + } else { + // PyUnicode_FromEncodedObject: whatever an exporter + // complains about, the argument is simply not bytes-like. + let buffer = PyBuffer::from_object(vm, &input, BufferFlags::SIMPLE) + .map_err(|_| { + vm.new_type_error(format!( + "decoding to str: need a bytes-like object, {} found", + input.class().name() + )) + })?; + vm.ctx + .new_bytes(buffer.contiguous_or_collect(<[u8]>::to_vec)) + .into() + }; let enc_str = encoding.as_ref().map_or("utf-8", |e| e.as_str()); let s = vm .state diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 5a7169983..46e2e4ebf 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -821,8 +821,6 @@ impl PyType { slots.basicsize = base.slots.basicsize; } - Self::inherit_readonly_slots(&mut slots, &base); - // Normalize: any type with HAS_WEAKREF gets MANAGED_WEAKREF if slots.flags.has_feature(PyTypeFlags::HAS_WEAKREF) { slots.flags |= PyTypeFlags::MANAGED_WEAKREF; @@ -891,8 +889,6 @@ impl PyType { slots.basicsize = base.slots.basicsize; } - Self::inherit_readonly_slots(&mut slots, &base); - // Normalize: any type with HAS_WEAKREF gets MANAGED_WEAKREF if slots.flags.has_feature(PyTypeFlags::HAS_WEAKREF) { slots.flags |= PyTypeFlags::MANAGED_WEAKREF; @@ -1018,18 +1014,9 @@ impl PyType { } } - /// Inherit readonly slots from base type at creation time. - /// These slots are not AtomicCell and must be set before the type is used. - fn inherit_readonly_slots(slots: &mut PyTypeSlots, base: &Self) { - if slots.as_buffer.is_none() { - slots.as_buffer = base.slots.as_buffer; - } - } - /// Inherit slots from base type. inherit_slots pub(crate) fn inherit_slots(&self, base: &Self) { // Use SLOT_DEFS to iterate all slots - // Note: as_buffer is handled in inherit_readonly_slots (not AtomicCell) for def in SLOT_DEFS { def.accessor.copyslot_if_none(self, base); } diff --git a/crates/vm/src/byte.rs b/crates/vm/src/byte.rs index 933ddead4..0e90f296a 100644 --- a/crates/vm/src/byte.rs +++ b/crates/vm/src/byte.rs @@ -2,11 +2,16 @@ use num_traits::ToPrimitive; -use crate::{AsObject, PyObject, PyResult, VirtualMachine}; +use crate::{ + AsObject, PyObject, PyResult, VirtualMachine, + protocol::{BufferFlags, PyBuffer}, +}; +// PyBytes_FromObject pub fn bytes_from_object(vm: &VirtualMachine, obj: &PyObject) -> PyResult> { - if let Ok(elements) = obj.try_bytes_like(vm, |bytes| bytes.to_vec()) { - return Ok(elements); + if obj.check_buffer() { + let buffer = PyBuffer::from_object(vm, obj, BufferFlags::FULL_RO)?; + return Ok(buffer.contiguous_or_collect(|bytes| bytes.to_vec())); } if !obj.fast_isinstance(vm.ctx.types.str_type) diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index 6ceebb70d..3c79ed329 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -1,6 +1,7 @@ // spell-checker:ignore unchunked use crate::{ - AsObject, PyObject, PyObjectRef, PyResult, TryFromBorrowedObject, VirtualMachine, + AsObject, PyObject, PyObjectRef, PyResult, TryFromBorrowedObject, TryFromObject, + VirtualMachine, anystr::{self, AnyStr, AnyStrContainer, AnyStrWrapper}, builtins::{ PyBaseExceptionRef, PyByteArray, PyBytes, PyBytesRef, PyInt, PyIntRef, PyStr, PyStrRef, @@ -12,7 +13,7 @@ use crate::{ common::wtf8::is_py_ascii_whitespace, function::{ArgIterable, Either, OptionalArg, OptionalOption, PyComparisonValue}, literal::escape::Escape, - protocol::PyBuffer, + protocol::{BufferFlags, PyBuffer}, sequence::{SequenceExt, SequenceMutExt}, types::PyComparisonOp, }; @@ -35,9 +36,10 @@ impl From> for PyBytesInner { } } +/// "y*": any bytes-like object, and nothing else. impl<'a> TryFromBorrowedObject<'a> for PyBytesInner { fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { - bytes_from_object(vm, obj).map(Self::from) + obj.try_bytes_like(vm, <[u8]>::to_vec).map(Self::from) } } @@ -137,10 +139,50 @@ impl ByteInnerNewOptions { } } +/// What is searched for: a bytes-like object, or a single byte given as an +/// integer. parse_args_finds_byte +pub enum ByteInnerSub { + Buffer(PyBytesInner), + Byte(PyIntRef), +} + +impl TryFromObject for ByteInnerSub { + fn try_from_object(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult { + if obj.check_buffer() { + return PyBytesInner::try_from_object(vm, obj).map(Self::Buffer); + } + match obj.try_index_opt(vm) { + Some(int) => int.map(Self::Byte), + None => Err(vm.new_type_error(format!( + "argument should be integer or bytes-like object, not '{}'", + obj.class().name() + ))), + } + } +} + +impl ByteInnerSub { + /// The needle of a containment test, which is an integer if it is one at + /// all and a bytes-like object otherwise. bytes_contains + pub fn from_contains_arg(obj: PyObjectRef, vm: &VirtualMachine) -> PyResult { + match obj.try_index_opt(vm) { + Some(int) => int.map(Self::Byte), + None => PyBytesInner::try_from_object(vm, obj).map(Self::Buffer), + } + } + + fn into_vec(self, vm: &VirtualMachine) -> PyResult> { + Ok(match self { + Self::Buffer(buffer) => buffer.elements, + Self::Byte(int) => vec![int.as_bigint().byte_or(vm)?], + }) + } +} + #[derive(FromArgs)] pub struct ByteInnerFindOptions { #[pyarg(positional)] - sub: Either, + sub: ByteInnerSub, #[pyarg(positional, default)] start: Option, #[pyarg(positional, default)] @@ -153,10 +195,7 @@ impl ByteInnerFindOptions { len: usize, vm: &VirtualMachine, ) -> PyResult<(Vec, core::ops::Range)> { - let sub = match self.sub { - Either::A(v) => v.elements.to_vec(), - Either::B(int) => vec![int.as_bigint().byte_or(vm)?], - }; + let sub = self.sub.into_vec(vm)?; let range = anystr::adjust_indices(self.start, self.end, len); Ok((sub, range)) } @@ -203,14 +242,11 @@ impl ByteInnerTranslateOptions { let table = self.table.map_or_else( || Ok((0..=u8::MAX).collect::>()), |v| { - let bytes = v - .try_into_value::(vm) - .ok() - .filter(|v| v.elements.len() == 256) - .ok_or_else(|| { - vm.new_value_error("translation table must be 256 characters long") - })?; - Ok(bytes.elements.to_vec()) + let bytes: PyBytesInner = v.try_into_value(vm)?; + if bytes.elements.len() != 256 { + return Err(vm.new_value_error("translation table must be 256 characters long")); + } + Ok(bytes.elements) }, )?; @@ -363,10 +399,10 @@ impl PyBytesInner { self.elements.py_add(other) } - pub fn contains(&self, needle: Either, vm: &VirtualMachine) -> PyResult { + pub fn contains(&self, needle: ByteInnerSub, vm: &VirtualMachine) -> PyResult { Ok(match needle { - Either::A(byte) => self.elements.contains_str(byte.elements.as_slice()), - Either::B(int) => self.elements.contains(&int.as_bigint().byte_or(vm)?), + ByteInnerSub::Buffer(sub) => self.elements.contains_str(sub.elements.as_slice()), + ByteInnerSub::Byte(int) => self.elements.contains(&int.as_bigint().byte_or(vm)?), }) } @@ -522,7 +558,8 @@ impl PyBytesInner { pub fn fromhex_object(string: PyObjectRef, vm: &VirtualMachine) -> PyResult> { if let Some(s) = string.downcast_ref::() { Self::fromhex(s.as_bytes(), vm) - } else if let Ok(buffer) = PyBuffer::try_from_borrowed_object(vm, &string) { + } else if string.check_buffer() { + let buffer = PyBuffer::from_object(vm, &string, BufferFlags::SIMPLE)?; let borrowed = buffer .as_contiguous() .ok_or_else(|| vm.new_buffer_error("fromhex() requires a contiguous buffer"))?; @@ -974,7 +1011,7 @@ impl PyBytesInner { } pub fn concat(&self, other: &PyObject, vm: &VirtualMachine) -> PyResult> { - let buffer = PyBuffer::try_from_borrowed_object(vm, other)?; + let buffer = PyBuffer::from_object(vm, other, BufferFlags::SIMPLE)?; let borrowed = buffer.as_contiguous(); if let Some(other) = borrowed { let mut v = Vec::with_capacity(self.elements.len() + other.len()); diff --git a/crates/vm/src/cformat.rs b/crates/vm/src/cformat.rs index 3bba0e5f8..7d47da399 100644 --- a/crates/vm/src/cformat.rs +++ b/crates/vm/src/cformat.rs @@ -22,7 +22,7 @@ use crate::{ wtf8::{CodePoint, Wtf8, Wtf8Buf}, }, function::ArgIntoFloat, - protocol::PyBuffer, + protocol::{BufferFlags, PyBuffer}, stdlib::builtins, }; @@ -39,24 +39,29 @@ fn spec_format_bytes( let b = builtins::ascii(obj, vm)?.as_bytes().to_vec(); Ok(b) } + // format_obj CFormatConversion::Str | CFormatConversion::Bytes => { - if let Ok(buffer) = PyBuffer::try_from_borrowed_object(vm, &obj) { - Ok(buffer.contiguous_or_collect(|bytes| spec.format_bytes(bytes))) - } else { - let bytes = vm - .get_special_method(&obj, identifier!(vm, __bytes__))? - .ok_or_else(|| { - let msg = format!( - "%b requires a bytes-like object, or an object that \ - implements __bytes__, not '{}'", - obj.class().name() - ); - vm.new_type_error(msg) - })? - .invoke((), vm)?; - let bytes = PyBytes::try_from_borrowed_object(vm, &bytes)?; - Ok(spec.format_bytes(bytes.as_bytes())) + if let Some(bytes) = obj.downcast_ref::() { + return Ok(spec.format_bytes(bytes.as_bytes())); } + if let Some(bytearray) = obj.downcast_ref::() { + return Ok(spec.format_bytes(&bytearray.borrow_buf())); + } + if let Some(method) = vm.get_special_method(&obj, identifier!(vm, __bytes__))? { + let bytes = method.invoke((), vm)?; + let bytes = PyBytes::try_from_borrowed_object(vm, &bytes)?; + return Ok(spec.format_bytes(bytes.as_bytes())); + } + if obj.check_buffer() { + let buffer = PyBuffer::from_object(vm, &obj, BufferFlags::FULL_RO)?; + return Ok(buffer.contiguous_or_collect(|bytes| spec.format_bytes(bytes))); + } + let msg = format!( + "%b requires a bytes-like object, or an object that \ + implements __bytes__, not '{}'", + obj.class().name() + ); + Err(vm.new_type_error(msg)) } }, CFormatType::Number(number_type) => match number_type { diff --git a/crates/vm/src/function/buffer.rs b/crates/vm/src/function/buffer.rs index 213193bb9..c73f27c04 100644 --- a/crates/vm/src/function/buffer.rs +++ b/crates/vm/src/function/buffer.rs @@ -3,7 +3,7 @@ use crate::{ VirtualMachine, builtins::{PyStr, PyStrRef}, common::borrow::{BorrowedValue, BorrowedValueMut}, - protocol::PyBuffer, + protocol::{BufferFlags, PyBuffer}, }; // Python/getargs.c @@ -17,7 +17,7 @@ impl PyObject { where F: FnOnce(&[u8]) -> R, { - let buffer = PyBuffer::try_from_borrowed_object(vm, self)?; + let buffer = PyBuffer::from_object(vm, self, BufferFlags::SIMPLE)?; buffer .as_contiguous() .map(|x| f(&x)) @@ -28,7 +28,7 @@ impl PyObject { where F: FnOnce(&mut [u8]) -> R, { - let buffer = PyBuffer::try_from_borrowed_object(vm, self)?; + let buffer = PyBuffer::from_object(vm, self, BufferFlags::WRITABLE)?; buffer .as_contiguous_mut() .map(|mut x| f(&mut x)) @@ -77,9 +77,9 @@ impl From for PyObjectRef { } } -impl<'a> TryFromBorrowedObject<'a> for ArgBytesLike { - fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { - let buffer = PyBuffer::try_from_borrowed_object(vm, obj)?; +impl ArgBytesLike { + fn from_request(vm: &VirtualMachine, obj: &PyObject, flags: BufferFlags) -> PyResult { + let buffer = PyBuffer::from_object(vm, obj, flags)?; if buffer.desc.is_contiguous() { Ok(Self(buffer)) } else { @@ -88,6 +88,31 @@ impl<'a> TryFromBorrowedObject<'a> for ArgBytesLike { } } +impl<'a> TryFromBorrowedObject<'a> for ArgBytesLike { + fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { + Self::from_request(vm, obj, BufferFlags::SIMPLE) + } +} + +/// A bytes-like object asked for as `PyBUF_CONTIG_RO`, which is what a shape is +/// requested with rather than assumed. +#[derive(Debug, Traverse)] +pub struct ArgContiguousBytesLike(ArgBytesLike); + +impl core::ops::Deref for ArgContiguousBytesLike { + type Target = ArgBytesLike; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'a> TryFromBorrowedObject<'a> for ArgContiguousBytesLike { + fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { + ArgBytesLike::from_request(vm, obj, BufferFlags::CONTIG_RO).map(Self) + } +} + /// A memory buffer, read-write access. Like the `w*` format code for `PyArg_Parse` in CPython. #[derive(Debug, Traverse)] pub struct ArgMemoryBuffer(PyBuffer); @@ -124,7 +149,15 @@ impl From for PyBuffer { impl<'a> TryFromBorrowedObject<'a> for ArgMemoryBuffer { fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { - let buffer = PyBuffer::try_from_borrowed_object(vm, obj)?; + let buffer = PyBuffer::from_object(vm, obj, BufferFlags::WRITABLE).map_err(|exc| { + if obj.check_buffer() { + // An exporter that cannot serve the request leaves the argument + // simply the wrong kind of object, as `PyArg_Parse` reports it. + vm.new_type_error("buffer is not a read-write bytes-like object") + } else { + exc + } + })?; if !buffer.desc.is_contiguous() { Err(vm.new_buffer_error("non-contiguous buffer is not a bytes-like object")) } else if buffer.desc.readonly { diff --git a/crates/vm/src/function/fspath.rs b/crates/vm/src/function/fspath.rs index 50feef86d..954c82cb7 100644 --- a/crates/vm/src/function/fspath.rs +++ b/crates/vm/src/function/fspath.rs @@ -3,7 +3,6 @@ use crate::{ builtins::{PyBytes, PyBytesRef, PyStrRef}, convert::{IntoPyException, ToPyObject}, function::PyStr, - protocol::PyBuffer, }; use alloc::borrow::Cow; use core::hint::cold_path; @@ -147,16 +146,9 @@ impl ToPyObject for FsPath { } impl TryFromObject for FsPath { - // PyUnicode_FSDecoder in CPython + // PyUnicode_FSDecoder, which takes what PyOS_FSPath takes: str, bytes, or an + // object with __fspath__, and nothing that merely exports a buffer. fn try_from_object(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult { - let obj = match obj.try_to_value::(vm) { - Ok(buffer) => { - let mut bytes = vec![]; - buffer.append_to(&mut bytes); - vm.ctx.new_bytes(bytes).into() - } - Err(_) => obj, - }; Self::try_from_path_like(obj, true, vm) } } diff --git a/crates/vm/src/function/mod.rs b/crates/vm/src/function/mod.rs index 2ec1d09e8..2b37c9fc8 100644 --- a/crates/vm/src/function/mod.rs +++ b/crates/vm/src/function/mod.rs @@ -15,7 +15,9 @@ pub use argument::{ OptionalArg, OptionalOption, PosArgs, }; pub use arithmetic::{PyArithmeticValue, PyComparisonValue}; -pub use buffer::{ArgAsciiBuffer, ArgBytesLike, ArgMemoryBuffer, ArgStrOrBytesLike}; +pub use buffer::{ + ArgAsciiBuffer, ArgBytesLike, ArgContiguousBytesLike, ArgMemoryBuffer, ArgStrOrBytesLike, +}; pub use builtin::{IntoPyNativeFn, PyNativeFn, static_func, static_raw_func}; pub use either::Either; pub use fspath::FsPath; diff --git a/crates/vm/src/protocol/buffer.rs b/crates/vm/src/protocol/buffer.rs index d79c5e993..cf60775f7 100644 --- a/crates/vm/src/protocol/buffer.rs +++ b/crates/vm/src/protocol/buffer.rs @@ -6,14 +6,101 @@ use crate::{ common::{ borrow::{BorrowedValue, BorrowedValueMut}, lock::{MapImmutable, PyMutex, PyMutexGuard}, + rc::PyRc, }, object::PyObjectPayload, sliceable::SequenceIndexOp, }; use alloc::borrow::Cow; +use bitflags::bitflags; use core::{fmt::Debug, ops::Range}; +use crossbeam_utils::atomic::AtomicCell; use itertools::Itertools; +bitflags! { + /// Capabilities a consumer asks a buffer exporter for, the `flags` argument of + /// `bf_getbuffer` and of `__buffer__` (`PyBUF_*`). + /// + /// The composite requests are supersets of the simpler ones, so + /// [`contains`](Self::contains) answers the `REQ_*` questions an exporter asks: + /// `flags.contains(BufferFlags::C_CONTIGUOUS)` is `REQ_C_CONTIGUOUS(flags)`. + #[derive(Copy, Clone, Debug, PartialEq, Eq)] + pub struct BufferFlags: u32 { + const WRITABLE = 0x0001; + const FORMAT = 0x0004; + const ND = 0x0008; + const STRIDES = 0x0010 | Self::ND.bits(); + const C_CONTIGUOUS = 0x0020 | Self::STRIDES.bits(); + const F_CONTIGUOUS = 0x0040 | Self::STRIDES.bits(); + const ANY_CONTIGUOUS = 0x0080 | Self::STRIDES.bits(); + const INDIRECT = 0x0100 | Self::STRIDES.bits(); + } +} + +impl BufferFlags { + /// `PyBUF_SIMPLE`: a plain read-only block of bytes. + pub const SIMPLE: Self = Self::empty(); + /// `PyBUF_CONTIG` + pub const CONTIG: Self = Self::ND.union(Self::WRITABLE); + /// `PyBUF_CONTIG_RO` + pub const CONTIG_RO: Self = Self::ND; + /// `PyBUF_STRIDED` + pub const STRIDED: Self = Self::STRIDES.union(Self::WRITABLE); + /// `PyBUF_STRIDED_RO` + pub const STRIDED_RO: Self = Self::STRIDES; + /// `PyBUF_RECORDS` + pub const RECORDS: Self = Self::STRIDED.union(Self::FORMAT); + /// `PyBUF_RECORDS_RO` + pub const RECORDS_RO: Self = Self::STRIDED_RO.union(Self::FORMAT); + /// `PyBUF_FULL`: everything an exporter can describe, writable. + pub const FULL: Self = Self::INDIRECT.union(Self::WRITABLE).union(Self::FORMAT); + /// `PyBUF_FULL_RO`: everything an exporter can describe, read-only. + pub const FULL_RO: Self = Self::INDIRECT.union(Self::FORMAT); + + /// `PyBUF_READ`. Belongs to `PyMemoryView_FromMemory`, not to `bf_getbuffer`. + const MEMORY_READ: Self = Self::from_bits_retain(0x100); + /// `PyBUF_WRITE`. Belongs to `PyMemoryView_FromMemory`, not to `bf_getbuffer`. + const MEMORY_WRITE: Self = Self::from_bits_retain(0x200); + + /// Whether this request is really a `PyMemoryView_FromMemory` access mode, + /// which no exporter can serve. + #[must_use] + pub const fn is_memory_access_mode(self) -> bool { + self.bits() == Self::MEMORY_READ.bits() || self.bits() == Self::MEMORY_WRITE.bits() + } + + /// Whether the consumer demands a writable buffer. + #[must_use] + pub const fn is_writable(self) -> bool { + self.intersects(Self::WRITABLE) + } + + /// The argument checks `PyBuffer_FillInfo` performs, for exporters that hand + /// out a flat block of bytes. + pub fn fill_info_check(self, readonly: bool, vm: &VirtualMachine) -> PyResult<()> { + if self == Self::SIMPLE { + return Ok(()); + } + if self.is_memory_access_mode() { + return Err(vm.new_system_error("bad argument to internal function")); + } + self.check_writable(readonly, "Object is not writable.", vm) + } + + /// Reject a writable request against a read-only export. + pub fn check_writable( + self, + readonly: bool, + message: &str, + vm: &VirtualMachine, + ) -> PyResult<()> { + if self.is_writable() && readonly { + return Err(vm.new_buffer_error(message.to_owned())); + } + Ok(()) + } +} + pub struct BufferMethods { pub obj_bytes: fn(&PyBuffer) -> BorrowedValue<'_, [u8]>, pub obj_bytes_mut: fn(&PyBuffer) -> BorrowedValueMut<'_, [u8]>, @@ -32,13 +119,46 @@ impl Debug for BufferMethods { } } -#[derive(Debug, Clone, Traverse)] +/// One acquisition from an exporter: the state a single `bf_getbuffer` set up, +/// shared by every handle taken from it. _PyManagedBufferObject +#[derive(Debug)] +struct BufferExport { + /// Handles and raw shares that have not been given up yet. mbuf->exports + shares: AtomicCell, + /// Whether the exporter's release has already run. + /// _Py_MANAGED_BUFFER_RELEASED + released: AtomicCell, +} + +#[derive(Debug, Traverse)] pub struct PyBuffer { pub obj: PyObjectRef, #[pytraverse(skip)] pub desc: BufferDescriptor, #[pytraverse(skip)] methods: &'static BufferMethods, + #[pytraverse(skip)] + export: PyRc, + /// Whether this handle still holds its share of `export`. + #[pytraverse(skip)] + owns_share: AtomicCell, +} + +/// Cloning takes another share of the same acquisition rather than asking the +/// exporter for a new one, and the exporter's release waits for the last share. +/// mbuf_add_view +impl Clone for PyBuffer { + fn clone(&self) -> Self { + debug_assert!(!self.export.released.load()); + self.export.shares.fetch_add(1); + Self { + obj: self.obj.clone(), + desc: self.desc.clone(), + methods: self.methods, + export: self.export.clone(), + owns_share: AtomicCell::new(true), + } + } } impl PyBuffer { @@ -47,8 +167,17 @@ impl PyBuffer { #[cfg(debug_assertions)] let desc = desc.validate(); - let zelf = Self { obj, desc, methods }; - zelf.retain(); + let zelf = Self { + obj, + desc, + methods, + export: PyRc::new(BufferExport { + shares: AtomicCell::new(1), + released: AtomicCell::new(false), + }), + owns_share: AtomicCell::new(true), + }; + (zelf.methods.retain)(&zelf); zelf } @@ -78,14 +207,16 @@ impl PyBuffer { /// assume the buffer is contiguous #[must_use] pub unsafe fn contiguous_unchecked(&self) -> BorrowedValue<'_, [u8]> { - self.obj_bytes() + let range = self.desc.contiguous_range(); + BorrowedValue::map(self.obj_bytes(), |x| &x[range]) } /// # Safety /// assume the buffer is contiguous and writable #[must_use] pub unsafe fn contiguous_mut_unchecked(&self) -> BorrowedValueMut<'_, [u8]> { - self.obj_bytes_mut() + let range = self.desc.contiguous_range(); + BorrowedValueMut::map(self.obj_bytes_mut(), |x| &mut x[range]) } pub fn append_to(&self, buf: &mut Vec) { @@ -113,6 +244,18 @@ impl PyBuffer { f(v) } + /// A copy of these bytes in C order, keeping shape and format. The copy + /// borrows nothing from the exporter, so it can be read while the exporter is + /// borrowed for writing. + #[must_use] + pub fn to_contiguous(&self, vm: &VirtualMachine) -> Self { + let mut data = vec![]; + self.append_to(&mut data); + VecBuffer::from(data) + .into_ref(&vm.ctx) + .into_pybuffer_with_descriptor(self.desc.contiguous()) + } + #[must_use] pub fn obj_as(&self) -> &Py { unsafe { self.obj.downcast_unchecked_ref() } @@ -128,31 +271,87 @@ impl PyBuffer { (self.methods.obj_bytes_mut)(self) } + /// Give up this handle's share of the acquisition. PyBuffer_Release + /// + /// Idempotent: a handle that has already been released owns nothing, so + /// dropping it afterwards does nothing, like a `Py_buffer` whose `obj` was + /// cleared. + /// + /// This can run arbitrary Python through `__release_buffer__`, so no borrow + /// of the exporter may be held while a buffer is released or dropped. pub fn release(&self) { + if self.owns_share.swap(false) { + self.drop_share(); + } + } + + /// Take a share of this acquisition that no handle owns. An exporter that + /// forwards a consumer's export onto a buffer it holds itself keeps the + /// acquisition alive this way. memory_getbuf + pub(crate) fn retain_share(&self) { + self.export.shares.fetch_add(1); + } + + /// Give back a share taken by [`Self::retain_share`]. memory_releasebuf + pub(crate) fn release_share(&self) { + self.drop_share(); + } + + fn drop_share(&self) { + if self.export.shares.fetch_sub(1) == 1 { + self.finalize(); + } + } + + /// The exporter learns its export is gone, once per acquisition. mbuf_release + fn finalize(&self) { + // Latched before the hook runs, so a release re-entered from Python is + // inert. + if self.export.released.swap(true) { + return; + } + // slot_bf_releasebuffer: a Python-level `__release_buffer__` runs first, + // then the exporter's own release so export counts stay balanced. + if self.obj.class().slots.python_release_buffer.load() { + crate::builtins::memory::release_buffer_call_python(self); + } (self.methods.release)(self) } - pub fn retain(&self) { - (self.methods.retain)(self) + /// Undo an acquisition the exporter had already handed out but that could not + /// be served, without telling Python: `bf_releasebuffer` does not run when + /// `bf_getbuffer` fails. + pub(crate) fn abort_acquisition(self) { + debug_assert_eq!(self.export.shares.load(), 1); + self.owns_share.store(false); + self.export.released.store(true); + (self.methods.release)(&self); } - // drop PyBuffer without calling release - // after this function, the owner should use forget() - // or wrap PyBuffer in the ManuallyDrop to prevent drop() - pub(crate) unsafe fn drop_without_release(&mut self) { - // SAFETY: requirements forwarded from caller - unsafe { - core::ptr::drop_in_place(&mut self.obj); - core::ptr::drop_in_place(&mut self.desc); + /// A copy that owns no share: it reads the same memory, but releasing it is + /// inert and it never finalizes the acquisition. A `Py_buffer` whose `obj` is + /// NULL. + #[must_use] + pub fn detached(&self) -> Self { + Self { + obj: self.obj.clone(), + desc: self.desc.clone(), + methods: self.methods, + export: self.export.clone(), + owns_share: AtomicCell::new(false), } } } -impl<'a> TryFromBorrowedObject<'a> for PyBuffer { - fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { +impl PyBuffer { + /// Acquire a buffer from `obj`. PyObject_GetBuffer + pub fn from_object(vm: &VirtualMachine, obj: &PyObject, flags: BufferFlags) -> PyResult { + if flags.is_memory_access_mode() { + return Err(vm.new_system_error("bad argument to internal function")); + } let cls = obj.class(); - if let Some(f) = cls.slots.as_buffer { - return f(obj, vm); + if let Some(f) = cls.slots.as_buffer.load() { + return f(obj, flags, vm); } Err(vm.new_type_error(format!( "a bytes-like object is required, not '{}'", @@ -161,6 +360,26 @@ impl<'a> TryFromBorrowedObject<'a> for PyBuffer { } } +impl PyObject { + /// Whether this object's type exports the buffer protocol. PyObject_CheckBuffer + /// + /// A consumer that falls back to something else for non-buffer objects asks + /// this instead of attempting an acquisition, so that an error raised by + /// `__buffer__` is not mistaken for "not a buffer". + #[must_use] + pub fn check_buffer(&self) -> bool { + self.class().slots.as_buffer.load().is_some() + } +} + +/// The request a conversion makes when the consumer has no say in it: describe +/// the export as fully as possible, read-only. +impl<'a> TryFromBorrowedObject<'a> for PyBuffer { + fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { + Self::from_object(vm, obj, BufferFlags::FULL_RO) + } +} + impl Drop for PyBuffer { fn drop(&mut self) { self.release(); @@ -172,10 +391,20 @@ pub struct BufferDescriptor { /// product(shape) * itemsize /// bytes length, but not the length for obj_bytes() even is contiguous pub len: usize, + /// Byte position of the element at index `[0, .., 0]` within + /// [`PyBuffer::obj_bytes`], which always yields the exporter's whole memory. + /// `Py_buffer.buf` + /// + /// A view that walks backwards begins at the far end of its data, so this is + /// where addressing starts rather than a lower bound. A view with no elements + /// addresses nothing and may sit outside the exporter altogether, which is why + /// this is signed. + pub offset: isize, pub readonly: bool, pub itemsize: usize, pub format: Cow<'static, str>, - /// (shape, stride, suboffset) for each dimension + /// (shape, stride, suboffset) for each dimension. A non-zero suboffset means + /// the dimension is reached through a pointer; slicing never introduces one. pub dim_desc: Vec<(usize, isize, isize)>, // TODO: flags } @@ -185,6 +414,7 @@ impl BufferDescriptor { pub fn simple(bytes_len: usize, readonly: bool) -> Self { Self { len: bytes_len, + offset: 0, readonly, itemsize: 1, format: Cow::Borrowed("B"), @@ -201,6 +431,7 @@ impl BufferDescriptor { ) -> Self { Self { len: bytes_len, + offset: 0, readonly, itemsize, format, @@ -208,9 +439,48 @@ impl BufferDescriptor { } } + /// The descriptor an exporter hands to a consumer that asked for `flags`: + /// everything the request did not ask for is dropped. + /// + /// A `Py_buffer` drops a field by setting it to NULL and leaves the consumer to + /// reconstruct it. A descriptor has no NULL, so a dropped field is filled in + /// here with what that reconstruction would produce: `"B"` for a format, C-order + /// strides for strides, and a single dimension of `len / itemsize` items for a + /// shape. `itemsize` is never touched, so `calcsize(format)` and `itemsize` can + /// disagree on a projected descriptor — the format governs an element's width, + /// the item size governs the step — and `product(shape) * itemsize == len` + /// continues to hold. + #[must_use] + pub fn projected(&self, flags: BufferFlags) -> Self { + let mut desc = self.clone(); + if !flags.contains(BufferFlags::FORMAT) { + desc.format = Cow::Borrowed("B"); + } + if !flags.contains(BufferFlags::ND) { + // A request this flat is refused unless the layout is C-contiguous, so + // one dimension addresses the same bytes. + let shape = desc.len.checked_div(desc.itemsize).unwrap_or(0); + desc.dim_desc = vec![(shape, desc.itemsize as isize, 0)]; + } else if !flags.contains(BufferFlags::STRIDES) { + // Shape survives but strides do not, which means C order. + let mut stride = desc.itemsize as isize; + for (shape, dim_stride, suboffset) in desc.dim_desc.iter_mut().rev() { + *dim_stride = stride; + *suboffset = 0; + stride *= *shape as isize; + } + } + desc + } + #[cfg(debug_assertions)] #[must_use] pub fn validate(self) -> Self { + // Only a view with nothing to address is allowed to start outside the + // exporter. + if self.len != 0 { + debug_assert!(self.offset >= 0); + } // ndim=0 is valid for scalar types (e.g., ctypes Structure) if self.ndim() == 0 { // Empty structures (len=0) can have itemsize=0 @@ -239,6 +509,7 @@ impl BufferDescriptor { self.dim_desc.len() } + /// Whether the elements are laid out in row-major order. _IsCContiguous #[must_use] pub fn is_contiguous(&self) -> bool { if self.len == 0 { @@ -254,11 +525,76 @@ impl BufferDescriptor { true } + /// Whether the elements are laid out in column-major order. A view whose + /// dimensions are all but one of length 1 is laid out both ways at once. + /// _IsFortranContiguous + #[must_use] + pub fn is_fortran_contiguous(&self) -> bool { + if self.len == 0 { + return true; + } + let mut sd = self.itemsize; + for (shape, stride, _) in self.dim_desc.iter().copied() { + if shape > 1 && stride != sd as isize { + return false; + } + sd *= shape; + } + true + } + + /// The byte range this view occupies in [`PyBuffer::obj_bytes`], for a + /// contiguous view. + /// + /// A view with no bytes maps to the empty range at zero: its offset is + /// wherever slicing left it and need not be a position that exists. + #[must_use] + pub fn contiguous_range(&self) -> Range { + if self.len == 0 { + return 0..0; + } + debug_assert!(self.offset >= 0); + let start = self.offset as usize; + start..start + self.len + } + + /// The same shape, format and item size, laid out in C order from byte zero. + #[must_use] + pub fn contiguous(&self) -> Self { + let itemsize = self.itemsize; + let mut dim_desc = self.dim_desc.clone(); + if let Some((_, stride, suboffset)) = dim_desc.last_mut() { + *stride = itemsize as isize; + *suboffset = 0; + } + for i in (1..dim_desc.len()).rev() { + dim_desc[i - 1].1 = dim_desc[i].1 * dim_desc[i].0 as isize; + dim_desc[i - 1].2 = 0; + } + Self { + len: self.len, + offset: 0, + readonly: self.readonly, + itemsize: self.itemsize, + format: self.format.clone(), + dim_desc, + } + } + + /// Whether any dimension is reached through a pointer rather than by + /// stepping, the layout `PyBUF_INDIRECT` describes. + #[must_use] + pub fn has_suboffsets(&self) -> bool { + self.dim_desc + .iter() + .any(|(_, _, suboffset)| *suboffset != 0) + } + /// this function do not check the bound /// panic if indices.len() != ndim #[must_use] pub fn fast_position(&self, indices: &[usize]) -> isize { - let mut pos = 0; + let mut pos = self.offset; for (i, (_, stride, suboffset)) in indices .iter() .copied() @@ -271,7 +607,7 @@ impl BufferDescriptor { /// panic if indices.len() != ndim pub fn position(&self, indices: &[isize], vm: &VirtualMachine) -> PyResult { - let mut pos = 0; + let mut pos = self.offset; for (i, (shape, stride, suboffset)) in indices .iter() .copied() @@ -289,14 +625,19 @@ impl BufferDescriptor { where F: FnMut(Range), { + // A view with no bytes reaches nothing, and its offset need not be a + // position that exists, so it yields no segment at all. + if self.len == 0 { + return; + } if self.ndim() == 0 { - f(0..self.itemsize as isize); + f(self.offset..self.offset + self.itemsize as isize); return; } if try_contiguous && self.is_last_dim_contiguous() { - self._for_each_segment::<_, true>(0, 0, &mut f); + self._for_each_segment::<_, true>(self.offset, 0, &mut f); } else { - self._for_each_segment::<_, false>(0, 0, &mut f); + self._for_each_segment::<_, false>(self.offset, 0, &mut f); } } @@ -328,14 +669,24 @@ impl BufferDescriptor { where F: FnMut(Range, Range) -> bool, { - if self.ndim() == 0 { - f(0..self.itemsize as isize, 0..other.itemsize as isize); + if self.len == 0 { return; } - if try_contiguous && self.is_last_dim_contiguous() { - self._zip_eq::<_, true>(other, 0, 0, 0, &mut f); + if self.ndim() == 0 { + f( + self.offset..self.offset + self.itemsize as isize, + other.offset..other.offset + other.itemsize as isize, + ); + return; + } + // last_dim_is_contiguous: the whole-run path walks both sides at once, so + // both have to be laid out that way. + let run_at_once = + try_contiguous && self.is_last_dim_contiguous() && other.is_last_dim_contiguous(); + if run_at_once { + self._zip_eq::<_, true>(other, self.offset, other.offset, 0, &mut f); } else { - self._zip_eq::<_, false>(other, 0, 0, 0, &mut f); + self._zip_eq::<_, false>(other, self.offset, other.offset, 0, &mut f); } } diff --git a/crates/vm/src/protocol/mod.rs b/crates/vm/src/protocol/mod.rs index 411aa4dfa..4061e0645 100644 --- a/crates/vm/src/protocol/mod.rs +++ b/crates/vm/src/protocol/mod.rs @@ -6,7 +6,9 @@ mod number; mod object; mod sequence; -pub use buffer::{BufferDescriptor, BufferMethods, BufferResizeGuard, PyBuffer, VecBuffer}; +pub use buffer::{ + BufferDescriptor, BufferFlags, BufferMethods, BufferResizeGuard, PyBuffer, VecBuffer, +}; pub use callable::PyCallable; pub(crate) use callable::TraceEvent; pub use iter::{PyIter, PyIterIter, PyIterReturn}; diff --git a/crates/vm/src/sliceable.rs b/crates/vm/src/sliceable.rs index b0f4c7808..ef78614ef 100644 --- a/crates/vm/src/sliceable.rs +++ b/crates/vm/src/sliceable.rs @@ -419,6 +419,50 @@ impl SaturatedSlice { (range, self.step, slice_len) } + // PySlice_AdjustIndices, keeping the adjusted start rather than a range. + /// The index the slice begins at, clamped into `0..=len` for a positive step + /// and into `-1..=len-1` for a negative one, together with its length. + /// + /// Unlike [`Self::adjust_indices`] this stays meaningful for an empty slice, + /// where it is still the position a strided view moves to. + #[must_use] + pub fn adjust_indices_start(&self, len: usize) -> (isize, usize) { + let len = len as isize; + let clamp = |i: isize| { + if i < 0 { + let i = i.saturating_add(len); + if i < 0 { + if self.step.is_negative() { -1 } else { 0 } + } else { + i + } + } else if i >= len { + if self.step.is_negative() { + len - 1 + } else { + len + } + } else { + i + } + }; + let start = clamp(self.start); + let stop = clamp(self.stop); + let step = self.step.unsigned_abs(); + let slice_len = if self.step.is_negative() { + if stop < start { + (start - stop - 1) as usize / step + 1 + } else { + 0 + } + } else if start < stop { + (stop - start - 1) as usize / step + 1 + } else { + 0 + }; + (start, slice_len) + } + #[must_use] pub fn iter(&self, len: usize) -> SaturatedSliceIter { SaturatedSliceIter::new(self, len) diff --git a/crates/vm/src/stdlib/_ctypes/array.rs b/crates/vm/src/stdlib/_ctypes/array.rs index c65f9748c..d4674f33b 100644 --- a/crates/vm/src/stdlib/_ctypes/array.rs +++ b/crates/vm/src/stdlib/_ctypes/array.rs @@ -1061,6 +1061,7 @@ impl AsBuffer for PyCArray { dim_desc.reverse(); BufferDescriptor { + offset: 0, len: buffer_len, readonly: false, itemsize, diff --git a/crates/vm/src/stdlib/_ctypes/base.rs b/crates/vm/src/stdlib/_ctypes/base.rs index e86fdbc7a..6067fc61b 100644 --- a/crates/vm/src/stdlib/_ctypes/base.rs +++ b/crates/vm/src/stdlib/_ctypes/base.rs @@ -624,7 +624,9 @@ impl PyCData { // Get buffer pointer - the memory is owned by source let ptr = { - let bytes = buffer.obj_bytes(); + // Contiguity is checked above, so this is the view's own bytes rather + // than the whole exporter's. + let bytes = unsafe { buffer.contiguous_unchecked() }; bytes.as_ptr().wrapping_add(offset) }; diff --git a/crates/vm/src/stdlib/_ctypes/function.rs b/crates/vm/src/stdlib/_ctypes/function.rs index 90b41a4e6..afbe0ae76 100644 --- a/crates/vm/src/stdlib/_ctypes/function.rs +++ b/crates/vm/src/stdlib/_ctypes/function.rs @@ -1655,6 +1655,7 @@ impl AsBuffer for PyCFuncPtr { (Cow::Borrowed(pointer_format()), pointer_size()) }; let desc = BufferDescriptor { + offset: 0, len: itemsize, readonly: false, itemsize, diff --git a/crates/vm/src/stdlib/_ctypes/pointer.rs b/crates/vm/src/stdlib/_ctypes/pointer.rs index a401fde6f..bcc39fd57 100644 --- a/crates/vm/src/stdlib/_ctypes/pointer.rs +++ b/crates/vm/src/stdlib/_ctypes/pointer.rs @@ -712,7 +712,8 @@ impl PyCPointer { } // Try bytes - if let Ok(bytes) = value.try_bytes_like(vm, |b| b.to_vec()) { + if value.check_buffer() { + let bytes = value.try_bytes_like(vm, |b| b.to_vec())?; rustpython_host_env::ctypes::write_value_to_address( addr, size, @@ -776,6 +777,7 @@ impl AsBuffer for PyCPointer { let itemsize = stg_info.size; // Pointer types are scalars with ndim=0, shape=() let desc = BufferDescriptor { + offset: 0, len: itemsize, readonly: false, itemsize, diff --git a/crates/vm/src/stdlib/_ctypes/simple.rs b/crates/vm/src/stdlib/_ctypes/simple.rs index 5577fb8d2..9699cef98 100644 --- a/crates/vm/src/stdlib/_ctypes/simple.rs +++ b/crates/vm/src/stdlib/_ctypes/simple.rs @@ -1283,6 +1283,7 @@ impl AsBuffer for PyCSimple { let itemsize = stg_info.size; // Simple types are scalars with ndim=0, shape=() let desc = BufferDescriptor { + offset: 0, len: itemsize, readonly: false, itemsize, diff --git a/crates/vm/src/stdlib/_ctypes/structure.rs b/crates/vm/src/stdlib/_ctypes/structure.rs index 1632d745d..12ddf8b5d 100644 --- a/crates/vm/src/stdlib/_ctypes/structure.rs +++ b/crates/vm/src/stdlib/_ctypes/structure.rs @@ -822,6 +822,7 @@ impl AsBuffer for PyCStructure { let buf = PyBuffer::new( zelf.to_owned().into(), BufferDescriptor { + offset: 0, len: buffer_len, readonly: false, itemsize: buffer_len, diff --git a/crates/vm/src/stdlib/_ctypes/union.rs b/crates/vm/src/stdlib/_ctypes/union.rs index 8fe2e8348..326e1fbd7 100644 --- a/crates/vm/src/stdlib/_ctypes/union.rs +++ b/crates/vm/src/stdlib/_ctypes/union.rs @@ -685,6 +685,7 @@ impl AsBuffer for PyCUnion { let buf = PyBuffer::new( zelf.to_owned().into(), BufferDescriptor { + offset: 0, len: buffer_len, readonly: false, itemsize: buffer_len, diff --git a/crates/vm/src/stdlib/_imp.rs b/crates/vm/src/stdlib/_imp.rs index 50f0b0be8..838012a1d 100644 --- a/crates/vm/src/stdlib/_imp.rs +++ b/crates/vm/src/stdlib/_imp.rs @@ -178,7 +178,6 @@ mod _imp { use crate::{ PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{PyBytesRef, PyCode, PyMemoryView, PyModule, PyStrRef, PyUtf8StrRef}, - convert::TryFromBorrowedObject, function::OptionalArg, import, version, }; @@ -270,8 +269,14 @@ mod _imp { name.clone().into_wtf8(), ) }; - // A non-buffer is a TypeError, not invalid frozen data. - crate::protocol::PyBuffer::try_from_borrowed_object(vm, &data)?; + // A non-buffer is a TypeError, not invalid frozen data. The request + // is the one marshal.loads() makes, so that what passes here is + // exactly what it accepts. + crate::protocol::PyBuffer::from_object( + vm, + &data, + crate::protocol::BufferFlags::SIMPLE, + )?; // The data is a marshalled code object: a whole marshal value, which // deserialize_code() does not read — it takes the code body alone, // without the type byte the writer puts in front of it. diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 80085a822..f32fce314 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -135,8 +135,8 @@ mod _io { convert::ToPyObject, exceptions::nul_char_error, function::{ - ArgBytesLike, ArgIterable, ArgMemoryBuffer, ArgSize, Either, FsPath, FuncArgs, - IntoFuncArgs, OptionalArg, OptionalOption, PySetterValue, + ArgBytesLike, ArgContiguousBytesLike, ArgIterable, ArgMemoryBuffer, ArgSize, Either, + FsPath, FuncArgs, IntoFuncArgs, OptionalArg, OptionalOption, PySetterValue, }, protocol::{ BufferDescriptor, BufferMethods, BufferResizeGuard, PyBuffer, PyIterReturn, VecBuffer, @@ -4785,8 +4785,12 @@ mod _io { } #[pymethod] - fn write(&self, data: ArgBytesLike, vm: &VirtualMachine) -> PyResult { + fn write(&self, data: ArgContiguousBytesLike, vm: &VirtualMachine) -> PyResult { let mut buffer = self.try_resizable(vm)?; + // Acquiring the buffer can run `__buffer__`, which may have closed us. + if self.closed.load() { + return Err(io_closed_error(vm)); + } data.with_ref(|b| buffer.write(b)) .ok_or_else(|| vm.new_type_error("Error Writing Bytes")) } diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index 168363103..a9f98ca70 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -3,8 +3,8 @@ pub(crate) use _sre::module_def; #[pymodule] mod _sre { use crate::{ - Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromBorrowedObject, - TryFromObject, VirtualMachine, atomic_func, + Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, + atomic_func, builtins::{ PyCallableIterator, PyDictRef, PyGenericAlias, PyInt, PyList, PyListRef, PyStr, PyStrRef, PyTuple, PyTupleRef, PyTypeRef, @@ -13,7 +13,7 @@ mod _sre { common::{ascii, hash::PyHash}, convert::ToPyObject, function::{ArgCallable, OptionalArg, PosArgs, PyComparisonValue}, - protocol::{PyBuffer, PyCallable, PyMappingMethods}, + protocol::{BufferFlags, PyBuffer, PyCallable, PyMappingMethods}, stdlib::sys, types::{AsMapping, Comparable, Hashable, Representable}, }; @@ -386,7 +386,7 @@ mod _sre { where F: FnOnce(&[u8]) -> PyResult, { - PyBuffer::try_from_borrowed_object(vm, string)?.contiguous_or_collect(f) + PyBuffer::from_object(vm, string, BufferFlags::SIMPLE)?.contiguous_or_collect(f) } #[pymethod(name = "match")] diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 95feea656..19e33110a 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -21,9 +21,9 @@ mod builtins { bytecode, common::hash::PyHash, function::{ - ArgBytesLike, ArgCallable, ArgIndex, ArgIntoBool, ArgIterable, ArgMapping, - ArgPrimitiveIndex, ArgStrOrBytesLike, Either, FsPath, FuncArgs, KwArgs, OptionalArg, - OptionalOption, PosArgs, + ArgCallable, ArgIndex, ArgIntoBool, ArgIterable, ArgMapping, ArgPrimitiveIndex, + ArgStrOrBytesLike, Either, FsPath, FuncArgs, KwArgs, OptionalArg, OptionalOption, + PosArgs, }, protocol::{PyIter, PyIterReturn}, py_io, @@ -997,18 +997,10 @@ mod builtins { } #[pyfunction] - fn ord(string: Either, vm: &VirtualMachine) -> PyResult { - match string { - Either::A(bytes) => bytes.with_ref(|bytes| { - let bytes_len = bytes.len(); - if bytes_len != 1 { - return Err(vm.new_type_error(format!( - "ord() expected a character, but string of length {bytes_len} found" - ))); - } - Ok(u32::from(bytes[0])) - }), - Either::B(string) => match string.as_wtf8().code_points().exactly_one() { + // builtin_ord + fn ord(c: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let bytes = if let Some(string) = c.downcast_ref::() { + return match string.as_wtf8().code_points().exactly_one() { Ok(character) => Ok(character.to_u32()), Err(_) => { let string_len = string.char_len(); @@ -1016,8 +1008,24 @@ mod builtins { "ord() expected a character, but string of length {string_len} found" ))) } - }, + }; + } else if let Some(bytes) = c.downcast_ref::() { + bytes.as_bytes().to_vec() + } else if let Some(bytearray) = c.downcast_ref::() { + bytearray.borrow_buf().to_vec() + } else { + return Err(vm.new_type_error(format!( + "ord() expected string of length 1, but {} found", + c.class().name() + ))); + }; + let bytes_len = bytes.len(); + if bytes_len != 1 { + return Err(vm.new_type_error(format!( + "ord() expected a character, but string of length {bytes_len} found" + ))); } + Ok(u32::from(bytes[0])) } #[derive(FromArgs)] diff --git a/crates/vm/src/stdlib/marshal.rs b/crates/vm/src/stdlib/marshal.rs index 38891200b..ca92b444a 100644 --- a/crates/vm/src/stdlib/marshal.rs +++ b/crates/vm/src/stdlib/marshal.rs @@ -16,7 +16,6 @@ mod decl { convert::ToPyObject, function::{ArgBytesLike, OptionalArg}, object::{AsObject, PyPayload}, - protocol::PyBuffer, }; use core::cell::RefCell; use malachite_bigint::BigInt; @@ -651,20 +650,16 @@ mod decl { #[derive(FromArgs)] struct LoadsArgs { #[pyarg(any)] - data: PyBuffer, + // marshal_loads_impl takes `bytes: Py_buffer`, a y* argument. + data: ArgBytesLike, #[pyarg(named, default = true)] allow_code: bool, } #[pyfunction] fn loads(args: LoadsArgs, vm: &VirtualMachine) -> PyResult { - let LoadsArgs { - data: pybuffer, - allow_code, - } = args; - let buf = pybuffer.as_contiguous().ok_or_else(|| { - vm.new_buffer_error("Buffer provided to marshal.loads() is not contiguous") - })?; + let LoadsArgs { data, allow_code } = args; + let buf = data.borrow_buf(); let result = deserialize_value(&mut &buf[..], vm)?; if !allow_code { diff --git a/crates/vm/src/stdlib/winsound.rs b/crates/vm/src/stdlib/winsound.rs index 95032ad89..091a3f801 100644 --- a/crates/vm/src/stdlib/winsound.rs +++ b/crates/vm/src/stdlib/winsound.rs @@ -6,10 +6,10 @@ pub(crate) use winsound::module_def; #[pymodule] mod winsound { use crate::builtins::{PyBaseExceptionRef, PyBytes, PyStr}; - use crate::convert::{IntoPyException, ToPyException, TryFromBorrowedObject}; + use crate::convert::{IntoPyException, ToPyException}; use crate::exceptions; use crate::host_env::windows::ToWideString; - use crate::protocol::PyBuffer; + use crate::protocol::{BufferFlags, PyBuffer}; use crate::{AsObject, PyObjectRef, PyResult, VirtualMachine}; use rustpython_host_env::winsound::{PlaySoundError, PlaySoundSource, play_sound}; @@ -90,7 +90,7 @@ mod winsound { } if flags & SND_MEMORY != 0 { - let buffer = PyBuffer::try_from_borrowed_object(vm, &sound)?; + let buffer = PyBuffer::from_object(vm, &sound, BufferFlags::SIMPLE)?; let buf = buffer .as_contiguous() .ok_or_else(|| vm.new_type_error("a bytes-like object is required, not 'str'"))?; diff --git a/crates/vm/src/types/slot.rs b/crates/vm/src/types/slot.rs index c039e6b5b..fc9d1b048 100644 --- a/crates/vm/src/types/slot.rs +++ b/crates/vm/src/types/slot.rs @@ -9,7 +9,7 @@ use crate::{ convert::ToPyObject, function::{Either, FromArgs, FuncArgs, PyComparisonValue, PyMethodDef, PySetterValue}, protocol::{ - PyBuffer, PyIterReturn, PyMapping, PyMappingMethods, PyMappingSlots, PyNumber, + BufferFlags, PyBuffer, PyIterReturn, PyMapping, PyMappingMethods, PyMappingSlots, PyNumber, PyNumberMethods, PyNumberSlots, PySequence, PySequenceMethods, PySequenceSlots, }, types::slot_defs::{SlotAccessor, find_slot_defs_by_name}, @@ -149,7 +149,12 @@ pub struct PyTypeSlots { pub setattro: AtomicCell>, // Functions to access object as input/output buffer - pub as_buffer: Option, + pub as_buffer: AtomicCell>, + /// bf_releasebuffer: releasing an export of this type is observable, so the + /// type exposes `__release_buffer__`. + pub has_release_buffer: AtomicCell, + /// True when a Python-level `__release_buffer__` must be invoked on release. + pub python_release_buffer: AtomicCell, // Assigned meaning in release 2.1 // rich comparisons @@ -296,7 +301,8 @@ pub(crate) type StringifyFunc = fn(&PyObject, &VirtualMachine) -> PyResult, &VirtualMachine) -> PyResult; pub(crate) type SetattroFunc = fn(&PyObject, &Py, PySetterValue, &VirtualMachine) -> PyResult<()>; -pub(crate) type AsBufferFunc = fn(&PyObject, &VirtualMachine) -> PyResult; +/// bf_getbuffer +pub(crate) type AsBufferFunc = fn(&PyObject, BufferFlags, &VirtualMachine) -> PyResult; pub(crate) type RichCompareFunc = fn( &PyObject, &PyObject, @@ -329,6 +335,15 @@ pub(crate) type MapSubscriptFunc = fn(PyMapping<'_>, &PyObject, &VirtualMachine) pub(crate) type MapAssSubscriptFunc = fn(PyMapping<'_>, &PyObject, Option, &VirtualMachine) -> PyResult<()>; +// slot_bf_getbuffer +pub(crate) fn python_as_buffer( + obj: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, +) -> PyResult { + crate::builtins::memory::buffer_from_python_getbuffer(obj, flags, vm) +} + // slot_sq_length pub(crate) fn len_wrapper(obj: &PyObject, vm: &VirtualMachine) -> PyResult { let ret = vm.call_special_method(obj, identifier!(vm, __len__), ())?; @@ -1579,6 +1594,58 @@ impl PyType { } } + // === Buffer protocol === + SlotAccessor::BfGetBuffer => { + if ADD { + match self.lookup_slot_in_mro(name, ctx, |sf| { + if let SlotFunc::GetBuffer(f) = sf { + Some(*f) + } else { + None + } + }) { + SlotLookupResult::NativeSlot(func) => { + self.slots.as_buffer.store(Some(func)); + } + SlotLookupResult::PythonMethod => { + self.slots.as_buffer.store(Some(python_as_buffer)); + } + SlotLookupResult::NotFound => { + accessor.inherit_from_mro(self); + } + } + } else { + accessor.inherit_from_mro(self); + } + } + SlotAccessor::BfReleaseBuffer => { + // Which of the two implementations `__release_buffer__` resolves to + // decides whether buffer release has to call back into Python. + if ADD { + match self.lookup_slot_in_mro(name, ctx, |sf| { + if matches!(sf, SlotFunc::ReleaseBuffer) { + Some(()) + } else { + None + } + }) { + SlotLookupResult::NativeSlot(()) => { + self.slots.python_release_buffer.store(false); + self.slots.has_release_buffer.store(true); + } + SlotLookupResult::PythonMethod => { + self.slots.python_release_buffer.store(true); + self.slots.has_release_buffer.store(true); + } + SlotLookupResult::NotFound => { + accessor.inherit_from_mro(self); + } + } + } else { + accessor.inherit_from_mro(self); + } + } + // Reserved slots - no-op _ => {} } @@ -2070,14 +2137,29 @@ pub trait SetAttr: PyPayload { #[pyclass] pub trait AsBuffer: PyPayload { - // TODO: `flags` parameter + /// bf_releasebuffer: set when releasing an export of this type is observable, + /// i.e. the exporter counts exports. Such types expose `__release_buffer__`. + const RELEASE_BUFFER: bool = false; + #[inline] #[pyslot] - fn slot_as_buffer(zelf: &PyObject, vm: &VirtualMachine) -> PyResult { + fn slot_as_buffer( + zelf: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { let zelf = zelf .downcast_ref() .ok_or_else(|| vm.new_type_error("unexpected payload for as_buffer"))?; - Self::as_buffer(zelf, vm) + let buffer = Self::as_buffer(zelf, vm)?; + if let Err(exc) = flags.check_writable(buffer.desc.readonly, "Object is not writable.", vm) + { + // An acquisition that cannot be served never happened, so the + // exporter's release is undone without running the Python hook. + buffer.abort_acquisition(); + return Err(exc); + } + Ok(buffer) } fn as_buffer(zelf: &Py, vm: &VirtualMachine) -> PyResult; diff --git a/crates/vm/src/types/slot_defs.rs b/crates/vm/src/types/slot_defs.rs index 69c7bb610..300ee3199 100644 --- a/crates/vm/src/types/slot_defs.rs +++ b/crates/vm/src/types/slot_defs.rs @@ -71,7 +71,7 @@ pub struct SlotDef { #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub enum SlotAccessor { - // Buffer protocol (1-2) - Reserved, not used in RustPython + // Buffer protocol (1-2) BfGetBuffer = 1, BfReleaseBuffer = 2, @@ -173,9 +173,7 @@ impl SlotAccessor { pub fn is_reserved(&self) -> bool { matches!( self, - Self::BfGetBuffer - | Self::BfReleaseBuffer - | Self::TpAlloc + Self::TpAlloc | Self::TpBase | Self::TpBases | Self::TpClear @@ -411,6 +409,10 @@ impl SlotAccessor { ) } + // Buffer protocol + Self::BfGetBuffer => matches!(slot_func, SlotFunc::GetBuffer(_)), + Self::BfReleaseBuffer => matches!(slot_func, SlotFunc::ReleaseBuffer), + // New and reserved slots Self::TpNew => false, _ => false, // Reserved slots @@ -539,6 +541,18 @@ impl SlotAccessor { Self::MpSubscript => inherit_mapping!(subscript), Self::MpAssSubscript => inherit_mapping!(ass_subscript), + // Buffer protocol + Self::BfGetBuffer => { + let inherited = mro.iter().find_map(|cls| cls.slots.as_buffer.load()); + typ.slots.as_buffer.store(inherited); + } + Self::BfReleaseBuffer => { + let has_release = mro.iter().any(|cls| cls.slots.has_release_buffer.load()); + typ.slots.has_release_buffer.store(has_release); + let py_release = mro.iter().any(|cls| cls.slots.python_release_buffer.load()); + typ.slots.python_release_buffer.store(py_release); + } + // Reserved slots - no-op _ => {} } @@ -677,6 +691,25 @@ impl SlotAccessor { Self::MpSubscript => copy_mapping!(subscript), Self::MpAssSubscript => copy_mapping!(ass_subscript), + // Buffer protocol + Self::BfGetBuffer => { + if typ.slots.as_buffer.load().is_none() + && let Some(base_val) = base.slots.as_buffer.load() + { + typ.slots.as_buffer.store(Some(base_val)); + } + } + Self::BfReleaseBuffer => { + if !typ.slots.has_release_buffer.load() && base.slots.has_release_buffer.load() { + typ.slots.has_release_buffer.store(true); + } + if !typ.slots.python_release_buffer.load() + && base.slots.python_release_buffer.load() + { + typ.slots.python_release_buffer.store(true); + } + } + // Reserved slots - no-op _ => {} } @@ -816,6 +849,16 @@ impl SlotAccessor { .load() .map(SlotFunc::MapSetSubscript), + // Buffer protocol + Self::BfGetBuffer => slots.as_buffer.load().map(SlotFunc::GetBuffer), + Self::BfReleaseBuffer => { + if slots.has_release_buffer.load() || slots.python_release_buffer.load() { + Some(SlotFunc::ReleaseBuffer) + } else { + None + } + } + // Reserved slots _ => None, } @@ -973,6 +1016,19 @@ pub const SLOT_DEFS_COUNT: usize = SLOT_DEFS.len(); /// All slot definitions pub static SLOT_DEFS: &[SlotDef] = &[ + // Buffer protocol (bf_*) + SlotDef { + name: "__buffer__", + accessor: SlotAccessor::BfGetBuffer, + op: None, + doc: "Return a buffer object that exposes the underlying memory of the object.", + }, + SlotDef { + name: "__release_buffer__", + accessor: SlotAccessor::BfReleaseBuffer, + op: None, + doc: "Release the buffer object that exposes the underlying memory of the object.", + }, // Type slots (tp_*) SlotDef { name: "__init__", diff --git a/crates/vm/src/vm/context.rs b/crates/vm/src/vm/context.rs index 71d017c6d..5deaffb3f 100644 --- a/crates/vm/src/vm/context.rs +++ b/crates/vm/src/vm/context.rs @@ -103,6 +103,7 @@ declare_const_name! { __await__, __bases__, __bool__, + __buffer__, __build_class__, __builtins__, __bytes__, @@ -205,6 +206,7 @@ declare_const_name! { __rdivmod__, __reduce__, __reduce_ex__, + __release_buffer__, __repr__, __reversed__, __rfloordiv__, diff --git a/extra_tests/snippets/builtin_memoryview.py b/extra_tests/snippets/builtin_memoryview.py index f206056eb..8a3a194d9 100644 --- a/extra_tests/snippets/builtin_memoryview.py +++ b/extra_tests/snippets/builtin_memoryview.py @@ -90,3 +90,427 @@ def test_delitem(): test_delitem() + + +def test_empty_view_offset(): + # An empty view keeps the offset slicing left it, which can sit outside the + # exporter, and reaches no byte through it. + ba = bytearray(range(17)) + assert bytes(memoryview(ba)[::-9][-30::-9]) == b"" + assert bytes(memoryview(ba)[-30::-1]) == b"" + v = memoryview(ba)[::-9][-30::-9] + assert v.shape == (0,) + assert v.strides == (81,) + assert v.suboffsets == () + b24 = bytearray(range(24)) + assert bytes(memoryview(b24).cast("B", [4, 6])[-30::-1]) == b"" + + +test_empty_view_offset() + + +def test_exported_suboffsets(): + mv = memoryview(bytearray(b"abcdef"))[::-1] + exported = mv.__buffer__(284) + assert exported.suboffsets == () + assert bytes(exported) == b"fedcba" + assert ( + bytes(memoryview(memoryview(bytearray(b"abcdefg"))[::2].__buffer__(284))) + == b"aceg" + ) + + +test_exported_suboffsets() + + +def test_setitem_slice_strided_source(): + src = bytearray(b"abcdef") + dst = bytearray(b"......") + memoryview(dst)[:] = memoryview(src)[::-1] + assert bytes(dst) == b"fedcba" + dst = bytearray(b"...") + memoryview(dst)[:] = memoryview(src)[::2] + assert bytes(dst) == b"ace" + + +test_setitem_slice_strided_source() + + +def test_zero_dim_position(): + z = memoryview(bytearray(range(8)))[4:5].cast("B", []) + assert z[()] == 4 + assert z.tolist() == 4 + w = bytearray(range(8)) + memoryview(w)[4:5].cast("B", [])[()] = 99 + assert w[4] == 99 + assert w[0] == 0 + + +test_zero_dim_position() + + +def test_cast_zero_dim_size(): + assert_raises(TypeError, lambda: memoryview(bytearray(range(8))).cast("B", [])) + assert memoryview(bytearray(b"a")).cast("B", []).nbytes == 1 + + +test_cast_zero_dim_size() + + +def test_hash_format(): + assert_raises(ValueError, lambda: hash(memoryview(b"abcd").cast("I"))) + hash(memoryview(b"abcd").cast("b")) + hash(memoryview(b"abcdef")[::2]) + hash(memoryview(b"a").cast("B", [])) + + +test_hash_format() + + +def test_cast_keeps_exports(): + ba = bytearray(b"abc") + mv = memoryview(ba) + cast = mv.cast("B") + mv.release() + assert_raises(BufferError, lambda: ba.clear()) + cast.release() + ba.clear() + assert bytes(ba) == b"" + + +test_cast_keeps_exports() + + +def test_setitem_converts_before_writing(): + ba = bytearray(b"abc") + mv = memoryview(ba) + + class Idx: + def __index__(self): + return len(bytes(ba)) + + mv[0] = Idx() + assert bytes(ba) == b"\x03bc" + + +test_setitem_converts_before_writing() + + +def test_pep688_exporter_aliasing(): + def exporter(view_factory): + class C: + def __buffer__(self, flags): + return view_factory() + + def __release_buffer__(self, view): + pass + + return C() + + ba = bytearray(b"abc") + memoryview(ba)[:] = exporter(lambda: memoryview(ba)) + assert bytes(ba) == b"abc" + + ba = bytearray(b"abcdef") + memoryview(ba)[0:3] = exporter(lambda: memoryview(ba)[3:6]) + assert bytes(ba) == b"defdef" + + ba = bytearray(b"abcdef") + memoryview(ba)[3:6] = exporter(lambda: memoryview(ba)[0:3]) + assert bytes(ba) == b"abcabc" + + ba = bytearray(b"abcdef") + memoryview(ba)[:] = exporter(lambda: memoryview(ba)[::-1]) + assert bytes(ba) == b"fedcba" + + ba = bytearray(b"abcdef") + memoryview(ba)[::2] = exporter(lambda: memoryview(ba)[0:3]) + assert bytes(ba) == b"abbdcf" + + ba = bytearray(b"abcdef") + mv = memoryview(exporter(lambda: memoryview(ba))) + mv[:] = exporter(lambda: memoryview(ba)) + assert bytes(ba) == b"abcdef" + mv[:] = ba + assert bytes(ba) == b"abcdef" + + +test_pep688_exporter_aliasing() + + +def test_release_buffer_waits_for_last_view(): + class C(bytearray): + calls = 0 + + def __release_buffer__(self, view): + type(self).calls += 1 + super().__release_buffer__(view) + + c = C(b"abcdef") + a = memoryview(c) + b = memoryview(a) + a.release() + assert C.calls == 0 + assert b.tobytes() == b"abcdef" + b.release() + assert C.calls == 1 + + class D: + n = 0 + + def __init__(self): + self.b = bytearray(b"abcdef") + + def __buffer__(self, flags): + return memoryview(self.b) + + def __release_buffer__(self, view): + type(self).n += 1 + + d = D() + m = memoryview(d) + m2 = memoryview(m) + m3 = m.cast("B") + m.release() + m2.release() + assert D.n == 0 + m3.release() + assert D.n == 1 + + # Two acquisitions are two exports, each released on its own. + D.n = 0 + d = D() + a1 = memoryview(d) + a2 = memoryview(d) + a1.release() + assert D.n == 1 + a2.release() + assert D.n == 2 + + +test_release_buffer_waits_for_last_view() + + +def test_failed_request_does_not_release(): + import inspect + import mmap + + class M(mmap.mmap): + calls = 0 + + def __release_buffer__(self, view): + type(self).calls += 1 + super().__release_buffer__(view) + + m = M(-1, 10, access=mmap.ACCESS_READ) + assert_raises(BufferError, lambda: m.__buffer__(inspect.BufferFlags.WRITABLE)) + assert M.calls == 0 + + +test_failed_request_does_not_release() + + +def test_request_shapes_exported_descriptor(): + import array + + a = array.array("I", [1, 2, 3]) + assert a.__buffer__(0).format == "B" + assert a.__buffer__(28).format == "I" + + m = memoryview(a) + b = m.__buffer__(0) + assert (b.format, b.itemsize, b.ndim, b.shape, b.strides) == ("B", 4, 1, (3,), (4,)) + assert m.__buffer__(28).format == "I" + + b = a.__buffer__(0) + assert b[0] == 1 + assert b.tolist() == [1, 2, 3] + assert len(b.tobytes()) == 12 + b[0] = 9 + assert a[0] == 9 + + n = memoryview(bytearray(b"abcdef" * 4)).cast("I", (2, 3)) + assert n.__buffer__(0).ndim == 1 + assert n.__buffer__(0).shape == (6,) + assert n.__buffer__(8).ndim == 2 + assert n.__buffer__(8).format == "B" + + +test_request_shapes_exported_descriptor() + + +def test_release_during_index_conversion(): + # CHECK_RELEASED_AGAIN: the conversion that produces the value, and the one + # that produced the index, both run Python that can release the view. + ba = bytearray(b"abcdefgh") + mv = memoryview(ba) + + class Writer: + def __index__(self): + mv.release() + ba.clear() + return 7 + + try: + mv[7] = Writer() + raise AssertionError("write into a released view") + except ValueError as e: + assert "released memoryview" in str(e), e + + ba = bytearray(b"abcdefgh") + mv = memoryview(ba) + + class Reader: + def __index__(self): + mv.release() + ba.clear() + return 7 + + try: + mv[Reader()] + raise AssertionError("read from a released view") + except ValueError as e: + assert "released memoryview" in str(e), e + + # A release that does not resize still forbids the write. + ba = bytearray(b"abcd") + mv = memoryview(ba) + + class Quiet: + def __index__(self): + mv.release() + return 65 + + try: + mv[0] = Quiet() + raise AssertionError("write into a released view") + except ValueError as e: + assert "released memoryview" in str(e), e + assert bytes(ba) == b"abcd" + + +test_release_during_index_conversion() + + +def test_cast_rejects_non_native_format(): + # get_native_fmtchar + for fmt in ["", "ii", " Date: Mon, 17 Aug 2026 00:33:27 +0900 Subject: [PATCH 87/94] Fix crashes found hunting the last open fuzzing record (#8524) * specialize: check the member descriptor's type before caching its slot offset The LOAD_ATTR/STORE_ATTR specializations cached the slot offset of any member descriptor found on the owner's type and then guarded the specialized instruction on the type version alone, while descr_get()/descr_set() check on every access that the instance belongs to the type the descriptor was defined for. A descriptor taken from a wider class and bound to a narrower one read past the instance's slot array once the cache warmed up: class Big: __slots__ = ("a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7") class Narrow: __slots__ = ("z",) Narrow.x = Big.__dict__["a7"] o = Narrow() for _ in range(1000): try: o.x except TypeError: pass # index out of bounds: the len is 1 but the index is 7 (object/core.rs) A class with no slots at all reached the ext_ref().unwrap() on the same line. Assisted-by: Claude * socket: reserve recv()'s buffer fallibly recv() and recvfrom() handed the caller's bufsize straight to Vec::with_capacity, so an unreachable size aborted the process through handle_alloc_error before any syscall was made: socket.socket().recv(2**62) # memory allocation of 4611686018427387904 bytes failed -> SIGABRT try_reserve_exact reports MemoryError instead, which is what CPython raises. Assisted-by: Claude * types: count the __call__ and __get__ slot dispatches as recursion Both wrappers re-enter Python without pushing a frame, so nothing counted the nesting when the special method named the object it was looked up on: class C: pass c = C(); C.__call__ = c c() # native stack overflow, SIGSEGV class D: pass d = D(); D.__get__ = d; D.x = d d.x # the same, through descr_get with_recursion around the two dispatches raises RecursionError instead, the way Py_EnterRecursiveCall bounds a tp_call dispatch. It costs about 5% on a __call__ dispatch and 3% on a __get__ dispatch through these wrappers. Assisted-by: Claude * typevar: show a ParamSpecArgs origin by its repr ParamSpecArgs and ParamSpecKwargs fell back to a Rust `{:?}` of __origin__ when it had no __name__. That walks the object graph natively through Debug for PyInner, where no recursion guard sits, so a single repr() of a deeply nested chain overflowed the native stack: a = object() for _ in range(30000): a = typing.ParamSpecArgs(a) repr(a) # SIGSEGV The origin is formatted with its repr now, which is guarded, and a ParamSpec origin is recognized by its type rather than by carrying a __name__. Assisted-by: Claude * Do not hold a lock across a call back into Python Three places kept a lock while running code that can reach the same object, so a callback that touched it wedged the process: _asyncio.future_add_to_awaited_by(fut, waiter) # waiter.__hash__ adds again select.select(elements, [], [], 0) # fileno() clears `elements` select.poll().poll(1000) # SIGALRM handler registers The future's awaited-by field is read and written under its lock but the set is built outside it, the list extraction re-reads the list on each step the way map_iterable_object() does, and poll() waits on a copy of its descriptors. All three ran forever before and now finish the way they do on CPython. Assisted-by: Claude * Validate memoryview.cast() arguments and export negative strides correctly cast() accepted any struct format and any shape element. A zero-size format ('0s') and a 0 in the shape both reached a division by zero; cast() now takes only a native single character format, optionally '@'-prefixed, and shape elements that are ints greater than zero. A view with a negative stride starts at its last item, so the bytes it exported began there and its own offsets walked off the front of them. Such a view now exports the whole underlying buffer with `start` folded into the descriptor's offsets, and zip_eq() hands over a whole run only when both sides are contiguous in the last dimension. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Charge native recursion to the stack, not to the frame limit with_recursion() checked the limit sys.setrecursionlimit() sets and incremented the same counter that pushing a frame does, so a guard on a native dispatch spent what Python code had left to call with, and did so where sys._getframe() cannot see it: test.support.get_recursion_available() reported frames that were no longer there. Py_EnterRecursiveCall bounds the native stack instead, which is a separate budget, and the C stack check with_recursion already performs is that bound. The snippets pinning the guarded paths nest deep enough to reach the stack rather than the frame limit. Assisted-by: Claude * Report a size that cannot be allocated instead of aborting on it A size taken from Python went straight into an infallible allocation in several places, so the process aborted through handle_alloc_error before any exception could be raised: - str/bytes/bytearray center(), ljust(), rjust() and zfill() reserved the padded result for the caller's width - expandtabs() built its runs of spaces from a tabsize of any width; the argument is a C int, and a wider one does not fit - Buffered{Reader,Writer,Random} allocated buffer_size, and read(), read1() and FileIO.read() their read size - bytes(n) and bytearray(n) allocated n - pbkdf2_hmac() allocated the derived key length, which is a C int Each of these now reports MemoryError, or OverflowError where the argument does not fit the type it is declared with. new_zeroed_bytes() leaves the zeroing to the allocator, so a large request costs the pages that are written to rather than all of them. Assisted-by: Claude * marshal: answer allow_code where a code object is written or read allow_code was answered by walking the whole result a second time, with no depth counter and no record of what it had already seen, so a value that referred back to itself or nested deeply enough ran off the native stack. w_object() and r_object() answer it where the code object is, inside the walk that already bounds its depth and resolves references. A container length is read the way r_long() reads one: it is signed, so a length with the top bit set is out of range rather than four billion items to reserve room for. load() no longer holds a borrow of the buffer read() returned across the seek() it makes afterwards. Assisted-by: Claude * Do not lock an object while running code that can reach it Several places held a lock or a borrow of an object across a call back into Python, so a callback that touched the same object waited on a lock its own caller was holding: - memoryview slice assignment read a source overlapping the destination, and __setitem__ converted the value while holding the write borrow - BytesIO.readinto() read into a buffer viewing the same BytesIO - array.__setitem__ converted the value under the array's write lock, and mmap.write() read a source viewing the same map - bytearray.join() and bytearray.__mod__ drove Python with the bytearray borrowed - array and bytearray answered "is this resizable" after taking the write lock, though an export is exactly a borrow someone else holds A TextIOWrapper cookie now has to name a position inside what was decoded in characters as well as in bytes; only the byte offset was checked, and the character count is what read() and tell() index with. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Stop asserting a pbkdf2 message that depends on the width of a C long The snippet asserted "key length is too great.", which pbkdf2_hmac() only reaches once the length has been converted; where a C long is narrower than the length asked for, the conversion fails first and says so instead. Both are OverflowError, which is what the case is about. test_support.test_get_recursion_depth passes now that a native recursion guard no longer spends frames get_recursion_depth() cannot see. Assisted-by: Claude * Publish and read the same pointer for a thread's top frame set_current_frame() casts the `Py` it publishes straight to `*mut FrameObject`, so ThreadSlot::top_frame holds the object's base. sys._current_frames() read it back through Py::from_payload_ptr(), which subtracts the payload offset from what it is given. The reference it took therefore incremented, and later decremented, a word 48 bytes ahead of the frame -- inside the object allocated before it, whose OnceLock state word sits exactly there for two frames adjacent in the size class. The neighbour then read an initialized-looking cold pointer that had never been written and locked whatever the uninitialized word addressed, so the thread that owned it crashed rather than the one that read. The slot now holds `*mut Py`, which is what both sides mean. A thread parked in a call has no FrameObject for its topmost frame, so top_frame is null there and the reader takes the materialize path instead: test_sys.test_current_frames never reaches the branch. The snippet takes _current_frames() against threads that are running. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Decide stop-the-world parking under the thread registry lock do_suspend() published SUSPENDED first and only then re-read `requested`, restoring itself to ATTACHED if the stop had ended in the meantime. That made a thread the second writer able to leave SUSPENDED, so a stop whose completion check had already observed the thread parked could be undone behind the requester's back: worker CAS ATTACHED -> SUSPENDED requester all_non_requester_suspended() -> true, world_stopped = true requester start_the_world(): requested = false, then walks the registry worker reads requested == false, stores ATTACHED With the store landing inside that walk the debug assertion in start_the_world fires; with the walk already past the slot, a following stop force-parks the thread DETACHED -> SUSPENDED, counts it as stopped, and the store then puts it back to ATTACHED with the world declared stopped and the thread running bytecode. `requested` is set in init_thread_countdown() and cleared in start_the_world() with the registry held, and start_the_world() keeps holding it while releasing every SUSPENDED thread. Taking the registry around the check and the transition therefore makes the two orders the only ones possible: park before that release pass and be woken by it, or find the request already withdrawn and stay ATTACHED. The requester is left as the only writer that takes a thread out of SUSPENDED, and the self-restore is gone. suspend_if_needed() takes the VirtualMachine to reach the registry. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Keep an atexit callback alive while it is being compared atexit.unregister() releases the callback list around each __eq__ call and identified the entry it had compared by the address of its Box. __eq__ can call atexit._clear(), which drops that Box, and atexit.register(), whose new Box lands on the freed allocation; the identity search then matched the freshly registered callback and removed it. atexit.register(a); atexit.register(b); atexit.register(c) # __eq__ runs _clear() then register(d), returns True atexit.unregister(probe) left no callbacks registered where CPython leaves d. Entries are Arc-shared now, so unregister() holds the one it is comparing and matches it with Arc::ptr_eq: an address cannot be reused while the comparison that named it is still running. Assisted-by: Claude * Hold atexit entries in PyRc rather than Arc PyObjectRef is Send and Sync only under the threading feature, so an Arc over a callback entry trips clippy::arc_with_non_send_sync in builds without it, such as the wasm package. PyRc is Arc there and Rc otherwise. Assisted-by: Claude * Do not hold a buffer's storage while waiting for a peer FileIO.readinto() and socket.recv_into()/recvfrom_into() took the target buffer's write borrow and kept it for the whole call, including the wait for data a pipe, socket or terminal may never deliver. What CPython holds across that wait is the export, which only forbids resizing; the borrow is a lock every other thread touching the same object waits on, so threading.Thread(target=lambda: sock.recv_into(buf)).start() len(buf) did not answer until the peer sent. A thread parked on that lock is ATTACHED and never reaches a safepoint, so gc.collect() in a third thread waited for the peer as well: one incidental read of the buffer stopped the world from being stopped at all. The wait now runs against storage of its own and the bytes are copied over once they arrive, with the export held throughout so the target still cannot be resized meanwhile. A seekable file answers from itself rather than from a peer, so FileIO.readinto() writes into the target directly there and the buffered read path is unchanged. Assisted-by: Claude * Do not hold a buffer's storage while waiting to hand it over socket.send()/sendall()/sendto()/sendmsg() and FileIO.write() kept the source buffer's read borrow for the whole call, including the wait for a peer that may never make room. That borrow is a lock every other thread writing to the same object waits on, so threading.Thread(target=lambda: sock.sendall(buf)).start() buf[0] = 1 did not return until the peer read; and a thread parked there is ATTACHED and never reaches a safepoint, so gc.collect() in a third thread waited for the peer too -- the same wedge readinto() had on the receiving side. ArgBytesLike::borrow_buf_unlocked() answers with bytes that survive the borrow being dropped. An immutable object hands out a plain reference and locks nothing, so those are sent where they lie and bytes and memoryviews over them cost nothing; only bytes reached through a lock are copied out first. The export is held throughout either way, so the source still cannot be resized while it is being sent. The regression snippet covers both directions now and is renamed for it. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Check select()'s descriptor limit while the sequence is walked seq2set() collected the whole sequence and compared the result's length against FD_SETSIZE afterwards. Selectable::try_from_object() calls fileno(), which runs Python and can append to the list being walked, and the walk re-reads the list on every step, so the collection had no end to reach and the comparison was never made. seq2set in Modules/selectmodule.c checks the count per element instead. stdlib_select.py gains a fileno() that appends to its own list, and releases its sockets with close() rather than by dropping the name. Assisted-by: Claude * Bound native recursion where the C stack cannot be measured check_c_stack_overflow() answers no unconditionally under miri and on musl, where the stack pointer is not read. Since 9c9905aff that check is all with_recursion() does, so every guard placed on native recursion -- __call__ and __get__ dispatch among them -- was a no-op on those targets and the nesting ran until the stack ran out. with_recursion() now counts its own depth on those targets and refuses past NATIVE_RECURSION_LIMIT_UNMEASURED. The count is separate from the frame limit sys.setrecursionlimit() sets, and compiles away where the stack pointer can be read. Assisted-by: Claude * Report the failure to allocate pbkdf2's key and Take.readinto's scratch Both buffers are sized from an argument -- pbkdf2_hmac's dklen accepts up to i32::MAX, and readinto's from the length of the destination -- and were built with vec![0u8; n], which aborts the process on allocation failure. new_zeroed_bytes() raises MemoryError instead. Assisted-by: Claude * Read the frozen-code tuple length as a signed length The '(' branches in read_marshal_str_vec() and read_marshal_const_tuple() took the length with read_u32() as usize, so a value with the top bit set read as four billion items rather than as out of range. read_len() is what every other length in this file goes through, and it reinterprets as i32. Both readers serve deserialize_code(), which reads only the frozen modules baked in at build time, so this changes no reachable behavior; marshal.loads() already went through read_len(). Assisted-by: Claude * Make the snippets from the fuzzer sweep assert what they check builtin_str.py expanded a tab to 2**31-1 columns, allocating 2 GiB to observe that the width is accepted; a string with no tab observes the same acceptance without laying anything out. stdlib_array.py caught the refusal of frombytes() on its own exported buffer and passed silently when nothing was raised. stdlib_hashlib.py used a bare `assert False` as its failure branch. Both now say so through the same shapes the other snippets use. stdlib_socket.py also accepts OverflowError from recv() with a size that does not fit the platform's C int. stdlib_threading_current_frames.py indexed the frame chain for "f123" without first asserting it is there. Assisted-by: Claude * Ask for a marshal container's room instead of assuming it A flagged tuple or list is published in the reference table before its children are read, and the placeholder was built with vec![none; len]. The length is the input's to choose and read_len() lets it reach i32::MAX, so marshal.loads(b"\xa8\xff\xff\xff\x7f") -- five bytes -- asks for 17 GB of element slots and aborts the process where the allocator cannot serve it. r_object() allocates the container up front too, but PyTuple_New() reports what it cannot get. The elements are now reserved with try_reserve_exact() and a refusal is raised as MemoryError through the decoder's pending-error channel. PyTuple::new_marshal_placeholder() held nothing but that allocation and is gone; the caller builds the elements and uses new_ref(). Assisted-by: Claude * Compare the blocking-buffer snippet against something measure() asserted len(buf) == len(buf), which holds whatever the length is; it now takes the length the caller expects. The pipe case compared the drained total against the whole source, while an unbuffered write() reports only what it transferred and a signal can cut that short; it now compares against what write() returned. Assisted-by: Claude * Refuse array.frombytes() a source whose items are not bytes frombytes() reads its argument as bytes, but accepted any contiguous buffer, so array("i").frombytes(memoryview(array("d", [1.0]))) appended a double's bytes read as ints instead of raising. array_array_frombytes_impl requires an itemsize of 1; ArgBytesLike now reports the itemsize so the same check can be made here. The BufferError that a resize meets while the array is exported also names the array rather than repeating bytearray's wording. stdlib_array.py's frombytes-of-itself case used typecode "i", where the new check answers before the resize guard is reached; it uses "b" so the guard is what refuses, and asserts the wider case separately. Assisted-by: Claude * Tell apart the ways marshal data can be bad A type byte no reader knows, a back reference that names nothing, and TYPE_NULL all came out as a bare "bad marshal data" ValueError. r_object() answers the first two with "bad marshal data (unknown type code)" and "bad marshal data (invalid reference)", and read_object() answers the third with a TypeError, "NULL object in marshal data for object", since TYPE_NULL stands for no object rather than for a value. MarshalError gains the three cases and deserialize_value() maps them. The container-specific wording r_object() uses for a NULL read inside a tuple or list is not reproduced; the exception type is. Assisted-by: Claude * Hash the collector's tables by address rather than by SipHash A collection keys three sets and two maps by object address, and it visits every tracked object and every edge between them, so the hashing is a per-edge cost. Those tables used the default RandomState, whose SipHash buys resistance against a caller choosing colliding keys -- and nothing chooses these keys: they are addresses this process handed out into tables that live and die inside one collection. A profile of gc.collect() over a 423k-object heap spent 45% of its samples in SipHash. They now hash with a splitmix64 finalizer. The shifts matter: a table picks its bucket from the low bits and an address arrives with those bits zeroed by alignment, so a plain multiply leaves every object in a handful of buckets and is slower than SipHash was. The reachability walk also copied each object's referent vector out of the map it was cached in, a second pass over every edge; it reads them in place, and reference subtraction hands its vector to the map instead of cloning it. Measured over 423k live objects: 0.93s to 0.15s. Over 843k dead ones: 3.00s to 0.79s. extra_tests/snippets/stdlib_threading_gc_import.py, whose collector thread calls gc.collect() in a loop, ran anywhere from 2.7s to 28s and now runs in 3.1-3.5s: a collection that takes longer leaves more garbage for the next one to walk, so the cost fed back on itself. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Keep the collection's candidates and their counts in one table A collection built a set of candidates and, beside it, a map from the same addresses to their reference counts. Both were probed for every edge in the heap -- membership from the set, the count from the map -- so each edge paid to hash the same address twice, and each candidate paid to be inserted twice. The map alone answers both questions. The candidates also keep a walkable order now, which the reference subtraction pass needs since it writes the counts while reading the candidates, and which the unreachable set is built from instead of a set difference. Over the 423k-object heap measured in the previous commit: 0.15s to 0.13s live, and 0.79s to 0.49s dead. Assisted-by: Claude Assisted-by: Codex:GPT-5 * gc: collect referents into one buffer instead of a vector per object Step 3 allocated a `Vec` for every tracked object to hold its referents and kept them all in a map until step 4 read them back. The referents now go into a single growing buffer, with the map holding each object's range into it. Adds `PyObject::gc_extend_referent_ptrs`, which appends to a caller's buffer; `gc_get_referent_ptrs` calls it with a fresh one. Assisted-by: Claude * memoryview and struct: match the checks and errors of the reference memoryview: - `cast()` accepted a source and destination that are both item types, which reinterprets the items rather than re-dividing the bytes; one side now has to be a byte format. - A cast to `shape=()` returned without checking that the buffer holds exactly the one item that shape describes. - `hash()` hashes the bytes, so it now raises ValueError for a view whose items are not bytes, rather than returning a hash that disagrees with the value the view compares equal to. - `tobytes()` takes the `order` argument, with 'F' walking a multidimensional view down its columns; `BufferDescriptor` gained `for_each_segment_fortran` for that walk. struct: - A value the format has no room for reported "argument out of range" instead of naming the format and its range. The format character is now passed to the packing functions to report it. - `Struct.__new__` no longer reads the format; `__init__` does, so `__init__` can be called again and a subclass can pass the format up. Methods raise RuntimeError until it has run, and `Struct` is a base type. Removes the expectedFailure from test_Struct_reinitialization and test_struct_subclass_instantiation. Assisted-by: Claude Assisted-by: Codex:GPT-5 * io: decide the readinto path by file type, not by seekability FileIO.readinto wrote straight into the caller's buffer, holding its write borrow, when the fd was seekable; otherwise it read aside into scratch and copied. Seekability stood in for "this read answers without waiting on a peer", which a pipe on Windows breaks: lseek on one succeeds, so the pipe took the borrow-holding path and every other thread touching that bytearray waited for the peer. host_io::reads_without_waiting answers it directly -- seekability elsewhere, GetFileType() == FILE_TYPE_DISK on Windows. The regression snippet times each operation separately, so a failure names the one that waited; it asserts the transfer is still in flight before checking the export; and the socket case fills the connection until it refuses rather than assuming a size that outruns it, which SO_SNDBUF on an already-connected pair does not settle. Assisted-by: Claude --- Lib/test/test_struct.py | 2 - Lib/test/test_support.py | 1 - crates/common/src/borrow.rs | 11 ++ crates/common/src/str.rs | 25 +-- crates/compiler-core/src/marshal.rs | 82 +++++--- crates/host_env/src/io.rs | 23 +++ crates/host_env/src/io_unsupported.rs | 4 + crates/stdlib/src/_asyncio.rs | 71 ++++--- crates/stdlib/src/array.rs | 61 ++++-- crates/stdlib/src/hashlib.rs | 6 +- crates/stdlib/src/mmap.rs | 25 ++- crates/stdlib/src/pystruct.rs | 98 +++++++--- crates/stdlib/src/select.rs | 33 +++- crates/stdlib/src/socket.rs | 71 ++++--- crates/vm/src/anystr.rs | 27 ++- crates/vm/src/buffer.rs | 67 +++++-- crates/vm/src/builtins/bytearray.rs | 19 +- crates/vm/src/builtins/bytes.rs | 4 +- crates/vm/src/builtins/memory.rs | 81 +++++++- crates/vm/src/builtins/str.rs | 42 ++-- crates/vm/src/builtins/tuple.rs | 4 - crates/vm/src/bytes_inner.rs | 29 ++- crates/vm/src/frame.rs | 8 + crates/vm/src/function/buffer.rs | 68 +++++++ crates/vm/src/gc_state.rs | 155 +++++++++------ crates/vm/src/object/core.rs | 13 +- crates/vm/src/protocol/buffer.rs | 41 ++++ crates/vm/src/stdlib/_io.rs | 100 +++++++--- crates/vm/src/stdlib/_thread.rs | 6 +- crates/vm/src/stdlib/atexit.rs | 20 +- crates/vm/src/stdlib/marshal.rs | 141 ++++++++------ crates/vm/src/stdlib/typevar.rs | 18 +- crates/vm/src/types/slot.rs | 12 +- crates/vm/src/vm/mod.rs | 80 ++++++-- crates/vm/src/vm/thread.rs | 183 ++++++++++-------- crates/vm/src/vm/vm_ops.rs | 21 ++ extra_tests/snippets/builtin_bytes.py | 19 ++ extra_tests/snippets/builtin_hash.py | 5 +- extra_tests/snippets/builtin_memoryview.py | 161 +++++++++++++++ extra_tests/snippets/builtin_str.py | 15 ++ extra_tests/snippets/builtin_type.py | 30 +++ extra_tests/snippets/recursion.py | 33 ++++ extra_tests/snippets/stdlib_array.py | 33 ++++ extra_tests/snippets/stdlib_asyncio.py | 27 +++ extra_tests/snippets/stdlib_atexit.py | 101 ++++++++++ extra_tests/snippets/stdlib_hashlib.py | 8 + extra_tests/snippets/stdlib_io.py | 45 +++++ .../snippets/stdlib_io_blocking_buffer.py | 176 +++++++++++++++++ extra_tests/snippets/stdlib_io_bytesio.py | 8 + extra_tests/snippets/stdlib_marshal.py | 58 ++++++ extra_tests/snippets/stdlib_select.py | 73 ++++++- extra_tests/snippets/stdlib_socket.py | 15 ++ extra_tests/snippets/stdlib_struct.py | 64 ++++++ .../stdlib_threading_current_frames.py | 100 ++++++++++ extra_tests/snippets/stdlib_types.py | 4 +- extra_tests/snippets/stdlib_typing.py | 18 ++ 56 files changed, 2145 insertions(+), 500 deletions(-) create mode 100644 extra_tests/snippets/stdlib_atexit.py create mode 100644 extra_tests/snippets/stdlib_io_blocking_buffer.py create mode 100644 extra_tests/snippets/stdlib_threading_current_frames.py diff --git a/Lib/test/test_struct.py b/Lib/test/test_struct.py index c76639809..f828b7786 100644 --- a/Lib/test/test_struct.py +++ b/Lib/test/test_struct.py @@ -601,7 +601,6 @@ class StructTest(ComplexesAreIdenticalMixin, unittest.TestCase): 'spam and eggs') self.assertRaises(struct.error, struct.unpack_from, '14s42', store, 0) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: '>h' != '>hh' def test_Struct_reinitialization(self): # Issue 9422: there was a memory leak when reinitializing a # Struct instance. This test can be used to detect the leak @@ -826,7 +825,6 @@ class StructTest(ComplexesAreIdenticalMixin, unittest.TestCase): test_error_propagation('N') test_error_propagation('n') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_struct_subclass_instantiation(self): # Regression test for https://github.com/python/cpython/issues/112358 class MyStruct(struct.Struct): diff --git a/Lib/test/test_support.py b/Lib/test/test_support.py index 19ea6fafc..42aa7e3d9 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -631,7 +631,6 @@ class TestSupport(unittest.TestCase): else: self.assertTrue(support.has_strftime_extensions) - @unittest.expectedFailure # TODO: RUSTPYTHON; - _testinternalcapi module not available def test_get_recursion_depth(self): # test support.get_recursion_depth() code = textwrap.dedent(""" diff --git a/crates/common/src/borrow.rs b/crates/common/src/borrow.rs index 2be5f8275..70d755ff1 100644 --- a/crates/common/src/borrow.rs +++ b/crates/common/src/borrow.rs @@ -34,6 +34,17 @@ impl_from!('a, T, BorrowedValue<'a, T>, ); impl<'a, T: ?Sized> BorrowedValue<'a, T> { + /// Whether reaching the value holds a lock that other threads wait on. + /// + /// An immutable object hands out a plain reference and answers `false`; + /// one whose storage can change hands out a guard. A caller about to wait + /// for something unrelated -- a peer, a file, a signal -- can use this to + /// decide whether it may keep the borrow for the duration. + #[must_use] + pub const fn is_locked(&self) -> bool { + !matches!(self, Self::Ref(_)) + } + pub fn map(s: Self, f: F) -> BorrowedValue<'a, U> where F: FnOnce(&T) -> &U, diff --git a/crates/common/src/str.rs b/crates/common/src/str.rs index 326f76c43..39ec7da1d 100644 --- a/crates/common/src/str.rs +++ b/crates/common/src/str.rs @@ -592,20 +592,21 @@ pub fn codepoint_range_end(s: &Wtf8, n_chars: usize) -> Option { } #[must_use] -pub fn zfill(bytes: &[u8], width: usize) -> Vec { +/// Returns `None` for a width whose result cannot be allocated. +pub fn zfill(bytes: &[u8], width: usize) -> Option> { if width <= bytes.len() { - bytes.to_vec() - } else { - let (sign, s) = match bytes.first() { - Some(_sign @ (b'+' | b'-')) => (unsafe { bytes.get_unchecked(..1) }, &bytes[1..]), - _ => (&b""[..], bytes), - }; - let mut filled = Vec::new(); - filled.extend_from_slice(sign); - filled.extend(core::iter::repeat_n(b'0', width - bytes.len())); - filled.extend_from_slice(s); - filled + return Some(bytes.to_vec()); } + let (sign, s) = match bytes.first() { + Some(_sign @ (b'+' | b'-')) => (unsafe { bytes.get_unchecked(..1) }, &bytes[1..]), + _ => (&b""[..], bytes), + }; + let mut filled = Vec::new(); + filled.try_reserve_exact(width).ok()?; + filled.extend_from_slice(sign); + filled.extend(core::iter::repeat_n(b'0', width - bytes.len())); + filled.extend_from_slice(s); + Some(filled) } /// Convert a string to ascii compatible, escaping unicode-s into escape diff --git a/crates/compiler-core/src/marshal.rs b/crates/compiler-core/src/marshal.rs index 46e004794..754854e7c 100644 --- a/crates/compiler-core/src/marshal.rs +++ b/crates/compiler-core/src/marshal.rs @@ -19,6 +19,14 @@ pub enum MarshalError { InvalidLocation, /// Bad type marker BadType, + /// A type marker no reader knows + UnknownType, + /// A back reference that names nothing + InvalidRef, + /// A marker that stands for no object at all + NullObject, + /// A container length that is negative or does not fit, named by what it counts + BadSize(&'static str), } impl core::fmt::Display for MarshalError { @@ -29,6 +37,10 @@ impl core::fmt::Display for MarshalError { Self::InvalidUtf8 => f.write_str("invalid utf8"), Self::InvalidLocation => f.write_str("invalid source location"), Self::BadType => f.write_str("bad type marker"), + Self::UnknownType => f.write_str("unknown type code"), + Self::InvalidRef => f.write_str("invalid reference"), + Self::NullObject => f.write_str("NULL object in marshal data for object"), + Self::BadSize(what) => write!(f, "{what} size out of range"), } } } @@ -111,7 +123,7 @@ impl TryFrom for Type { b'A' => Self::AsciiInterned, b'z' => Self::ShortAscii, b'Z' => Self::ShortAsciiInterned, - _ => return Err(MarshalError::BadType), + _ => return Err(MarshalError::UnknownType), }) } } @@ -146,6 +158,13 @@ pub trait Read { fn read_u64(&mut self) -> Result { Ok(u64::from_le_bytes(*self.read_array()?)) } + + /// A length, read the way `r_long` reads one: it is signed, so a value + /// with the top bit set is out of range rather than four billion items. + fn read_len(&mut self, what: &'static str) -> Result { + let len = self.read_u32()? as i32; + usize::try_from(len).map_err(|_| MarshalError::BadSize(what)) + } } pub(crate) trait ReadBorrowed<'a>: Read { @@ -305,7 +324,7 @@ fn reserve_ref_slot(has_flag: bool, refs: &mut Vec>) -> Option(idx: usize, refs: &[Option]) -> Result { refs.get(idx) .and_then(|v| v.clone()) - .ok_or(MarshalError::InvalidBytecode) + .ok_or(MarshalError::InvalidRef) } /// Read a marshal bytes object (TYPE_STRING = b's'), resolving TYPE_REF @@ -408,7 +427,7 @@ fn read_marshal_str_vec( } let n = match type_byte { - b'(' => rdr.read_u32()? as usize, + b'(' => rdr.read_len("tuple")?, b')' => rdr.read_u8()? as usize, _ => return Err(MarshalError::BadType), }; @@ -471,7 +490,7 @@ fn read_marshal_const_tuple( } let n = match type_byte { - b'(' => rdr.read_u32()? as usize, + b'(' => rdr.read_len("tuple")?, b')' => rdr.read_u8()? as usize, _ => return Err(MarshalError::BadType), }; @@ -553,7 +572,7 @@ pub trait MarshalBag: Copy { fn make_code( &self, code: CodeObject<::Constant>, - ) -> Self::Value; + ) -> Result; /// Construct a runtime code object while retaining the exact values read /// from ``co_consts``. Compiler bags ignore this second channel; runtime @@ -563,7 +582,7 @@ pub trait MarshalBag: Copy { &self, code: CodeObject<::Constant>, _constants: Vec, - ) -> Self::Value { + ) -> Result { self.make_code(code) } @@ -583,8 +602,12 @@ pub trait MarshalBag: Copy { /// Install partially-built containers in the marshal reference table /// before reading their children, as CPython's `r_object()` does. /// Runtime bags can opt in; constant bags retain collect-then-construct. - fn make_tuple_placeholder(&self, _len: usize) -> Option { - None + /// + /// `len` comes straight from the input and is only bounded by what a + /// length can hold, so a bag that opts in reports the room it cannot get + /// rather than taking it for granted. + fn make_tuple_placeholder(&self, _len: usize) -> Result> { + Ok(None) } fn set_tuple_item( @@ -596,8 +619,8 @@ pub trait MarshalBag: Copy { Err(MarshalError::BadType) } - fn make_list_placeholder(&self, _len: usize) -> Option { - None + fn make_list_placeholder(&self, _len: usize) -> Result> { + Ok(None) } fn set_list_item(&self, _list: &Self::Value, _index: usize, _value: Self::Value) -> Result<()> { @@ -725,8 +748,8 @@ impl MarshalBag for Bag { fn make_code( &self, code: CodeObject<::Constant>, - ) -> Self::Value { - self.make_code(code) + ) -> Result { + Ok(self.make_code(code)) } fn make_stop_iter(&self) -> Result { @@ -830,10 +853,7 @@ fn deserialize_value_after_header( // TYPE_REF: return previously stored object if type_code == Type::Ref as u8 { let idx = rdr.read_u32()? as usize; - return refs - .get(idx) - .and_then(|v| v.clone()) - .ok_or(MarshalError::InvalidBytecode); + return resolve_ref(idx, refs); } // Reserve ref slot before reading (matches write order) @@ -986,7 +1006,7 @@ fn deserialize_code_value_inner( linetable, exceptiontable, }; - Ok(bag.make_code_with_constants(code, constant_values)) + bag.make_code_with_constants(code, constant_values) } fn deserialize_value_typed( @@ -1033,13 +1053,13 @@ fn deserialize_value_typed( bag.make_complex(value) } Type::Ascii | Type::Unicode => { - let len = rdr.read_u32()?; - let value = rdr.read_wtf8(len)?; + let len = rdr.read_len("string")?; + let value = rdr.read_wtf8(len as u32)?; bag.make_str(value) } Type::AsciiInterned | Type::Interned => { - let len = rdr.read_u32()?; - let value = rdr.read_wtf8(len)?; + let len = rdr.read_len("string")?; + let value = rdr.read_wtf8(len as u32)?; bag.make_interned_str(value) } Type::ShortAscii => { @@ -1056,7 +1076,7 @@ fn deserialize_value_typed( let len = rdr.read_u8()? as usize; let d = depth - 1; if let Some(index) = slot - && let Some(tuple) = bag.make_tuple_placeholder(len) + && let Some(tuple) = bag.make_tuple_placeholder(len)? { refs[index] = Some(tuple.clone()); for item_index in 0..len { @@ -1070,17 +1090,17 @@ fn deserialize_value_typed( } } Type::Null => { - return Err(MarshalError::BadType); + return Err(MarshalError::NullObject); } Type::Ref => { // Handled in deserialize_value_depth before calling this function return Err(MarshalError::BadType); } Type::Tuple => { - let len = rdr.read_u32()? as usize; + let len = rdr.read_len("tuple")?; let d = depth - 1; if let Some(index) = slot - && let Some(tuple) = bag.make_tuple_placeholder(len) + && let Some(tuple) = bag.make_tuple_placeholder(len)? { refs[index] = Some(tuple.clone()); for item_index in 0..len { @@ -1094,10 +1114,10 @@ fn deserialize_value_typed( } } Type::List => { - let len = rdr.read_u32()? as usize; + let len = rdr.read_len("list")?; let d = depth - 1; if let Some(index) = slot - && let Some(list) = bag.make_list_placeholder(len) + && let Some(list) = bag.make_list_placeholder(len)? { refs[index] = Some(list.clone()); for item_index in 0..len { @@ -1111,7 +1131,7 @@ fn deserialize_value_typed( } } Type::Set => { - let len = rdr.read_u32()? as usize; + let len = rdr.read_len("set")?; let d = depth - 1; if let Some(index) = slot && let Some(set) = bag.make_set_placeholder() @@ -1128,7 +1148,7 @@ fn deserialize_value_typed( } } Type::FrozenSet => { - let len = rdr.read_u32()?; + let len = rdr.read_len("set")?; let d = depth - 1; let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); itertools::process_results(it, |it| bag.make_frozenset(it))?? @@ -1165,8 +1185,8 @@ fn deserialize_value_typed( } Type::Bytes => { // After marshaling, byte arrays are converted into bytes. - let len = rdr.read_u32()?; - let value = rdr.read_slice(len)?; + let len = rdr.read_len("bytes object")?; + let value = rdr.read_slice(len as u32)?; bag.make_bytes(value) } Type::Code => return Err(MarshalError::BadType), diff --git a/crates/host_env/src/io.rs b/crates/host_env/src/io.rs index 6df29bcd6..f32ef2f69 100644 --- a/crates/host_env/src/io.rs +++ b/crates/host_env/src/io.rs @@ -199,6 +199,29 @@ pub fn is_seekable(fd: crt_fd::Borrowed<'_>) -> bool { os::seek_fd(fd, 0, libc::SEEK_CUR).is_ok() } +/// Whether a read from `fd` answers from data the file already holds, rather +/// than waiting for whoever writes the other end. +/// +/// Seeking answers this everywhere but Windows, where a pipe seeks too -- +/// `lseek` on one succeeds and reports a position, so a reader that took +/// seekability for an answer would wait on a peer while holding whatever it +/// holds for the length of the call. +#[cfg(not(windows))] +pub fn reads_without_waiting(fd: crt_fd::Borrowed<'_>) -> bool { + is_seekable(fd) +} + +#[cfg(windows)] +pub fn reads_without_waiting(fd: crt_fd::Borrowed<'_>) -> bool { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, GetFileType}; + + let Ok(handle) = crt_fd::as_handle(fd) else { + return false; + }; + unsafe { GetFileType(handle.as_raw_handle() as _) == FILE_TYPE_DISK } +} + pub fn validate_whence(whence: i32) -> bool { let standard = (0..=2).contains(&whence); #[cfg(any(target_os = "dragonfly", target_os = "freebsd", target_os = "linux"))] diff --git a/crates/host_env/src/io_unsupported.rs b/crates/host_env/src/io_unsupported.rs index e46f05af9..d9fdc5d0d 100644 --- a/crates/host_env/src/io_unsupported.rs +++ b/crates/host_env/src/io_unsupported.rs @@ -176,6 +176,10 @@ pub fn is_seekable(_fd: crt_fd::Borrowed<'_>) -> bool { false } +pub fn reads_without_waiting(_fd: crt_fd::Borrowed<'_>) -> bool { + false +} + pub fn validate_whence(whence: i32) -> bool { (0..=2).contains(&whence) } diff --git a/crates/stdlib/src/_asyncio.rs b/crates/stdlib/src/_asyncio.rs index 9ad75fb8d..c3f28590e 100644 --- a/crates/stdlib/src/_asyncio.rs +++ b/crates/stdlib/src/_asyncio.rs @@ -724,47 +724,56 @@ pub(crate) mod _asyncio { /// Add waiter to fut_awaited_by with single-object optimization fn awaited_by_add(&self, waiter: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - let mut awaited_by = self.fut_awaited_by.write(); - if awaited_by.is_none() { - // First waiter - store directly - *awaited_by = Some(waiter); - return Ok(()); - } + // Storing a waiter in the set runs its __hash__ and __eq__, which can + // come back to this future, so the field is locked only while it is + // read or written. + let existing = { + let mut awaited_by = self.fut_awaited_by.write(); + match awaited_by.as_ref() { + // First waiter - store directly + None => { + *awaited_by = Some(waiter); + return Ok(()); + } + Some(existing) => existing.clone(), + } + }; if self.fut_awaited_by_is_set.load(Ordering::Relaxed) { // Already a Set - add to it - let set = awaited_by.as_ref().unwrap(); - vm.call_method(set, "add", (waiter,))?; - } else { - // Single object - convert to Set - let existing = awaited_by.take().unwrap(); - let new_set = PySet::default().into_ref(&vm.ctx); - new_set.add(existing, vm)?; - new_set.add(waiter, vm)?; - *awaited_by = Some(new_set.into()); - self.fut_awaited_by_is_set.store(true, Ordering::Relaxed); + return vm.call_method(&existing, "add", (waiter,)).map(drop); } + + // Single object - convert to Set + let new_set = PySet::default().into_ref(&vm.ctx); + new_set.add(existing, vm)?; + new_set.add(waiter, vm)?; + *self.fut_awaited_by.write() = Some(new_set.into()); + self.fut_awaited_by_is_set.store(true, Ordering::Relaxed); Ok(()) } /// Discard waiter from fut_awaited_by with single-object optimization fn awaited_by_discard(&self, waiter: &PyObject, vm: &VirtualMachine) -> PyResult<()> { - let mut awaited_by = self.fut_awaited_by.write(); - if awaited_by.is_none() { - return Ok(()); - } - - let obj = awaited_by.as_ref().unwrap(); - if !self.fut_awaited_by_is_set.load(Ordering::Relaxed) { - // Single object - check if it matches - if obj.is(waiter) { - *awaited_by = None; + // As in awaited_by_add, discarding from the set runs Python. + let set = { + let mut awaited_by = self.fut_awaited_by.write(); + let Some(obj) = awaited_by.as_ref() else { + return Ok(()); + }; + if !self.fut_awaited_by_is_set.load(Ordering::Relaxed) { + // Single object - check if it matches + if obj.is(waiter) { + *awaited_by = None; + } + return Ok(()); } - } else { - // It's a Set - use discard - vm.call_method(obj, "discard", (waiter.to_owned(),))?; - } - Ok(()) + obj.clone() + }; + + // It's a Set - use discard + vm.call_method(&set, "discard", (waiter.to_owned(),)) + .map(drop) } #[pymethod] diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index 68e7aab25..0f652efd3 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -55,6 +55,11 @@ pub mod array { $($n(Vec<$t>),)* } + /// One item, already converted to the array's element type. + enum ArrayItem { + $($n($t),)* + } + impl ArrayContentType { fn from_char(c: char) -> Result { match c { @@ -303,17 +308,31 @@ pub mod array { } } - fn setitem_by_index( - &mut self, - i: isize, + /// Convert an object to the element type of the array with + /// this typecode. This runs the object's conversion methods, + /// which can reach the array, so it takes the typecode by + /// value and holds no lock on it. + fn item_from_object( + typecode: char, value: PyObjectRef, vm: &VirtualMachine + ) -> PyResult { + match typecode { + $($c => Ok(ArrayItem::$n(<$t>::try_into_from_object(vm, value)?)),)* + _ => unreachable!("array has a typecode"), + } + } + + fn setitem_by_item( + &mut self, + i: isize, + item: ArrayItem, + vm: &VirtualMachine ) -> PyResult<()> { - match self { - $(ArrayContentType::$n(v) => { - let value = <$t>::try_into_from_object(vm, value)?; - v.setitem_by_index(vm, i, value) - })* + match (self, item) { + $((ArrayContentType::$n(v), ArrayItem::$n(value)) => + v.setitem_by_index(vm, i, value),)* + _ => unreachable!("item was converted for this array"), } } @@ -906,6 +925,11 @@ pub mod array { #[pymethod] fn frombytes(&self, b: ArgBytesLike, vm: &VirtualMachine) -> PyResult<()> { + // The source is read as bytes, so items of any other width would + // be reinterpreted rather than appended. + if b.itemsize() != 1 { + return Err(vm.new_type_error("a bytes-like object is required")); + } let b = b.borrow_buf(); let itemsize = self.read().itemsize(); self._from_bytes(&b, itemsize, vm) @@ -1047,7 +1071,11 @@ pub mod array { vm: &VirtualMachine, ) -> PyResult<()> { match SequenceIndex::try_from_borrowed_object(vm, needle, "array")? { - SequenceIndex::Int(i) => zelf.write().setitem_by_index(i, value, vm), + SequenceIndex::Int(i) => { + let typecode = zelf.read().typecode(); + let item = ArrayContentType::item_from_object(typecode, value, vm)?; + zelf.write().setitem_by_item(i, item, vm) + } SequenceIndex::Slice(slice) => { let cloned; let guard; @@ -1408,7 +1436,9 @@ pub mod array { ass_item: atomic_func!(|seq, i, value, vm| { let zelf = PyArray::sequence_downcast(seq); if let Some(value) = value { - zelf.write().setitem_by_index(i, value, vm) + let typecode = zelf.read().typecode(); + let item = ArrayContentType::item_from_object(typecode, value, vm)?; + zelf.write().setitem_by_item(i, item, vm) } else { zelf.write().delitem_by_index(i, vm) } @@ -1443,8 +1473,15 @@ pub mod array { type Resizable<'a> = PyRwLockWriteGuard<'a, ArrayContentType>; fn try_resizable_opt(&self) -> Option> { - let w = self.write(); - (self.exports.load(atomic::Ordering::SeqCst) == 0).then_some(w) + // An export is a borrow someone else still holds, so it is + // answered before the lock rather than by waiting on it. + (self.exports.load(atomic::Ordering::SeqCst) == 0).then(|| self.write()) + } + + fn try_resizable(&self, vm: &VirtualMachine) -> PyResult> { + self.try_resizable_opt().ok_or_else(|| { + vm.new_buffer_error("cannot resize an array that is exporting buffers") + }) } } diff --git a/crates/stdlib/src/hashlib.rs b/crates/stdlib/src/hashlib.rs index c2153b08a..d7f94cc27 100644 --- a/crates/stdlib/src/hashlib.rs +++ b/crates/stdlib/src/hashlib.rs @@ -847,15 +847,15 @@ pub(crate) mod _hashlib { if len < 1 { return Err(vm.new_value_error("key length must be greater than 0.")); } - usize::try_from(len) - .map_err(|_| vm.new_overflow_error("key length is too great."))? + i32::try_from(len).map_err(|_| vm.new_overflow_error("key length is too great."))? + as usize } None => hash_digest_size(&name).ok_or_else(|| unsupported_hash(&name, vm))?, }; let password_buf = args.password.borrow_buf(); let salt_buf = args.salt.borrow_buf(); - let mut dk = vec![0u8; dklen]; + let mut dk = vm.new_zeroed_bytes(dklen)?; macro_rules! do_pbkdf2 { ($hash_ty:ty) => {{ diff --git a/crates/stdlib/src/mmap.rs b/crates/stdlib/src/mmap.rs index b5dec9765..14957ad90 100644 --- a/crates/stdlib/src/mmap.rs +++ b/crates/stdlib/src/mmap.rs @@ -1152,24 +1152,35 @@ mod mmap { } #[pymethod] - fn write(&self, bytes: ArgBytesLike, vm: &VirtualMachine) -> PyResult { - let pos = self.pos(); - let size = self.__len__(); + fn write(zelf: &Py, bytes: ArgBytesLike, vm: &VirtualMachine) -> PyResult { + let self_ = &**zelf; + let pos = self_.pos(); + let size = self_.__len__(); - let data = bytes.borrow_buf(); + // Writing locks the map, and reading a source that views this same + // map locks it too, so such a source is copied out first. + let copied; + let borrowed; + let data: &[u8] = if bytes.source_object().is(zelf.as_object()) { + copied = bytes.borrow_buf().to_vec(); + &copied + } else { + borrowed = bytes.borrow_buf(); + &borrowed + }; if pos > size || size - pos < data.len() { return Err(vm.new_value_error("data out of range")); } - let len = self.try_writable(vm, |mmap| { + let len = self_.try_writable(vm, |mmap| { (&mut mmap[pos..(pos + data.len())]) - .write(&data) + .write(data) .map_err(|err| err.to_pyexception(vm))?; Ok(data.len()) })??; - self.advance_pos(len); + self_.advance_pos(len); Ok(PyInt::from(len).into_ref(&vm.ctx)) } diff --git a/crates/stdlib/src/pystruct.rs b/crates/stdlib/src/pystruct.rs index c525942e3..496b448e5 100644 --- a/crates/stdlib/src/pystruct.rs +++ b/crates/stdlib/src/pystruct.rs @@ -10,13 +10,14 @@ pub(crate) use _struct::module_def; #[pymodule] pub(crate) mod _struct { use crate::vm::{ - AsObject, Py, PyObjectRef, PyPayload, PyResult, TryFromObject, VirtualMachine, + AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, buffer::{FormatSpec, new_struct_error, struct_error_type}, builtins::{PyBytes, PyStr, PyStrRef, PyTupleRef, PyType, PyTypeRef}, - function::{ArgBytesLike, ArgMemoryBuffer, PosArgs}, + common::lock::{PyMappedRwLockReadGuard, PyRwLock, PyRwLockReadGuard}, + function::{ArgBytesLike, ArgMemoryBuffer, FuncArgs, PosArgs}, match_class, protocol::PyIterReturn, - types::{Constructor, IterNext, Iterable, Representable, SelfIter}, + types::{Constructor, Initializer, IterNext, Iterable, Representable, SelfIter}, }; use crossbeam_utils::atomic::AtomicCell; use rustpython_common::wtf8::{Wtf8Buf, wtf8_concat}; @@ -251,41 +252,76 @@ pub(crate) mod _struct { Ok(fmt.format_spec(vm)?.size) } + /// What a `Struct` is once a format has been read into it. Held apart + /// from the object because `__new__` hands out a `Struct` that `__init__` + /// has not filled in yet, and `__init__` may be called again on one that + /// already holds a format. + #[derive(Debug)] + struct StructSpec { + spec: FormatSpec, + format: PyStrRef, + } + #[pyattr] #[pyclass(name = "Struct", traverse)] #[derive(Debug, PyPayload)] struct PyStruct { #[pytraverse(skip)] - spec: FormatSpec, - format: PyStrRef, + inner: PyRwLock>, } impl Constructor for PyStruct { - type Args = IntoStructFormatBytes; + type Args = FuncArgs; - fn py_new(_cls: &Py, fmt: Self::Args, vm: &VirtualMachine) -> PyResult { - let spec = fmt.format_spec(vm)?; - let format = fmt.0; - Ok(Self { spec, format }) + fn py_new(_cls: &Py, _args: Self::Args, _vm: &VirtualMachine) -> PyResult { + Ok(Self { + inner: PyRwLock::new(None), + }) } } - #[pyclass(with(Constructor, Representable))] + impl Initializer for PyStruct { + type Args = IntoStructFormatBytes; + + fn init(zelf: PyRef, fmt: Self::Args, vm: &VirtualMachine) -> PyResult<()> { + // The format is read before anything is replaced, so a format that + // cannot be read leaves the object as it was. + let spec = fmt.format_spec(vm)?; + *zelf.inner.write() = Some(StructSpec { + spec, + format: fmt.0, + }); + Ok(()) + } + } + + #[pyclass(with(Constructor, Initializer, Representable), flags(BASETYPE))] impl PyStruct { - #[pygetset] - fn format(&self) -> PyStrRef { - self.format.clone() + /// The format this was initialized with, or an error if `__init__` + /// never ran. + fn ready(&self, vm: &VirtualMachine) -> PyResult> { + PyRwLockReadGuard::try_map(self.inner.read(), Option::as_ref) + .map_err(|_| vm.new_runtime_error("Struct object is not initialized")) } #[pygetset] - #[inline] - const fn size(&self) -> usize { - self.spec.size + fn format(&self, vm: &VirtualMachine) -> PyResult { + Ok(self.ready(vm)?.format.clone()) + } + + /// The size an uninitialized `Struct` reports, which no format has + /// yet given a value. + #[pygetset] + fn size(&self) -> isize { + self.inner + .read() + .as_ref() + .map_or(-1, |inner| inner.spec.size as isize) } #[pymethod] fn pack(&self, args: PosArgs, vm: &VirtualMachine) -> PyResult> { - self.spec.pack(args.into_vec(), vm) + self.ready(vm)?.spec.pack(args.into_vec(), vm) } #[pymethod] @@ -296,23 +332,28 @@ pub(crate) mod _struct { args: PosArgs, vm: &VirtualMachine, ) -> PyResult<()> { - let offset = get_buffer_offset(buffer.len(), offset, self.size(), true, vm)?; + let inner = self.ready(vm)?; + let offset = get_buffer_offset(buffer.len(), offset, inner.spec.size, true, vm)?; buffer.with_ref(|data| { - self.spec + inner + .spec .pack_into(&mut data[offset..], args.into_vec(), vm) }) } #[pymethod] fn unpack(&self, data: ArgBytesLike, vm: &VirtualMachine) -> PyResult { - data.with_ref(|buf| self.spec.unpack(buf, vm)) + let inner = self.ready(vm)?; + data.with_ref(|buf| inner.spec.unpack(buf, vm)) } #[pymethod] fn unpack_from(&self, args: UpdateFromArgs, vm: &VirtualMachine) -> PyResult { - let offset = get_buffer_offset(args.buffer.len(), args.offset, self.size(), false, vm)?; + let inner = self.ready(vm)?; + let size = inner.spec.size; + let offset = get_buffer_offset(args.buffer.len(), args.offset, size, false, vm)?; args.buffer - .with_ref(|buf| self.spec.unpack(&buf[offset..][..self.size()], vm)) + .with_ref(|buf| inner.spec.unpack(&buf[offset..][..size], vm)) } #[pymethod] @@ -321,14 +362,19 @@ pub(crate) mod _struct { buffer: ArgBytesLike, vm: &VirtualMachine, ) -> PyResult { - UnpackIterator::with_buffer(vm, self.spec.clone(), buffer) + let spec = self.ready(vm)?.spec.clone(); + UnpackIterator::with_buffer(vm, spec, buffer) } } impl Representable for PyStruct { #[inline] - fn repr_wtf8(zelf: &Py, _vm: &VirtualMachine) -> PyResult { - Ok(wtf8_concat!("Struct('", zelf.format.as_wtf8(), "')")) + fn repr_wtf8(zelf: &Py, vm: &VirtualMachine) -> PyResult { + Ok(wtf8_concat!( + "Struct('", + zelf.ready(vm)?.format.as_wtf8(), + "')" + )) } } diff --git a/crates/stdlib/src/select.rs b/crates/stdlib/src/select.rs index c1f10f3ec..84ec92927 100644 --- a/crates/stdlib/src/select.rs +++ b/crates/stdlib/src/select.rs @@ -79,16 +79,26 @@ mod decl { } let deadline = timeout.map(|s| time::time(vm).unwrap() + s); - let seq2set = |list: &PyObject| -> PyResult<(Vec, FdSet)> { - let v: Vec = list.try_to_value(vm)?; + let max_fds: usize = cfg_select! { + windows => FD_SETSIZE as usize, + _ => FD_SETSIZE, + }; - let too_many_fds = cfg_select! { - windows => v.len() > FD_SETSIZE as usize, - _ => v.len() > FD_SETSIZE, - }; - if too_many_fds { - return Err(vm.new_value_error("too many file descriptors in select()")); - } + let seq2set = |list: &PyObject| -> PyResult<(Vec, FdSet)> { + // The limit is answered while the sequence is walked rather than + // from the length of the result. fileno() runs Python and can + // append to the very list being walked, and a walk that re-reads + // the list each step -- which is what `seq2set` does -- then never + // reaches a length to check. + let seen = core::cell::Cell::new(0usize); + let v: Vec = vm.extract_elements_with(list, |obj| { + let selectable = Selectable::try_from_object(vm, obj)?; + seen.set(seen.get() + 1); + if seen.get() > max_fds { + return Err(vm.new_value_error("too many file descriptors in select()")); + } + Ok(selectable) + })?; let mut fds = FdSet::new(); for fd in &v { @@ -304,7 +314,10 @@ mod decl { timeout: OptionalArg>, vm: &VirtualMachine, ) -> PyResult> { - let mut fds = self.fds.lock(); + // Poll a copy: the wait releases the GIL-equivalent and runs + // signal handlers, which can register or unregister on the same + // object, and a held lock would deadlock them. + let mut fds = self.fds.lock().clone(); let TimeoutArg(timeout) = timeout.unwrap_or_default(); let timeout_ms = match timeout { Some(d) => i32::try_from(d.as_millis()) diff --git a/crates/stdlib/src/socket.rs b/crates/stdlib/src/socket.rs index f78bec69d..4e02dca45 100644 --- a/crates/stdlib/src/socket.rs +++ b/crates/stdlib/src/socket.rs @@ -40,7 +40,6 @@ mod _socket { } use core::{ - mem::MaybeUninit, net::{Ipv4Addr, Ipv6Addr, SocketAddr}, time::Duration, }; @@ -1589,7 +1588,10 @@ mod _socket { vm: &VirtualMachine, ) -> Result, IoOrPyException> { let flags = flags.unwrap_or(0); - let mut buffer = Vec::with_capacity(bufsize); + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(bufsize) + .map_err(|_| vm.new_memory_error(""))?; let sock = self.sock()?; let n = self.sock_op(vm, SockWaitKind::Read, || { sock.recv_with_flags(buffer.spare_capacity_mut(), flags) @@ -1608,8 +1610,6 @@ mod _socket { ) -> Result { let flags = flags.unwrap_or(0); let sock = self.sock()?; - let mut buf = buf.borrow_buf_mut(); - let buf = &mut *buf; // Handle nbytes parameter let read_len = if let OptionalArg::Present(nbytes) = nbytes { @@ -1621,10 +1621,13 @@ mod _socket { buf.len() }; - let buf = &mut buf[..read_len]; - self.sock_op(vm, SockWaitKind::Read, || { - sock.recv_with_flags(unsafe { slice_as_uninit(buf) }, flags) - }) + let mut scratch = alloc_recv_scratch(read_len, vm)?; + let n = self.sock_op(vm, SockWaitKind::Read, || { + sock.recv_with_flags(&mut scratch.spare_capacity_mut()[..read_len], flags) + })?; + unsafe { scratch.set_len(n) }; + buf.borrow_buf_mut()[..n].copy_from_slice(&scratch); + Ok(n) } #[pymethod] @@ -1638,7 +1641,10 @@ mod _socket { let bufsize = bufsize .to_usize() .ok_or_else(|| vm.new_value_error("negative buffersize in recvfrom"))?; - let mut buffer = Vec::with_capacity(bufsize); + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(bufsize) + .map_err(|_| vm.new_memory_error(""))?; let (n, addr) = self.sock_op(vm, SockWaitKind::Read, || { self.sock()? .recv_from_with_flags(buffer.spare_capacity_mut(), flags) @@ -1655,24 +1661,28 @@ mod _socket { flags: OptionalArg, vm: &VirtualMachine, ) -> Result<(usize, PyObjectRef), IoOrPyException> { - let mut buf = buf.borrow_buf_mut(); - let buf = &mut *buf; - let buf = match nbytes { + let read_len = match nbytes { OptionalArg::Present(i) => { let i = i.to_usize().ok_or_else(|| { vm.new_value_error("negative buffersize in recvfrom_into") })?; - buf.get_mut(..i).ok_or_else(|| { - vm.new_value_error("nbytes is greater than the length of the buffer") - })? + if i > buf.len() { + return Err(vm + .new_value_error("nbytes is greater than the length of the buffer") + .into()); + } + i } - OptionalArg::Missing => buf, + OptionalArg::Missing => buf.len(), }; let flags = flags.unwrap_or(0); let sock = self.sock()?; + let mut scratch = alloc_recv_scratch(read_len, vm)?; let (n, addr) = self.sock_op(vm, SockWaitKind::Read, || { - sock.recv_from_with_flags(unsafe { slice_as_uninit(buf) }, flags) + sock.recv_from_with_flags(&mut scratch.spare_capacity_mut()[..read_len], flags) })?; + unsafe { scratch.set_len(n) }; + buf.borrow_buf_mut()[..n].copy_from_slice(&scratch); Ok((n, get_addr_tuple(&addr, vm))) } @@ -1684,7 +1694,7 @@ mod _socket { vm: &VirtualMachine, ) -> Result { let flags = flags.unwrap_or(0); - let buf = bytes.borrow_buf(); + let buf = bytes.borrow_buf_unlocked(vm)?; let buf = &*buf; self.sock_op(vm, SockWaitKind::Write, || { self.sock()?.send_with_flags(buf, flags) @@ -1704,7 +1714,7 @@ mod _socket { let deadline = timeout.map(Deadline::new); - let buf = bytes.borrow_buf(); + let buf = bytes.borrow_buf_unlocked(vm)?; let buf = &*buf; let mut buf_offset = 0; // now we have like 3 layers of interrupt loop :) @@ -1741,7 +1751,7 @@ mod _socket { OptionalArg::Missing => (0, arg2), }; let addr = self.extract_address(address, "sendto", vm)?; - let buf = bytes.borrow_buf(); + let buf = bytes.borrow_buf_unlocked(vm)?; let buf = &*buf; self.sock_op(vm, SockWaitKind::Write, || { self.sock()?.send_to_with_flags(buf, &addr, flags) @@ -1771,8 +1781,8 @@ mod _socket { let buffers = buffers .iter() - .map(|buf| buf.borrow_buf()) - .collect::>(); + .map(|buf| buf.borrow_buf_unlocked(vm)) + .collect::>>()?; let buffers = buffers .iter() .map(|buf| io::IoSlice::new(buf)) @@ -2380,8 +2390,21 @@ mod _socket { Ok(s.to_string_lossy().into_owned()) } - unsafe fn slice_as_uninit(v: &mut [T]) -> &mut [MaybeUninit] { - unsafe { &mut *(v as *mut [T] as *mut [MaybeUninit]) } + /// Room to receive into that belongs to no Python object. + /// + /// A peer may never send, so the wait for it is unbounded. The export of + /// the caller's buffer is held for the whole call, which is what keeps it + /// from being resized, but the borrow that reaches its bytes is a lock + /// every other thread touching that object waits on, and a thread waiting + /// on a lock never reaches a safepoint — holding it across the wait stops + /// the world from being stopped at all. The bytes are copied over once + /// they have arrived. + fn alloc_recv_scratch(len: usize, vm: &VirtualMachine) -> PyResult> { + let mut scratch = Vec::new(); + scratch + .try_reserve_exact(len) + .map_err(|_| vm.new_memory_error(""))?; + Ok(scratch) } enum IoOrPyException { diff --git a/crates/vm/src/anystr.rs b/crates/vm/src/anystr.rs index 0f187f6d4..4896f2789 100644 --- a/crates/vm/src/anystr.rs +++ b/crates/vm/src/anystr.rs @@ -27,7 +27,7 @@ pub struct SplitLinesArgs { #[derive(FromArgs)] pub struct ExpandTabsArgs { #[pyarg(any, default = 8)] - tabsize: isize, + tabsize: i32, } impl ExpandTabsArgs { @@ -132,6 +132,11 @@ where { fn new() -> Self; fn with_capacity(capacity: usize) -> Self; + /// `with_capacity`, reporting a capacity that cannot be allocated instead + /// of aborting the process on it. + fn try_with_capacity(capacity: usize) -> Option + where + Self: Sized; fn push_str(&mut self, s: &S); } @@ -285,27 +290,29 @@ pub(crate) trait AnyStr { } } - fn py_pad(&self, left: usize, right: usize, fillchar: Self::Char) -> Self::Container { - let mut u = Self::Container::with_capacity( - (left + right) * fillchar.bytes_len() + self.bytes_len(), - ); + fn py_pad(&self, left: usize, right: usize, fillchar: Self::Char) -> Option { + let capacity = left + .checked_add(right)? + .checked_mul(fillchar.bytes_len())? + .checked_add(self.bytes_len())?; + let mut u = Self::Container::try_with_capacity(capacity)?; u.extend(core::iter::repeat_n(fillchar, left)); u.push_str(self); u.extend(core::iter::repeat_n(fillchar, right)); - u + Some(u) } - fn py_center(&self, width: usize, fillchar: Self::Char, len: usize) -> Self::Container { + fn py_center(&self, width: usize, fillchar: Self::Char, len: usize) -> Option { let marg = width - len; let left = marg / 2 + (marg & width & 1); self.py_pad(left, marg - left, fillchar) } - fn py_ljust(&self, width: usize, fillchar: Self::Char, len: usize) -> Self::Container { + fn py_ljust(&self, width: usize, fillchar: Self::Char, len: usize) -> Option { self.py_pad(0, width - len, fillchar) } - fn py_rjust(&self, width: usize, fillchar: Self::Char, len: usize) -> Self::Container { + fn py_rjust(&self, width: usize, fillchar: Self::Char, len: usize) -> Option { self.py_pad(width - len, 0, fillchar) } @@ -402,7 +409,7 @@ pub(crate) trait AnyStr { elements } - fn py_zfill(&self, width: isize) -> Vec { + fn py_zfill(&self, width: isize) -> Option> { let width = width.to_usize().unwrap_or(0); let char_len = self.elements().count(); let width = self diff --git a/crates/vm/src/buffer.rs b/crates/vm/src/buffer.rs index dc3691b54..038e7cae9 100644 --- a/crates/vm/src/buffer.rs +++ b/crates/vm/src/buffer.rs @@ -16,7 +16,7 @@ use malachite_bigint::BigInt; use num_traits::{PrimInt, ToPrimitive}; use std::os::raw; -type PackFunc = fn(&VirtualMachine, PyObjectRef, &mut [u8]) -> PyResult<()>; +type PackFunc = fn(&VirtualMachine, FormatType, PyObjectRef, &mut [u8]) -> PyResult<()>; type UnpackFunc = fn(&VirtualMachine, &[u8]) -> PyObjectRef; static OVERFLOW_MSG: &str = "total struct size too long"; // not a const to reduce code size @@ -490,7 +490,7 @@ impl FormatSpec { let pack = code.info.pack.unwrap(); for arg in args.by_ref().take(code.repeat) { let (item_buf, rest) = buffer.split_at_mut(code.info.size); - pack(vm, arg, item_buf)?; + pack(vm, code.code, arg, item_buf)?; buffer = rest; } } @@ -549,7 +549,12 @@ impl FormatSpec { } trait Packable { - fn pack(vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8]) -> PyResult<()>; + fn pack( + vm: &VirtualMachine, + code: FormatType, + arg: PyObjectRef, + data: &mut [u8], + ) -> PyResult<()>; fn unpack(vm: &VirtualMachine, data: &[u8]) -> PyObjectRef; } @@ -576,10 +581,11 @@ macro_rules! make_pack_prim_int { impl Packable for $T { fn pack( vm: &VirtualMachine, + code: FormatType, arg: PyObjectRef, data: &mut [u8], ) -> PyResult<()> { - let i: $T = get_int_or_index(vm, arg)?; + let i: $T = get_int_or_index(vm, code, arg)?; i.pack_int::(data); Ok(()) } @@ -592,16 +598,28 @@ macro_rules! make_pack_prim_int { }; } -fn get_int_or_index(vm: &VirtualMachine, arg: PyObjectRef) -> PyResult +fn get_int_or_index(vm: &VirtualMachine, code: FormatType, arg: PyObjectRef) -> PyResult where - T: PrimInt + for<'a> TryFrom<&'a BigInt>, + T: PrimInt + fmt::Display + for<'a> TryFrom<&'a BigInt>, { let index = arg .try_index_opt(vm) .unwrap_or_else(|| Err(new_struct_error(vm, "required argument is not an integer")))?; - index - .try_to_primitive(vm) - .map_err(|_| new_struct_error(vm, "argument out of range")) + index.try_to_primitive(vm).map_err(|_| { + // A pointer is converted rather than checked against the range of a + // named format, so what it reports is the conversion failing. + let msg = if code == FormatType::VoidP { + "int too large to convert".to_owned() + } else { + format!( + "'{}' format requires {} <= number <= {}", + code as u8 as char, + T::min_value(), + T::max_value() + ) + }; + new_struct_error(vm, msg) + }) } make_pack_prim_int!(i8); @@ -620,6 +638,7 @@ macro_rules! make_pack_float { impl Packable for $T { fn pack( vm: &VirtualMachine, + _code: FormatType, arg: PyObjectRef, data: &mut [u8], ) -> PyResult<()> { @@ -648,7 +667,12 @@ make_pack_float!(f32, "f"); make_pack_float!(f64, "d"); impl Packable for f16 { - fn pack(vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8]) -> PyResult<()> { + fn pack( + vm: &VirtualMachine, + _code: FormatType, + arg: PyObjectRef, + data: &mut [u8], + ) -> PyResult<()> { let f_64 = ArgIntoFloat::try_from_object(vm, arg)?.into_float(); // "from_f64 should be preferred in any non-`const` context" except it gives the wrong result :/ let f_16 = Self::from_f64_const(f_64); @@ -666,8 +690,13 @@ impl Packable for f16 { } impl Packable for *mut raw::c_void { - fn pack(vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8]) -> PyResult<()> { - usize::pack::(vm, arg, data) + fn pack( + vm: &VirtualMachine, + code: FormatType, + arg: PyObjectRef, + data: &mut [u8], + ) -> PyResult<()> { + usize::pack::(vm, code, arg, data) } fn unpack(vm: &VirtualMachine, rdr: &[u8]) -> PyObjectRef { @@ -676,7 +705,12 @@ impl Packable for *mut raw::c_void { } impl Packable for bool { - fn pack(vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8]) -> PyResult<()> { + fn pack( + vm: &VirtualMachine, + _code: FormatType, + arg: PyObjectRef, + data: &mut [u8], + ) -> PyResult<()> { let v = ArgIntoBool::try_from_object(vm, arg)?.into_bool() as u8; v.pack_int::(data); Ok(()) @@ -688,7 +722,12 @@ impl Packable for bool { } } -fn pack_char(vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8]) -> PyResult<()> { +fn pack_char( + vm: &VirtualMachine, + _code: FormatType, + arg: PyObjectRef, + data: &mut [u8], +) -> PyResult<()> { let v = PyBytesRef::try_from_object(vm, arg)?; let ch = *v .as_bytes() diff --git a/crates/vm/src/builtins/bytearray.rs b/crates/vm/src/builtins/bytearray.rs index 9be51a370..93046b493 100644 --- a/crates/vm/src/builtins/bytearray.rs +++ b/crates/vm/src/builtins/bytearray.rs @@ -354,7 +354,10 @@ impl PyByteArray { #[pymethod] fn join(&self, iter: ArgIterable, vm: &VirtualMachine) -> PyResult { - Ok(self.inner().join(iter, vm)?.into()) + // Driving the iterable runs Python, which can reach this bytearray, + // so the separator is taken by value rather than left borrowed. + let separator = self.inner().clone(); + Ok(separator.join(iter, vm)?.into()) } #[pymethod] @@ -497,8 +500,8 @@ impl PyByteArray { } #[pymethod] - fn zfill(&self, width: isize) -> Self { - self.inner().zfill(width).into() + fn zfill(&self, width: isize, vm: &VirtualMachine) -> PyResult { + Ok(self.inner().zfill(width, vm)?.into()) } #[pymethod] @@ -532,7 +535,10 @@ impl PyByteArray { } fn __mod__(&self, values: PyObjectRef, vm: &VirtualMachine) -> PyResult { - let formatted = self.inner().cformat(values, vm)?; + // Formatting calls the values' conversion methods, which can reach + // this bytearray, so the format is taken by value. + let format = self.inner().clone(); + let formatted = format.cformat(values, vm)?; Ok(formatted.into()) } @@ -778,8 +784,9 @@ impl BufferResizeGuard for PyByteArray { type Resizable<'a> = PyRwLockWriteGuard<'a, PyBytesInner>; fn try_resizable_opt(&self) -> Option> { - let w = self.inner.write(); - (self.exports.load(Ordering::SeqCst) == 0).then_some(w) + // An export is a borrow someone else still holds, so it is answered + // before the lock rather than by waiting on it. + (self.exports.load(Ordering::SeqCst) == 0).then(|| self.inner.write()) } } diff --git a/crates/vm/src/builtins/bytes.rs b/crates/vm/src/builtins/bytes.rs index d62b873bc..48c0e4312 100644 --- a/crates/vm/src/builtins/bytes.rs +++ b/crates/vm/src/builtins/bytes.rs @@ -505,8 +505,8 @@ impl PyBytes { } #[pymethod] - fn zfill(&self, width: isize) -> Self { - self.inner.zfill(width).into() + fn zfill(&self, width: isize, vm: &VirtualMachine) -> PyResult { + Ok(self.inner.zfill(width, vm)?.into()) } #[pymethod] diff --git a/crates/vm/src/builtins/memory.rs b/crates/vm/src/builtins/memory.rs index 31f997157..a4c29fe44 100644 --- a/crates/vm/src/builtins/memory.rs +++ b/crates/vm/src/builtins/memory.rs @@ -1,6 +1,6 @@ use super::{ PositionIterInternal, PyBytes, PyBytesRef, PyGenericAlias, PyInt, PyListRef, PySlice, PyStr, - PyTuple, PyTupleRef, PyType, PyTypeRef, PyUtf8StrRef, iter::builtins_iter, + PyStrRef, PyTuple, PyTupleRef, PyType, PyTypeRef, PyUtf8StrRef, iter::builtins_iter, }; use crate::common::lock::LazyLock; use crate::{ @@ -192,6 +192,11 @@ impl PyMemoryView { } } + /// The object this view looks at, whose storage it borrows. + pub fn viewed_object(&self) -> &PyObject { + &self.buffer.obj + } + fn try_not_released(&self, vm: &VirtualMachine) -> PyResult<()> { if self.released.load() { Err(vm.new_value_error("operation forbidden on released memoryview object")) @@ -826,10 +831,32 @@ impl PyMemoryView { } #[pymethod] - fn tobytes(&self, vm: &VirtualMachine) -> PyResult { + fn tobytes(&self, args: ToBytesArgs, vm: &VirtualMachine) -> PyResult { self.try_not_released(vm)?; + let order = match &args.order { + None => Order::C, + Some(order) => match order.to_str() { + Some("C") => Order::C, + Some("F") => Order::Fortran, + Some("A") => Order::Any, + _ => return Err(vm.new_value_error("order must be 'C', 'F' or 'A'")), + }, + }; + let mut v = vec![]; - self.append_to(&mut v); + // 'A' asks for the memory as it is laid out, which is what appending a + // contiguous view does. Only a Fortran walk of a view that is not + // already Fortran-contiguous reorders anything, and a view of fewer + // than two dimensions has one layout under either name. + if order == Order::Fortran && self.desc.ndim() > 1 { + v.reserve(self.desc.len); + let bytes = &*self.buffer.obj_bytes(); + self.desc.for_each_segment_fortran(|range| { + v.extend_from_slice(&bytes[range.start as usize..range.end as usize]); + }); + } else { + self.append_to(&mut v); + } Ok(PyBytes::from(v).into_ref(&vm.ctx)) } @@ -925,10 +952,17 @@ impl PyMemoryView { fn cast_to_1d(&self, format: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { let format_str = format.as_str(); - if Self::native_fmtchar(format_str).is_none() { + let Some(dest_char) = Self::native_fmtchar(format_str) else { return Err(vm.new_value_error( "memoryview: destination format must be a native single character format prefixed with an optional '@'", )); + }; + // One side has to be bytes. Casting between two item types would + // reinterpret the items rather than re-divide the memory, and the + // source items were written by something that chose their type. + let source_is_bytes = Self::native_fmtchar(&self.desc.format).is_some_and(is_byte_fmtchar); + if !source_is_bytes && !is_byte_fmtchar(dest_char) { + return Err(vm.new_type_error("memoryview: cannot cast between two non-byte formats")); } let format_spec = Self::parse_format(format_str, vm)?; let itemsize = format_spec.size(); @@ -994,7 +1028,7 @@ impl PyMemoryView { let mut other = self.cast_to_1d(format, vm)?; let itemsize = other.desc.itemsize; - // 0 ndim is single item + // 0 ndim is single item, so the buffer has to be that one item if shape_ndim == 0 { if itemsize != other.desc.len { return Err( @@ -1002,7 +1036,6 @@ impl PyMemoryView { ); } other.desc.dim_desc = vec![]; - other.desc.len = itemsize; return Ok(other.into_ref(&vm.ctx)); } @@ -1010,7 +1043,19 @@ impl PyMemoryView { let mut dim_descriptor = Vec::with_capacity(shape_ndim); for x in shape { - let x = usize::try_from_borrowed_object(vm, x)?; + let x = x + .downcast_ref::() + .ok_or_else(|| { + vm.new_type_error("memoryview.cast(): elements of shape must be integers") + })? + .try_to_primitive::(vm) + .ok() + .filter(|x| *x > 0) + .ok_or_else(|| { + vm.new_value_error( + "memoryview.cast(): elements of shape must be integers > 0", + ) + })?; if x > isize::MAX as usize / product_shape { return Err(vm.new_value_error("memoryview.cast(): product(shape) > SSIZE_MAX")); @@ -1084,6 +1129,20 @@ impl Py { } } +#[derive(FromArgs)] +struct ToBytesArgs { + #[pyarg(any, default)] + order: Option, +} + +/// The layout a copy of a view is written in. +#[derive(PartialEq, Eq)] +enum Order { + C, + Fortran, + Any, +} + #[derive(FromArgs)] struct CastArgs { #[pyarg(any)] @@ -1242,7 +1301,9 @@ impl Hashable for PyMemoryView { if !zelf.desc.readonly { return Err(vm.new_value_error("cannot hash writable memoryview object")); } - if !matches!(&*zelf.desc.format, "B" | "b" | "c") { + // The hash is over the bytes, so it agrees with the hash of the same + // bytes only where an item is a byte. + if !Self::native_fmtchar(&zelf.desc.format).is_some_and(is_byte_fmtchar) { return Err( vm.new_value_error("memoryview: hashing is restricted to formats 'B', 'b' or 'c'") ); @@ -1496,6 +1557,10 @@ fn format_unpack( }) } +/// Whether `ch` names a format whose items are single bytes. +const fn is_byte_fmtchar(ch: u8) -> bool { + matches!(ch, b'c' | b'b' | b'B') +} fn is_equiv_shape(a: &BufferDescriptor, b: &BufferDescriptor) -> bool { if a.ndim() != b.ndim() { return false; diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index 8a95dcb64..6e774f7e6 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -1320,11 +1320,13 @@ impl PyStr { } #[pymethod] - fn zfill(&self, width: isize) -> Wtf8Buf { - unsafe { - // SAFETY: this is safe-guaranteed because the original self.as_wtf8() is valid wtf8 - Wtf8Buf::from_bytes_unchecked(self.as_wtf8().py_zfill(width)) - } + fn zfill(&self, width: isize, vm: &VirtualMachine) -> PyResult { + let filled = self + .as_wtf8() + .py_zfill(width) + .ok_or_else(|| vm.new_memory_error(""))?; + // SAFETY: this is safe-guaranteed because the original self.as_wtf8() is valid wtf8 + Ok(unsafe { Wtf8Buf::from_bytes_unchecked(filled) }) } #[inline] @@ -1332,7 +1334,7 @@ impl PyStr { &self, width: isize, fillchar: OptionalArg, - pad: fn(&Wtf8, usize, CodePoint, usize) -> Wtf8Buf, + pad: fn(&Wtf8, usize, CodePoint, usize) -> Option, vm: &VirtualMachine, ) -> PyResult { let fillchar = fillchar.map_or(Ok(' '.into()), |ref s| { @@ -1340,11 +1342,11 @@ impl PyStr { vm.new_type_error("The fill character must be exactly one character long") }) })?; - Ok(if self.len() as isize >= width { - self.as_wtf8().to_owned() - } else { - pad(self.as_wtf8(), width as usize, fillchar, self.len()) - }) + if self.len() as isize >= width { + return Ok(self.as_wtf8().to_owned()); + } + pad(self.as_wtf8(), width as usize, fillchar, self.len()) + .ok_or_else(|| vm.new_memory_error("")) } #[pymethod] @@ -2214,6 +2216,12 @@ impl AnyStrContainer for String { Self::with_capacity(capacity) } + fn try_with_capacity(capacity: usize) -> Option { + let mut s = Self::new(); + s.try_reserve_exact(capacity).ok()?; + Some(s) + } + fn push_str(&mut self, other: &str) { Self::push_str(self, other) } @@ -2327,6 +2335,12 @@ impl AnyStrContainer for Wtf8Buf { Self::with_capacity(capacity) } + fn try_with_capacity(capacity: usize) -> Option { + let mut s = Self::new(); + s.try_reserve_exact(capacity).ok()?; + Some(s) + } + fn push_str(&mut self, other: &Wtf8) { self.push_wtf8(other) } @@ -2447,6 +2461,12 @@ impl AnyStrContainer for AsciiString { Self::with_capacity(capacity) } + fn try_with_capacity(capacity: usize) -> Option { + let mut v = Vec::new(); + v.try_reserve_exact(capacity).ok()?; + Some(Self::from(v)) + } + fn push_str(&mut self, other: &AsciiStr) { Self::push_str(self, other) } diff --git a/crates/vm/src/builtins/tuple.rs b/crates/vm/src/builtins/tuple.rs index 7af176840..d510e3532 100644 --- a/crates/vm/src/builtins/tuple.rs +++ b/crates/vm/src/builtins/tuple.rs @@ -342,10 +342,6 @@ impl PyTuple { } } - pub(crate) fn new_marshal_placeholder(len: usize, ctx: &Context) -> PyRef { - Self::new_ref(vec![ctx.none(); len], ctx) - } - /// # Safety /// This tuple must be a marshal placeholder which has not escaped the /// decoder, and `index` must not have been replaced previously. diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index 3c79ed329..65a9dc0a0 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -78,7 +78,7 @@ impl ByteInnerNewOptions { } else { size as usize }; - Ok(vec![0; size].into()) + Ok(vm.new_zeroed_bytes(size)?.into()) } fn handle_object_fallback(obj: PyObjectRef, vm: &VirtualMachine) -> PyResult { @@ -576,16 +576,15 @@ impl PyBytesInner { fn _pad( &self, options: ByteInnerPaddingOptions, - pad: fn(&[u8], usize, u8, usize) -> Vec, + pad: PadFn, vm: &VirtualMachine, ) -> PyResult> { let (width, fillchar) = options.get_value("center", vm)?; let len = self.len(); - Ok(if len as isize >= width { - Vec::from(&self.elements[..]) - } else { - pad(&self.elements, width as usize, fillchar, len) - }) + if len as isize >= width { + return Ok(Vec::from(&self.elements[..])); + } + pad(&self.elements, width as usize, fillchar, len).ok_or_else(|| vm.new_memory_error("")) } pub fn center( @@ -821,8 +820,10 @@ impl PyBytesInner { self.elements.py_bytes_splitlines(options, into_wrapper) } - pub fn zfill(&self, width: isize) -> Vec { - self.elements.py_zfill(width) + pub fn zfill(&self, width: isize, vm: &VirtualMachine) -> PyResult> { + self.elements + .py_zfill(width) + .ok_or_else(|| vm.new_memory_error("")) } // len(self)>=1, from="", len(to)>=1, max_count>=1 @@ -1077,11 +1078,21 @@ impl AnyStrContainer<[u8]> for Vec { Self::with_capacity(capacity) } + fn try_with_capacity(capacity: usize) -> Option { + let mut v = Self::new(); + v.try_reserve_exact(capacity).ok()?; + Some(v) + } + fn push_str(&mut self, other: &[u8]) { self.extend(other) } } +/// A padding function from `AnyStr`, returning `None` for a width whose result +/// cannot be allocated. +type PadFn = fn(&[u8], usize, u8, usize) -> Option>; + const ASCII_WHITESPACES: [u8; 6] = [0x20, 0x09, 0x0a, 0x0c, 0x0d, 0x0b]; impl anystr::AnyChar for u8 { diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 69417ee61..d102b9a6d 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -9362,9 +9362,14 @@ impl ExecutingFrame<'_> { if has_data_descr { // Check for member descriptor (slot access) + // The slot offset only means anything on the layout the + // descriptor was defined for; the specialized instruction + // guards on the type version alone, so what descr_get() + // checks on every access has to be checked here instead. if let Some(ref descr) = cls_attr && let Some(member_descr) = descr.downcast_ref::() && let MemberGetter::Offset(offset) = member_descr.member.getter + && cls.fast_issubclass(&member_descr.common.typ) { unsafe { self.code @@ -11099,9 +11104,12 @@ impl ExecutingFrame<'_> { if has_data_descr { // Check for member descriptor (slot access) + // As in the load specialization, the offset is only valid for + // instances of the type the descriptor belongs to. if let Some(ref descr) = cls_attr && let Some(member_descr) = descr.downcast_ref::() && let MemberGetter::Offset(offset) = member_descr.member.getter + && cls.fast_issubclass(&member_descr.common.typ) { unsafe { self.code diff --git a/crates/vm/src/function/buffer.rs b/crates/vm/src/function/buffer.rs index c73f27c04..dba97e9c7 100644 --- a/crates/vm/src/function/buffer.rs +++ b/crates/vm/src/function/buffer.rs @@ -49,11 +49,41 @@ impl ArgBytesLike { f(&self.borrow_buf()) } + /// The bytes to hand to an operation that may wait, and whatever keeps + /// them readable while it does. + /// + /// `borrow_buf` may answer with a lock that every other thread writing to + /// the same object waits on, and a thread waiting on a lock never reaches + /// a safepoint, so keeping one across a wait for a peer, a pipe or a + /// signal stops the world from being stopped at all. Bytes reached that + /// way are copied out first. Bytes that lock nothing -- an immutable + /// object's -- are borrowed where they lie, which is all CPython holds in + /// either case. + pub fn borrow_buf_unlocked(&self, vm: &VirtualMachine) -> PyResult> { + let borrowed = self.borrow_buf(); + if !borrowed.is_locked() { + return Ok(UnlockedBuf::Borrowed(borrowed)); + } + let mut copy = Vec::new(); + copy.try_reserve_exact(borrowed.len()) + .map_err(|_| vm.new_memory_error(""))?; + copy.extend_from_slice(&borrowed); + Ok(UnlockedBuf::Copied(copy)) + } + #[must_use] pub const fn len(&self) -> usize { self.0.desc.len } + /// The width of one item. Callers that read the buffer as bytes rather + /// than as whatever it holds have to ask, since a contiguous buffer of + /// wider items is contiguous all the same. + #[must_use] + pub const fn itemsize(&self) -> usize { + self.0.desc.itemsize + } + #[must_use] pub const fn is_empty(&self) -> bool { self.len() == 0 @@ -63,6 +93,16 @@ impl ArgBytesLike { pub fn as_object(&self) -> &PyObject { &self.0.obj } + + /// The object whose storage is borrowed while this buffer is read: a view + /// borrows the object it looks at, not itself. + #[must_use] + pub fn source_object(&self) -> &PyObject { + self.0 + .obj + .downcast_ref::() + .map_or(&self.0.obj, |view| view.viewed_object()) + } } impl From for PyBuffer { @@ -113,6 +153,24 @@ impl<'a> TryFromBorrowedObject<'a> for ArgContiguousBytesLike { } } +/// Bytes that stay readable across a wait, from [`ArgBytesLike::borrow_buf_unlocked`]. +#[derive(Debug)] +pub enum UnlockedBuf<'a> { + Borrowed(BorrowedValue<'a, [u8]>), + Copied(Vec), +} + +impl core::ops::Deref for UnlockedBuf<'_> { + type Target = [u8]; + + fn deref(&self) -> &[u8] { + match self { + Self::Borrowed(b) => b, + Self::Copied(v) => v, + } + } +} + /// A memory buffer, read-write access. Like the `w*` format code for `PyArg_Parse` in CPython. #[derive(Debug, Traverse)] pub struct ArgMemoryBuffer(PyBuffer); @@ -139,6 +197,16 @@ impl ArgMemoryBuffer { pub const fn is_empty(&self) -> bool { self.len() == 0 } + + /// The object whose storage is borrowed while this buffer is written: a + /// view borrows the object it looks at, not itself. + #[must_use] + pub fn source_object(&self) -> &PyObject { + self.0 + .obj + .downcast_ref::() + .map_or(&self.0.obj, |view| view.viewed_object()) + } } impl From for PyBuffer { diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index a9b8c7be1..e5eb37589 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -8,7 +8,6 @@ use crate::object::{GC_NO_OWNER, GC_PERMANENT, GC_UNTRACKED, GcLink, GcOwner}; use crate::{AsObject, PyObject, PyObjectRef}; use core::ptr::NonNull; use core::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicUsize, Ordering}; -use std::collections::HashSet; fn elapsed_secs( #[cfg(target_arch = "wasm32")] _start: (), @@ -155,6 +154,45 @@ fn is_owned_by(obj: &PyObject, owner: GcOwner) -> bool { #[derive(Clone, Copy, PartialEq, Eq, Hash)] struct GcPtr(NonNull); +/// Hashing for the tables a collection keys by an object's address. +/// +/// The default hasher is SipHash, which buys resistance against a caller +/// choosing keys that collide. Nothing chooses these keys: they are addresses +/// this process handed out, and the tables live and die inside one collection. +/// What a collection needs from them is speed -- it hashes every tracked +/// object and every edge between them -- so this runs the address through a +/// handful of multiplies and shifts instead. The shifts are what earns the +/// speed: a table picks its bucket from the low bits, and an address arrives +/// with its low bits zeroed by alignment, so entropy has to be carried +/// downward or every object lands in the same few buckets. +#[derive(Default)] +struct GcPtrHasher(u64); + +impl core::hash::Hasher for GcPtrHasher { + fn finish(&self) -> u64 { + self.0 + } + + fn write_usize(&mut self, value: usize) { + let mut z = (value as u64).wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + self.0 = z ^ (z >> 31); + } + + fn write(&mut self, bytes: &[u8]) { + // Addresses reach this hasher through `write_usize`; a key hashed any + // other way still has to land somewhere sensible. + for &byte in bytes { + self.0 = (self.0 ^ u64::from(byte)).wrapping_mul(0x0100_0000_01B3); + } + } +} + +type GcBuildHasher = core::hash::BuildHasherDefault; +type GcSet = std::collections::HashSet; +type GcMap = std::collections::HashMap; + /// RAII barrier that parks every other thread for the pointer-reading phases /// of a collection and lets them run again before finalizers execute. /// @@ -556,14 +594,25 @@ impl GcState { retired.sort_unstable(); retired }; - let mut collecting: HashSet = HashSet::new(); + // The candidates and their reference counts go in one table, not a set + // beside a map: every edge in the heap is looked up here, and the two + // held the same keys, so a second table only bought a second hash of + // the same address. `candidate_ptrs` keeps them in a walkable order, + // since the counts are written while the candidates are read. + let mut gc_refs: GcMap = GcMap::default(); + let mut candidate_ptrs: Vec = Vec::new(); for gen_list in &gen_locks { for obj in gen_list.iter() { if retired.binary_search(&obj.gc_owner()).is_ok() { obj.set_gc_owner(GC_NO_OWNER); } - if obj.strong_count() > 0 && is_owned_by(obj, owner) { - collecting.insert(GcPtr(NonNull::from(obj))); + let strong_count = obj.strong_count(); + let ptr = GcPtr(NonNull::from(obj)); + if strong_count > 0 + && is_owned_by(obj, owner) + && gc_refs.insert(ptr, strong_count).is_none() + { + candidate_ptrs.push(ptr); } } } @@ -583,7 +632,7 @@ impl GcState { .retain(|tag| retired.binary_search(tag).is_err()); } - if collecting.is_empty() { + if candidate_ptrs.is_empty() { // Reset counts for generations whose objects were promoted away. // For gen2 (oldest), survivors stay in-place so don't reset gen2 count. let reset_end = if generation >= 2 { 2 } else { generation + 1 }; @@ -602,26 +651,10 @@ impl GcState { }; } - let candidates = collecting.len(); + let candidates = candidate_ptrs.len(); if debug.contains(GcDebugFlags::STATS) { - eprintln!( - "gc: collecting {} objects from generations 0..={}", - collecting.len(), - generation - ); - } - - // Step 2: Build gc_refs map (copy reference counts) - let mut gc_refs: std::collections::HashMap = std::collections::HashMap::new(); - - #[expect( - clippy::iter_over_hash_type, - reason = "Iteration order doesn't matter here" - )] - for &ptr in &collecting { - let obj = unsafe { ptr.0.as_ref() }; - gc_refs.insert(ptr, obj.strong_count()); + eprintln!("gc: collecting {candidates} objects from generations 0..={generation}"); } // Step 3: Subtract internal references @@ -630,32 +663,31 @@ impl GcState { // of each object's children. Without this, a dict whose write lock is // held during one traversal but not the other can yield inconsistent // results, causing live objects to be incorrectly collected. - let mut referents_map: std::collections::HashMap>> = - std::collections::HashMap::new(); + // + // Every object's referents go in one buffer, with each object holding + // the range that is its own: a vector each would be an allocation per + // tracked object, and the collection wants them all at once anyway. + let mut referent_ptrs: Vec> = Vec::new(); + let mut referent_ranges: GcMap = GcMap::default(); - #[expect( - clippy::iter_over_hash_type, - reason = "Iteration order doesn't matter here" - )] - for &ptr in &collecting { + for &ptr in &candidate_ptrs { let obj = unsafe { ptr.0.as_ref() }; if obj.strong_count() == 0 { continue; } - let referent_ptrs = unsafe { obj.gc_get_referent_ptrs() }; - referents_map.insert(ptr, referent_ptrs.clone()); - for child_ptr in referent_ptrs { - let gc_ptr = GcPtr(child_ptr); - if collecting.contains(&gc_ptr) - && let Some(refs) = gc_refs.get_mut(&gc_ptr) - { + let start = referent_ptrs.len(); + unsafe { obj.gc_extend_referent_ptrs(&mut referent_ptrs) }; + let end = referent_ptrs.len(); + for &child_ptr in &referent_ptrs[start..end] { + if let Some(refs) = gc_refs.get_mut(&GcPtr(child_ptr)) { *refs = refs.saturating_sub(1); } } + referent_ranges.insert(ptr, (start, end)); } // Step 4: Find reachable objects (gc_refs > 0) and traverse from them - let mut reachable: HashSet = HashSet::new(); + let mut reachable: GcSet = GcSet::default(); let mut worklist: Vec = Vec::new(); #[expect( @@ -672,16 +704,21 @@ impl GcState { while let Some(ptr) = worklist.pop() { let obj = unsafe { ptr.0.as_ref() }; if obj.is_gc_tracked() { - // Reuse the pre-computed referent pointers from step 3. - // For objects that were skipped in step 3 (strong_count was 0), - // compute them now as a fallback. - let referent_ptrs = referents_map - .get(&ptr) - .cloned() - .unwrap_or_else(|| unsafe { obj.gc_get_referent_ptrs() }); - for child_ptr in referent_ptrs { + // Reuse the pre-computed referent pointers from step 3, in + // place: copying them out again costs a second pass over every + // edge in the heap. Objects skipped in step 3 (strong_count was + // 0) have none stored and are traversed here instead. + let computed; + let children: &[NonNull] = match referent_ranges.get(&ptr) { + Some(&(start, end)) => &referent_ptrs[start..end], + None => { + computed = unsafe { obj.gc_get_referent_ptrs() }; + &computed + } + }; + for &child_ptr in children { let gc_ptr = GcPtr(child_ptr); - if collecting.contains(&gc_ptr) && reachable.insert(gc_ptr) { + if gc_refs.contains_key(&gc_ptr) && reachable.insert(gc_ptr) { worklist.push(gc_ptr); } } @@ -689,7 +726,11 @@ impl GcState { } // Step 5: Find unreachable objects - let unreachable: Vec = collecting.difference(&reachable).copied().collect(); + let unreachable: Vec = candidate_ptrs + .iter() + .filter(|ptr| !reachable.contains(ptr)) + .copied() + .collect(); // With the world stopped, every frame on any thread's call stack is a // live root that is externally referenced and must have been @@ -702,7 +743,7 @@ impl GcState { // set_current_frame_nosave), not top_frame. #[cfg(all(unix, feature = "threading", debug_assertions))] if stw.is_stopped() { - let unreachable_set: HashSet = unreachable.iter().copied().collect(); + let unreachable_set: GcSet = unreachable.iter().copied().collect(); let mut cur = crate::vm::thread::get_current_frame(); while !cur.is_null() { let iframe = unsafe { &*cur }; @@ -802,7 +843,7 @@ impl GcState { } // 6b: Record initial strong counts (for resurrection detection) - let initial_counts: std::collections::HashMap = unreachable_refs + let initial_counts: GcMap = unreachable_refs .iter() .map(|obj| { let ptr = GcPtr(core::ptr::NonNull::from(obj.as_ref())); @@ -833,8 +874,8 @@ impl GcState { } // Detect resurrection - let mut resurrected_set: HashSet = HashSet::new(); - let unreachable_set: HashSet = unreachable.iter().copied().collect(); + let mut resurrected_set: GcSet = GcSet::default(); + let unreachable_set: GcSet = unreachable.iter().copied().collect(); for obj in &unreachable_refs { let ptr = GcPtr(core::ptr::NonNull::from(obj.as_ref())); @@ -874,7 +915,7 @@ impl GcState { // Compute collected count (exclude instance dicts in truly_dead) let collected = { - let dead_ptrs: HashSet = truly_dead + let dead_ptrs: GcSet = truly_dead .iter() .map(|obj| obj.as_ref() as *const PyObject as usize) .collect(); @@ -932,10 +973,9 @@ impl GcState { // never be observable through the generation lists, or another // thread could obtain a strong reference via gc.get_objects() // and access the cleared payload. - let mut late_resurrected: HashSet = HashSet::new(); + let mut late_resurrected: GcSet = GcSet::default(); if !save_all { - let mut expected_counts: std::collections::HashMap = - std::collections::HashMap::new(); + let mut expected_counts: GcMap = GcMap::default(); for obj_ref in &truly_dead { let obj = obj_ref.as_ref(); if obj.is_gc_tracked() { @@ -949,8 +989,7 @@ impl GcState { // the dead set; any surplus in strong_count means another thread // grabbed a reference before untracking (late resurrection) and // the object must not be cleared. - let mut referents: std::collections::HashMap>> = - std::collections::HashMap::new(); + let mut referents: GcMap>> = GcMap::default(); for obj_ref in &truly_dead { let referent_ptrs = unsafe { obj_ref.gc_get_referent_ptrs() }; for child_ptr in &referent_ptrs { diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index 1e36d57ab..bdacb7c5b 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -1957,11 +1957,20 @@ impl PyObject { /// and its contents haven't been modified. pub unsafe fn gc_get_referent_ptrs(&self) -> Vec> { let mut result = Vec::new(); + unsafe { self.gc_extend_referent_ptrs(&mut result) }; + result + } + + /// Append this object's referents to `out`, for a caller that holds many + /// objects' referents in one buffer rather than one buffer each. + /// + /// # Safety + /// Same as [`Self::gc_get_referent_ptrs`]. + pub unsafe fn gc_extend_referent_ptrs(&self, out: &mut Vec>) { // Traverse the entire object including dict and slots self.0.traverse(&mut |child: &Self| { - result.push(NonNull::from(child)); + out.push(NonNull::from(child)); }); - result } /// Pop edges from this object for cycle breaking. diff --git a/crates/vm/src/protocol/buffer.rs b/crates/vm/src/protocol/buffer.rs index cf60775f7..050c568b7 100644 --- a/crates/vm/src/protocol/buffer.rs +++ b/crates/vm/src/protocol/buffer.rs @@ -641,6 +641,47 @@ impl BufferDescriptor { } } + /// Visit each item's byte range with the *first* dimension varying + /// fastest, which is the order a Fortran-ordered copy is written in. + /// `for_each_segment` visits in the opposite order and can hand over whole + /// rows at once; here every item is its own range, since consecutive items + /// in this order are a row apart. + pub fn for_each_segment_fortran(&self, mut f: F) + where + F: FnMut(Range), + { + if self.len == 0 { + return; + } + if self.ndim() == 0 { + f(self.offset..self.offset + self.itemsize as isize); + return; + } + let mut indices = vec![0usize; self.ndim()]; + loop { + let pos = self.offset + + indices + .iter() + .zip_eq(self.dim_desc.iter()) + .map(|(&i, &(_, stride, suboffset))| i as isize * stride + suboffset) + .sum::(); + f(pos..pos + self.itemsize as isize); + + let mut dim = 0; + loop { + indices[dim] += 1; + if indices[dim] < self.dim_desc[dim].0 { + break; + } + indices[dim] = 0; + dim += 1; + if dim == self.ndim() { + return; + } + } + } + } + fn _for_each_segment(&self, mut index: isize, dim: usize, f: &mut F) where F: FnMut(Range), diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index f32fce314..9aad883b0 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -1258,7 +1258,7 @@ mod _io { let current_size = self.readahead() as usize; - let mut out = vec![0u8; n]; + let mut out = vm.new_zeroed_bytes(n)?; let mut remaining = n; let mut written = 0; if current_size > 0 { @@ -1673,7 +1673,7 @@ mod _io { check_writable(&raw, vm)?; } - data.buffer = vec![0; buffer_size]; + data.buffer = vm.new_zeroed_bytes(buffer_size)?; if Self::READABLE { data.reset_read(); @@ -1938,7 +1938,7 @@ mod _io { if data.writable() { data.flush_rewind(vm)?; } - let mut v = vec![0; n]; + let mut v = vm.new_zeroed_bytes(n)?; data.reset_read(); let r = data .raw_read(Either::A(Some(&mut v)), 0..n, vm)? @@ -3364,14 +3364,17 @@ mod _io { *snapshot = Some((cookie.dec_flags, input_chunk.clone())); let decoded = vm.call_method(decoder, "decode", (input_chunk, cookie.need_eof))?; let decoded = check_decoded(decoded, vm)?; - let pos_is_valid = decoded - .as_wtf8() - .is_code_point_boundary(cookie.bytes_to_skip as usize); + // The position is stored both as a count of characters and as + // an offset in bytes, so both have to land inside what was + // just decoded: everything read back from here indexes it. + let num_to_skip = cookie.num_to_skip(); + let pos_is_valid = num_to_skip.chars <= decoded.char_len() + && decoded.as_wtf8().is_code_point_boundary(num_to_skip.bytes); textio.set_decoded_chars(Some(decoded)); if !pos_is_valid { return Err(vm.new_os_error("can't restore logical file position")); } - textio.decoded_chars_used = cookie.num_to_skip(); + textio.decoded_chars_used = num_to_skip; } else { textio.snapshot = Some((cookie.dec_flags, PyBytes::from(vec![]).into_ref(&vm.ctx))) } @@ -4813,8 +4816,20 @@ mod _io { } #[pymethod] - fn readinto(&self, obj: ArgMemoryBuffer, vm: &VirtualMachine) -> PyResult { - let mut buf = self.buffer(vm)?; + fn readinto(zelf: &Py, obj: ArgMemoryBuffer, vm: &VirtualMachine) -> PyResult { + // Reading locks this object, and a destination that views it locks + // it too, so such a destination is filled after the read is done. + if obj.source_object().is(zelf.as_object()) { + let mut data = vm.new_zeroed_bytes(obj.len())?; + let ret = zelf + .buffer(vm)? + .cursor + .read(&mut data) + .map_err(|_| vm.new_value_error("Error readinto from Take"))?; + obj.borrow_buf_mut()[..ret].copy_from_slice(&data[..ret]); + return Ok(ret); + } + let mut buf = zelf.buffer(vm)?; let ret = buf .cursor .read(&mut obj.borrow_buf_mut()) @@ -5767,7 +5782,7 @@ mod fileio { } let handle = zelf.get_fd(vm)?; let bytes = if let Some(read_byte) = read_byte.to_usize() { - let mut bytes = vec![0; read_byte]; + let mut bytes = vm.new_zeroed_bytes(read_byte)?; // Loop on EINTR (PEP 475) let n = loop { match vm.allow_threads(|| host_io::read_once(handle, &mut bytes)) { @@ -5811,6 +5826,26 @@ mod fileio { Ok(Some(bytes)) } + /// One `read()` into `buf`, retried on EINTR (PEP 475). `None` on EAGAIN. + fn read_once_into( + zelf: &Py, + handle: crt_fd::Borrowed<'_>, + buf: &mut [u8], + vm: &VirtualMachine, + ) -> PyResult> { + loop { + match vm.allow_threads(|| host_io::read_once(handle, buf)) { + Ok(n) => return Ok(Some(n)), + Err(e) if host_io::is_interrupted_error(&e) => { + vm.check_signals()?; + } + // Non-blocking mode: return None if EAGAIN + Err(e) if host_io::is_would_block_error(&e) => return Ok(None), + Err(e) => return Err(Self::io_error(zelf, e, vm)), + } + } + } + #[pymethod] fn readinto( zelf: &Py, @@ -5826,24 +5861,28 @@ mod fileio { let handle = zelf.get_fd(vm)?; - let mut buf = obj.borrow_buf_mut(); - // Loop on EINTR (PEP 475) - let ret = loop { - match vm.allow_threads(|| host_io::read_once(handle, &mut buf)) { - Ok(n) => break n, - Err(e) if host_io::is_interrupted_error(&e) => { - vm.check_signals()?; - continue; - } - // Non-blocking mode: return None if EAGAIN - Err(e) if host_io::is_would_block_error(&e) => { - return Ok(None); - } - Err(e) => return Err(Self::io_error(zelf, e, vm)), - } - }; + if host_io::reads_without_waiting(handle) { + // The read answers from the file itself, so it returns without + // waiting on anyone; write where the caller asked directly. + // Seekability is not the question -- a pipe on Windows seeks. + let mut buf = obj.borrow_buf_mut(); + return Self::read_once_into(zelf, handle, &mut buf, vm); + } - Ok(Some(ret)) + // A pipe, socket or terminal answers only when the other end + // writes, which may be never. Holding the export for the whole + // call is what keeps the target from being resized meanwhile, as a + // Py_buffer does; but reaching its bytes takes a lock that every + // other thread touching the same object waits on, and a thread + // waiting on a lock never reaches a safepoint, so holding that one + // across the wait stops the world from being stopped at all. Read + // aside and take the lock for the copy. + let mut scratch = vm.new_zeroed_bytes(obj.len())?; + let ret = Self::read_once_into(zelf, handle, &mut scratch, vm)?; + if let Some(n) = ret { + obj.borrow_buf_mut()[..n].copy_from_slice(&scratch[..n]); + } + Ok(ret) } #[pymethod] @@ -5861,9 +5900,14 @@ mod fileio { let handle = zelf.get_fd(vm)?; + // A pipe, socket or terminal takes the bytes only when the other + // end makes room, which may be never; see readinto above for what + // holding the source's lock across that wait costs. + let buf = obj.borrow_buf_unlocked(vm)?; + // Loop on EINTR (PEP 475) let len = loop { - match obj.with_ref(|b| vm.allow_threads(|| host_io::write_once(handle, b))) { + match vm.allow_threads(|| host_io::write_once(handle, &buf)) { Ok(n) => break n, Err(e) if host_io::is_interrupted_error(&e) => { vm.check_signals()?; diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index de942f947..79dce3d21 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -1207,9 +1207,9 @@ pub(crate) mod _thread { // fall back to top_iframe (may be a stack-allocated frame). let top = slot.top_frame.load(Ordering::Relaxed); if let Some(p) = core::ptr::NonNull::new(top) { - let py = unsafe { - &*Py::::from_payload_ptr(p.as_ptr()) - }; + // SAFETY: world stopped -> the owning thread is parked + // with this frame on its chain, so it is alive. + let py = unsafe { p.as_ref() }; Some((*id, py.to_owned())) } else { // Stack-allocated frame: materialize from top_iframe. diff --git a/crates/vm/src/stdlib/atexit.rs b/crates/vm/src/stdlib/atexit.rs index 891f8e543..0260b0f11 100644 --- a/crates/vm/src/stdlib/atexit.rs +++ b/crates/vm/src/stdlib/atexit.rs @@ -3,7 +3,9 @@ pub(crate) use atexit::module_def; #[pymodule] mod atexit { - use crate::{AsObject, PyObjectRef, PyResult, VirtualMachine, function::FuncArgs}; + use crate::{ + AsObject, PyObjectRef, PyResult, VirtualMachine, common::rc::PyRc, function::FuncArgs, + }; #[pyfunction] fn register(func: PyObjectRef, args: FuncArgs, vm: &VirtualMachine) -> PyObjectRef { @@ -11,7 +13,7 @@ mod atexit { vm.state .atexit_funcs .lock() - .insert(0, Box::new((func.clone(), args))); + .insert(0, PyRc::new((func.clone(), args))); func } @@ -29,24 +31,26 @@ mod atexit { funcs.len() as isize - 1 }; while i >= 0 { - let (cb, entry_ptr) = { + let entry = { let funcs = vm.state.atexit_funcs.lock(); if i as usize >= funcs.len() { i = funcs.len() as isize; i -= 1; continue; } - let entry = &funcs[i as usize]; - (entry.0.clone(), &**entry as *const (PyObjectRef, FuncArgs)) + // Keep the entry alive for as long as it is being compared, so + // it cannot be dropped and have its address handed to a + // callback registered from within __eq__. + funcs[i as usize].clone() }; // Lock released: __eq__ can safely call atexit functions - let eq = vm.bool_eq(&func, &cb)?; + let eq = vm.bool_eq(&func, &entry.0)?; if eq { // The entry may have moved during __eq__. Search backward by identity. let mut funcs = vm.state.atexit_funcs.lock(); let mut j = (funcs.len() as isize - 1).min(i); while j >= 0 { - if core::ptr::eq(&**funcs.get(j as usize).unwrap(), entry_ptr) { + if PyRc::ptr_eq(funcs.get(j as usize).unwrap(), &entry) { funcs.remove(j as usize); i = j; break; @@ -70,7 +74,7 @@ mod atexit { let funcs: Vec<_> = core::mem::take(&mut *vm.state.atexit_funcs.lock()); // Callbacks stored in LIFO order, iterate forward for entry in funcs { - let (func, args) = *entry; + let (func, args) = PyRc::try_unwrap(entry).unwrap_or_else(|e| (*e).clone()); if let Err(e) = func.call(args, vm) { let exit = e.fast_isinstance(vm.ctx.exceptions.system_exit); let msg = func diff --git a/crates/vm/src/stdlib/marshal.rs b/crates/vm/src/stdlib/marshal.rs index ca92b444a..08a8f589a 100644 --- a/crates/vm/src/stdlib/marshal.rs +++ b/crates/vm/src/stdlib/marshal.rs @@ -116,9 +116,6 @@ mod decl { )?; } - if !allow_code { - check_no_code(&value, vm)?; - } check_exact_type(&value, vm)?; let mut buf = Vec::new(); let mut refs = if version >= 3 { @@ -126,7 +123,7 @@ mod decl { } else { None }; - write_object(&mut buf, &value, &mut refs, version, vm)?; + write_object(&mut buf, &value, &mut refs, version, allow_code, vm)?; Ok(PyBytes::from(buf)) } @@ -185,6 +182,7 @@ mod decl { obj: &PyObjectRef, refs: &mut Option, version: i32, + allow_code: bool, vm: &VirtualMachine, ) -> PyResult<()> { write_object_depth( @@ -192,6 +190,7 @@ mod decl { obj, refs, version, + allow_code, vm, marshal::MAX_MARSHAL_STACK_DEPTH, ) @@ -202,6 +201,7 @@ mod decl { obj: &PyObjectRef, refs: &mut Option, version: i32, + allow_code: bool, vm: &VirtualMachine, depth: usize, ) -> PyResult<()> { @@ -322,20 +322,20 @@ mod decl { buf.write_u8(b'('); buf.write_u32(t.len() as u32); for elem in t.as_slice() { - write_object_depth(buf, elem, refs, version, vm, depth - 1)?; + write_object_depth(buf, elem, refs, version, allow_code, vm, depth - 1)?; } } else if let Some(l) = obj.downcast_ref::() { buf.write_u8(b'['); let items = l.borrow_vec(); buf.write_u32(items.len() as u32); for elem in items.iter() { - write_object_depth(buf, elem, refs, version, vm, depth - 1)?; + write_object_depth(buf, elem, refs, version, allow_code, vm, depth - 1)?; } } else if let Some(d) = obj.downcast_ref::() { buf.write_u8(b'{'); for (k, v) in d { - write_object_depth(buf, &k, refs, version, vm, depth - 1)?; - write_object_depth(buf, &v, refs, version, vm, depth - 1)?; + write_object_depth(buf, &k, refs, version, allow_code, vm, depth - 1)?; + write_object_depth(buf, &v, refs, version, allow_code, vm, depth - 1)?; } buf.write_u8(b'0'); // TYPE_NULL terminator } else if let Some(s) = obj.downcast_ref::() { @@ -343,16 +343,19 @@ mod decl { let elems = s.elements(); buf.write_u32(elems.len() as u32); for elem in &elems { - write_object_depth(buf, elem, refs, version, vm, depth - 1)?; + write_object_depth(buf, elem, refs, version, allow_code, vm, depth - 1)?; } } else if let Some(s) = obj.downcast_ref::() { buf.write_u8(b'>'); let elems = s.elements(); buf.write_u32(elems.len() as u32); for elem in &elems { - write_object_depth(buf, elem, refs, version, vm, depth - 1)?; + write_object_depth(buf, elem, refs, version, allow_code, vm, depth - 1)?; } } else if let Some(co) = obj.downcast_ref::() { + if !allow_code { + return Err(vm.new_value_error("marshalling code objects is disallowed")); + } buf.write_u8(b'c'); // `Literal` holds the exact object a constant was built from, so // route `co_consts` back through the object writer: it reaches the @@ -360,7 +363,7 @@ mod decl { // reference table the reader indexes against. marshal::serialize_code_with(buf, &co.code, |buf, constant| { let constant = PyObjectRef::from(constant.clone()); - write_object_depth(buf, &constant, refs, version, vm, depth - 1) + write_object_depth(buf, &constant, refs, version, allow_code, vm, depth - 1) })?; } else if let Some(sl) = obj.downcast_ref::() { if version < 5 { @@ -373,15 +376,17 @@ mod decl { sl.start.as_ref().unwrap_or(&none), refs, version, + allow_code, vm, depth - 1, )?; - write_object_depth(buf, &sl.stop, refs, version, vm, depth - 1)?; + write_object_depth(buf, &sl.stop, refs, version, allow_code, vm, depth - 1)?; write_object_depth( buf, sl.step.as_ref().unwrap_or(&none), refs, version, + allow_code, vm, depth - 1, )?; @@ -431,14 +436,36 @@ mod decl { struct PyMarshalBag<'a> { vm: &'a VirtualMachine, pending_error: &'a RefCell>, + allow_code: bool, } impl<'a> PyMarshalBag<'a> { fn new( vm: &'a VirtualMachine, pending_error: &'a RefCell>, + allow_code: bool, ) -> Self { - Self { vm, pending_error } + Self { + vm, + pending_error, + allow_code, + } + } + + /// Room for a container the decoder publishes before it reads what + /// goes in it. The length is the input's to choose, so the room is + /// asked for rather than assumed: a length no allocator can serve is + /// a MemoryError, not an aborted process. + fn placeholder_elements( + &self, + len: usize, + ) -> Result, marshal::MarshalError> { + let mut elements = Vec::new(); + elements + .try_reserve_exact(len) + .map_err(|_| self.remember_python_error(self.vm.new_memory_error("")))?; + elements.resize(len, self.vm.ctx.none()); + Ok(elements) } fn remember_python_error(&self, error: PyBaseExceptionRef) -> marshal::MarshalError { @@ -484,8 +511,12 @@ mod decl { fn make_tuple(&self, elements: impl Iterator) -> Self::Value { self.vm.ctx.new_tuple(elements.collect()).into() } - fn make_tuple_placeholder(&self, len: usize) -> Option { - Some(PyTuple::new_marshal_placeholder(len, &self.vm.ctx).into()) + fn make_tuple_placeholder( + &self, + len: usize, + ) -> Result, marshal::MarshalError> { + let elements = self.placeholder_elements(len)?; + Ok(Some(PyTuple::new_ref(elements, &self.vm.ctx).into())) } fn set_tuple_item( &self, @@ -501,8 +532,14 @@ mod decl { unsafe { tuple.set_marshal_item(index, value) }; Ok(()) } - fn make_code(&self, code: CodeObject) -> Self::Value { - crate::builtins::PyCode::new_ref_with_bag(self.vm, code).into() + fn make_code(&self, code: CodeObject) -> Result { + if !self.allow_code { + return Err(self.remember_python_error( + self.vm + .new_value_error("unmarshalling code objects is disallowed"), + )); + } + Ok(crate::builtins::PyCode::new_ref_with_bag(self.vm, code).into()) } fn make_stop_iter(&self) -> Result { Ok(self.vm.ctx.exceptions.stop_iteration.to_owned().into()) @@ -513,8 +550,12 @@ mod decl { ) -> Result { Ok(self.vm.ctx.new_list(it.collect()).into()) } - fn make_list_placeholder(&self, len: usize) -> Option { - Some(self.vm.ctx.new_list(vec![self.vm.ctx.none(); len]).into()) + fn make_list_placeholder( + &self, + len: usize, + ) -> Result, marshal::MarshalError> { + let elements = self.placeholder_elements(len)?; + Ok(Some(self.vm.ctx.new_list(elements).into())) } fn set_list_item( &self, @@ -635,13 +676,20 @@ mod decl { fn deserialize_value( rdr: &mut impl marshal::Read, + allow_code: bool, vm: &VirtualMachine, ) -> PyResult { let pending_error = RefCell::new(None); - match marshal::deserialize_value(rdr, PyMarshalBag::new(vm, &pending_error)) { + match marshal::deserialize_value(rdr, PyMarshalBag::new(vm, &pending_error, allow_code)) { Ok(value) => Ok(value), Err(error) => Err(pending_error.into_inner().unwrap_or_else(|| match error { marshal::MarshalError::Eof => vm.new_eof_error("marshal data too short"), + error @ marshal::MarshalError::NullObject => vm.new_type_error(error.to_string()), + error @ (marshal::MarshalError::BadSize(_) + | marshal::MarshalError::UnknownType + | marshal::MarshalError::InvalidRef) => { + vm.new_value_error(format!("bad marshal data ({error})")) + } _ => vm.new_value_error("bad marshal data"), })), } @@ -661,11 +709,7 @@ mod decl { let LoadsArgs { data, allow_code } = args; let buf = data.borrow_buf(); - let result = deserialize_value(&mut &buf[..], vm)?; - if !allow_code { - check_no_code(&result, vm)?; - } - Ok(result) + deserialize_value(&mut &buf[..], allow_code, vm) } #[derive(FromArgs)] @@ -685,54 +729,25 @@ mod decl { .try_into_value::(vm)?; let read_res = vm.call_method(&args.f, "read", ())?; let bytes = ArgBytesLike::try_from_object(vm, read_res)?; - let buf = bytes.borrow_buf(); - let mut rdr: &[u8] = &buf; - let len_before = rdr.len(); - let result = deserialize_value(&mut rdr, vm)?; - let consumed = len_before - rdr.len(); + // The borrow ends here: seek() below is the caller's, and reaching the + // same buffer from it would deadlock on a borrow still held. + let (result, consumed) = { + let buf = bytes.borrow_buf(); + let mut rdr: &[u8] = &buf; + let len_before = rdr.len(); + let result = deserialize_value(&mut rdr, args.allow_code, vm)?; + (result, len_before - rdr.len()) + }; // Seek file to just after the consumed bytes let new_pos = tell_before + consumed as i64; vm.call_method(&args.f, "seek", (new_pos,))?; - if !args.allow_code { - check_no_code(&result, vm)?; - } Ok(result) } /// Reject subclasses of marshallable types (int, float, complex, tuple, etc.). - /// Recursively check that no code objects are present. - fn check_no_code(obj: &PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - if obj.downcast_ref::().is_some() { - return Err(vm.new_value_error("unmarshalling code objects is disallowed")); - } - if let Some(tup) = obj.downcast_ref::() { - for elem in tup.as_slice() { - check_no_code(elem, vm)?; - } - } else if let Some(list) = obj.downcast_ref::() { - for elem in list.borrow_vec().iter() { - check_no_code(elem, vm)?; - } - } else if let Some(set) = obj.downcast_ref::() { - for elem in set.elements() { - check_no_code(&elem, vm)?; - } - } else if let Some(fset) = obj.downcast_ref::() { - for elem in fset.elements() { - check_no_code(&elem, vm)?; - } - } else if let Some(dict) = obj.downcast_ref::() { - for (k, v) in dict { - check_no_code(&k, vm)?; - check_no_code(&v, vm)?; - } - } - Ok(()) - } - fn check_exact_type(obj: &PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { let cls = obj.class(); // bool is a subclass of int but is marshallable diff --git a/crates/vm/src/stdlib/typevar.rs b/crates/vm/src/stdlib/typevar.rs index 3e2581406..b784d8799 100644 --- a/crates/vm/src/stdlib/typevar.rs +++ b/crates/vm/src/stdlib/typevar.rs @@ -923,11 +923,12 @@ pub(crate) mod typevar { impl Representable for ParamSpecArgs { #[inline(always)] fn repr_str(zelf: &crate::Py, vm: &VirtualMachine) -> PyResult { - // Check if origin is a ParamSpec - if let Ok(name) = zelf.__origin__.get_attr("__name__", vm) { - return Ok(format!("{name}.args", name = name.str(vm)?)); + // A ParamSpec origin is named; anything else is shown by its repr, + // which carries the recursion guard a Rust `{:?}` walk does not. + if let Some(param_spec) = zelf.__origin__.downcast_ref::() { + return Ok(format!("{}.args", param_spec.__name__().str_utf8(vm)?)); } - Ok(format!("{:?}.args", zelf.__origin__)) + Ok(format!("{}.args", zelf.__origin__.repr(vm)?)) } } @@ -986,11 +987,12 @@ pub(crate) mod typevar { impl Representable for ParamSpecKwargs { #[inline(always)] fn repr_str(zelf: &crate::Py, vm: &VirtualMachine) -> PyResult { - // Check if origin is a ParamSpec - if let Ok(name) = zelf.__origin__.get_attr("__name__", vm) { - return Ok(format!("{name}.kwargs", name = name.str(vm)?)); + // A ParamSpec origin is named; anything else is shown by its repr, + // which carries the recursion guard a Rust `{:?}` walk does not. + if let Some(param_spec) = zelf.__origin__.downcast_ref::() { + return Ok(format!("{}.kwargs", param_spec.__name__().str_utf8(vm)?)); } - Ok(format!("{:?}.kwargs", zelf.__origin__)) + Ok(format!("{}.kwargs", zelf.__origin__.repr(vm)?)) } } diff --git a/crates/vm/src/types/slot.rs b/crates/vm/src/types/slot.rs index fc9d1b048..c0b9142c7 100644 --- a/crates/vm/src/types/slot.rs +++ b/crates/vm/src/types/slot.rs @@ -527,7 +527,11 @@ pub fn hash_not_implemented(zelf: &PyObject, vm: &VirtualMachine) -> PyResult PyResult { - vm.call_special_method(zelf, identifier!(vm, __call__), args) + // `__call__` can name the object being called, and dispatching it pushes no + // Python frame, so nothing else counts the nesting. + vm.with_recursion("while calling a Python object", || { + vm.call_special_method(zelf, identifier!(vm, __call__), args) + }) } fn getattro_wrapper(zelf: &PyObject, name: &Py, vm: &VirtualMachine) -> PyResult { @@ -616,7 +620,11 @@ fn descr_get_wrapper( cls: Option, vm: &VirtualMachine, ) -> PyResult { - vm.call_special_method(&zelf, identifier!(vm, __get__), (obj, cls)) + // A descriptor whose `__get__` is the descriptor itself resolves it by + // fetching `__get__` again, and none of that pushes a Python frame. + vm.with_recursion("while calling a Python object", || { + vm.call_special_method(&zelf, identifier!(vm, __get__), (obj, cls)) + }) } fn descr_set_wrapper( diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index c3861797b..54d3e813e 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -100,6 +100,11 @@ pub struct VirtualMachine { pub state: PyRc, pub initialized: bool, recursion_depth: Cell, + /// Depth of native recursion that pushes no Python frame, counted only + /// where the stack pointer cannot be read. Everywhere else the native + /// stack itself answers, and nothing needs counting. + #[cfg(any(miri, target_env = "musl"))] + native_recursion_depth: Cell, /// C stack soft limit for detecting stack overflow (like c_stack_soft_limit) #[cfg_attr(any(miri, target_env = "musl"), allow(dead_code))] c_stack_soft_limit: Cell, @@ -384,7 +389,7 @@ impl StopTheWorldState { /// is only ever `try_lock`'d. The active requester therefore force-parks /// this thread, finishes its whole stop→start span, releases the exclusion, /// and only then does this thread resume and acquire it. - fn acquire_exclusion(&self) { + fn acquire_exclusion(&self, state: &PyGlobalState) { if self .exclusion .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) @@ -393,7 +398,7 @@ impl StopTheWorldState { return; } loop { - crate::vm::thread::suspend_if_needed(self); + crate::vm::thread::suspend_if_needed(state); std::thread::yield_now(); if self .exclusion @@ -421,7 +426,7 @@ impl StopTheWorldState { /// drives the stop→start span at a time; it is released by /// `start_the_world`/`reset_after_fork`. pub fn stop_the_world(&self, state: &PyGlobalState) { - self.acquire_exclusion(); + self.acquire_exclusion(state); let start = std::time::Instant::now(); let requester_ident = crate::stdlib::_thread::get_ident(); self.requester.store(requester_ident, Ordering::Relaxed); @@ -759,7 +764,10 @@ pub struct PyGlobalState { pub stacksize: AtomicCell, pub thread_count: AtomicCell, pub hash_secret: HashSecret, - pub atexit_funcs: PyMutex>>, + /// Registered `atexit` callbacks, newest first. Shared ownership so + /// `atexit.unregister` can keep the entry it is comparing alive while the + /// list is unlocked, and still recognize it afterwards by identity. + pub atexit_funcs: PyMutex>>, pub codec_registry: CodecsRegistry, pub finalizing: AtomicBool, pub warnings: WarningsState, @@ -991,6 +999,8 @@ impl VirtualMachine { state, initialized: false, recursion_depth: Cell::new(0), + #[cfg(any(miri, target_env = "musl"))] + native_recursion_depth: Cell::new(0), c_stack_soft_limit: Cell::new(Self::calculate_c_stack_soft_limit()), async_gen_firstiter: RefCell::new(None), async_gen_finalizer: RefCell::new(None), @@ -2004,6 +2014,14 @@ impl VirtualMachine { const STACK_MARGIN_BYTES: usize = (if cfg!(debug_assertions) { 16384 } else { 4096 }) * core::mem::size_of::(); + /// How deep native recursion may go where the stack cannot be measured + /// (`Py_C_RECURSION_LIMIT`). A native step costs far more stack than a + /// Python one and debug builds cost more again, so this sits well under + /// what a default stack holds rather than at what it would just fit. + #[cfg(any(miri, target_env = "musl"))] + const NATIVE_RECURSION_LIMIT_UNMEASURED: usize = + if cfg!(debug_assertions) { 500 } else { 1500 }; + /// Get the stack boundaries using platform-specific APIs. /// Returns (base, top) where base is the lowest address and top is the highest. #[cfg(all(not(miri), not(target_env = "musl"), windows))] @@ -2105,16 +2123,34 @@ impl VirtualMachine { /// Used to run the body of a (possibly) recursive function. It will raise a /// RecursionError if recursive functions are nested far too many times, /// preventing a stack overflow. + /// `Py_EnterRecursiveCall`: bounds native recursion that pushes no Python + /// frame, against the native stack. That is a separate budget from the + /// frame limit `sys.setrecursionlimit()` sets, so nesting counted here does + /// not come out of what Python code has left to call with. pub fn with_recursion PyResult>(&self, _where: &str, f: F) -> PyResult { - self.check_recursive_call(_where)?; + // `check_c_stack_overflow()` answers no unconditionally where the stack + // pointer cannot be read, which would leave this guard with nothing to + // stop. A count of the nesting stands in for the measurement there. + #[cfg(any(miri, target_env = "musl"))] + let counted_too_deep = + self.native_recursion_depth.get() >= Self::NATIVE_RECURSION_LIMIT_UNMEASURED; + #[cfg(not(any(miri, target_env = "musl")))] + let counted_too_deep = false; - // Native stack guard: check C stack like _Py_MakeRecCheck - if self.check_c_stack_overflow() { - return Err(self.new_recursion_error(_where.to_string())); + if counted_too_deep || self.check_c_stack_overflow() { + return Err( + self.new_recursion_error(format!("maximum recursion depth exceeded {_where}")) + ); } - self.recursion_depth.update(|d| d + 1); - scopeguard::defer! { self.recursion_depth.update(|d| d - 1) } + #[cfg(any(miri, target_env = "musl"))] + let _native_depth_guard = { + self.native_recursion_depth.update(|d| d + 1); + scopeguard::guard((), |()| { + self.native_recursion_depth.update(|d| d.saturating_sub(1)) + }) + }; + f() } @@ -2603,12 +2639,28 @@ impl VirtualMachine { // Objects/listobject.c. Each branch takes an atomic snapshot to avoid // race conditions from concurrent mutation (no GIL). let cls = value.class(); - let list_borrow; let slice = if cls.is(self.ctx.types.tuple_type) { value.downcast_ref::().unwrap().as_slice() } else if cls.is(self.ctx.types.list_type) { - list_borrow = value.downcast_ref::().unwrap().borrow_vec(); - &list_borrow + // The list is re-read on every step, the way map_iterable_object() + // does it: func() runs Python, which can mutate or even clear the + // same list, and a borrow held across that call deadlocks it. + let list = value.downcast_ref::().unwrap(); + let mut results = Vec::new(); + let mut i = 0; + loop { + let elem = { + let elements = list.borrow_vec(); + let Some(elem) = elements.get(i) else { + break; + }; + elem.clone() + // free the lock + }; + results.push(func(elem)?); + i += 1; + } + return Ok(results); } else if cls.is(self.ctx.types.dict_type) { let keys = value.downcast_ref::().unwrap().keys_vec(); return keys.into_iter().map(func).collect(); @@ -2797,7 +2849,7 @@ impl VirtualMachine { // Suspend this thread if stop-the-world is in progress #[cfg(feature = "threading")] - thread::suspend_if_needed(&self.state.stop_the_world); + thread::suspend_if_needed(&self.state); // Pass a QSBR checkpoint if requested (deferred memory reclamation). #[cfg(feature = "threading")] diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 3b378acd6..3f83d88fe 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -5,11 +5,13 @@ use crate::builtins::PyBaseExceptionRef; #[cfg(feature = "threading")] use alloc::sync::Arc; -#[cfg(all(unix, feature = "threading"))] -use crate::frame::FrameObject; use crate::frame::InterpreterFrame; +#[cfg(feature = "threading")] +use crate::vm::PyGlobalState; use crate::{AsObject, PyObject, VirtualMachine}; #[cfg(all(unix, feature = "threading"))] +use crate::{Py, frame::FrameObject}; +#[cfg(all(unix, feature = "threading"))] use core::sync::atomic::AtomicPtr; use core::{ cell::{Cell, RefCell}, @@ -44,7 +46,7 @@ pub struct ThreadSlot { /// thread at a safepoint and supplies the happens-before edge, so the /// pointer and the frames it reaches are quiescent and alive at read time. #[cfg(unix)] - pub top_frame: AtomicPtr, + pub top_frame: AtomicPtr>, /// Raw InterpreterFrame pointer, published alongside top_frame so /// cross-thread readers (sys._current_frames) can materialize /// stack-allocated frames that have no FrameObject. @@ -114,7 +116,7 @@ thread_local! { /// initialized; the `Arc` in `CURRENT_THREAD_SLOT` keeps the /// pointee alive until `cleanup_current_thread_frames` clears this. #[cfg(all(unix, feature = "threading"))] - static CURRENT_TOP_FRAME_SLOT: Cell<*const AtomicPtr> = + static CURRENT_TOP_FRAME_SLOT: Cell<*const AtomicPtr>> = const { Cell::new(core::ptr::null()) }; /// Cached pointer to this thread's `ThreadSlot::top_iframe` for the hot @@ -536,9 +538,10 @@ fn attach_thread(vm: &VirtualMachine) { // a thread doing rapid allow_threads calls from re-attaching and running // past the requester forever, which would stall stop-the-world. Done // outside the CURRENT_THREAD_SLOT borrow above because suspend re-borrows - // it. Safe against a concurrent start_the_world: suspend_if_needed only - // parks while the request is still live and self-recovers otherwise. - suspend_if_needed(&vm.state.stop_the_world); + // it. Safe against a concurrent start_the_world: suspend_if_needed decides + // whether to park under the registry lock, so it never parks after the + // request has been withdrawn. + suspend_if_needed(&vm.state); } /// Transition ATTACHED → DETACHED (like `_PyThreadState_Detach`). @@ -605,102 +608,111 @@ pub fn allow_threads(_vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { /// Transitions ATTACHED → SUSPENDED and waits until released /// (like `_PyThreadState_Suspend` + `_PyThreadState_Attach`). #[cfg(feature = "threading")] -pub fn suspend_if_needed(stw: &super::StopTheWorldState) { +pub fn suspend_if_needed(state: &PyGlobalState) { let should_suspend = CURRENT_THREAD_SLOT.with(|slot| { slot.borrow() .as_ref() .is_some_and(|s| s.stop_requested.load(Ordering::Relaxed)) }); - if !should_suspend { - return; + if should_suspend { + do_suspend(state); } - - if !stw.requested.load(Ordering::Acquire) { - CURRENT_THREAD_SLOT.with(|slot| { - if let Some(s) = slot.borrow().as_ref() { - s.stop_requested.store(false, Ordering::Release); - } - }); - return; - } - - do_suspend(stw); } #[cfg(feature = "threading")] #[cold] -fn do_suspend(stw: &super::StopTheWorldState) { +fn do_suspend(state: &PyGlobalState) { + let stw = &state.stop_the_world; CURRENT_THREAD_SLOT.with(|slot| { - if let Some(s) = slot.borrow().as_ref() { - // ATTACHED → SUSPENDED + let borrowed = slot.borrow(); + let Some(s) = borrowed.as_ref() else { + return; + }; + + // Decide whether to park while holding the thread registry. Both edges + // of `requested` are written under that lock: `init_thread_countdown` + // sets it, and `start_the_world` clears it and then releases every + // SUSPENDED thread without letting go. Publishing SUSPENDED here is + // therefore either seen by that release pass or never reached, which + // leaves the requester the only writer that takes a thread out of + // SUSPENDED. A completion check that observed this thread parked cannot + // then be invalidated by the thread resuming on its own. + let park = { + let _registry = state.thread_frames.lock(); + if stw.requested.load(Ordering::Acquire) { + Some(s.state.compare_exchange( + THREAD_ATTACHED, + THREAD_SUSPENDED, + Ordering::AcqRel, + Ordering::Acquire, + )) + } else { + // The stop already ended; this thread's request bit is stale. + s.stop_requested.store(false, Ordering::Release); + None + } + }; + + match park { + None => { + super::stw_trace(format_args!("suspend skip not-requested")); + return; + } + Some(Ok(_)) => { + // Consumed this thread's stop request bit. + s.stop_requested.store(false, Ordering::Release); + } + Some(Err(THREAD_DETACHED)) => { + // Leaving VM; caller will re-check on next entry. + super::stw_trace(format_args!("suspend skip DETACHED")); + return; + } + Some(Err(THREAD_SUSPENDED)) => { + // Already parked by another path. + s.stop_requested.store(false, Ordering::Release); + super::stw_trace(format_args!("suspend skip already-suspended")); + return; + } + Some(Err(state)) => { + debug_assert!(false, "unexpected thread state in suspend: {state}"); + return; + } + } + super::stw_trace(format_args!("suspend ATTACHED->SUSPENDED")); + + // Notify the stop-the-world requester that we've parked. The registry + // is released first: the requester's wait loop takes the notify mutex + // and then the registry, so taking them the other way round here would + // invert the order. + stw.notify_suspended(); + super::stw_trace(format_args!("suspend notified-requester")); + + // Wait until start_the_world sets us back to DETACHED + let wait_yields = wait_while_suspended(s); + stw.add_suspend_wait_yields(wait_yields); + + // Re-attach (DETACHED → ATTACHED), tstate_wait_attach CAS loop. + loop { match s.state.compare_exchange( + THREAD_DETACHED, THREAD_ATTACHED, - THREAD_SUSPENDED, Ordering::AcqRel, Ordering::Acquire, ) { - Ok(_) => { - // Consumed this thread's stop request bit. - s.stop_requested.store(false, Ordering::Release); - } - Err(THREAD_DETACHED) => { - // Leaving VM; caller will re-check on next entry. - super::stw_trace(format_args!("suspend skip DETACHED")); - return; - } + Ok(_) => break, Err(THREAD_SUSPENDED) => { - // Already parked by another path. - s.stop_requested.store(false, Ordering::Release); - super::stw_trace(format_args!("suspend skip already-suspended")); - return; + let extra_wait = wait_while_suspended(s); + stw.add_suspend_wait_yields(extra_wait); } + Err(THREAD_ATTACHED) => break, Err(state) => { - debug_assert!(false, "unexpected thread state in suspend: {state}"); - return; + debug_assert!(false, "unexpected post-suspend state: {state}"); + break; } } - super::stw_trace(format_args!("suspend ATTACHED->SUSPENDED")); - - // Re-check: if start_the_world already ran (cleared `requested`), - // no one will set us back to DETACHED — we must self-recover. - if !stw.requested.load(Ordering::Acquire) { - s.state.store(THREAD_ATTACHED, Ordering::Release); - s.stop_requested.store(false, Ordering::Release); - super::stw_trace(format_args!("suspend abort requested-cleared")); - return; - } - - // Notify the stop-the-world requester that we've parked - stw.notify_suspended(); - super::stw_trace(format_args!("suspend notified-requester")); - - // Wait until start_the_world sets us back to DETACHED - let wait_yields = wait_while_suspended(s); - stw.add_suspend_wait_yields(wait_yields); - - // Re-attach (DETACHED → ATTACHED), tstate_wait_attach CAS loop. - loop { - match s.state.compare_exchange( - THREAD_DETACHED, - THREAD_ATTACHED, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => break, - Err(THREAD_SUSPENDED) => { - let extra_wait = wait_while_suspended(s); - stw.add_suspend_wait_yields(extra_wait); - } - Err(THREAD_ATTACHED) => break, - Err(state) => { - debug_assert!(false, "unexpected post-suspend state: {state}"); - break; - } - } - } - s.stop_requested.store(false, Ordering::Release); - super::stw_trace(format_args!("suspend resume -> ATTACHED")); } + s.stop_requested.store(false, Ordering::Release); + super::stw_trace(format_args!("suspend resume -> ATTACHED")); }); } @@ -818,11 +830,8 @@ pub fn set_current_frame(frame: *const InterpreterFrame) -> *const InterpreterFr core::ptr::null_mut() } else { let frame_obj = unsafe { (*frame).frame_obj() }; - // The payload address, which is what the cross-thread - // reader hands to `Py::from_payload_ptr`. The `Py` address - // would be off by the object header. frame_obj.map_or(core::ptr::null_mut(), |py| { - core::ptr::from_ref::(py).cast_mut() + py as *const Py as *mut Py }) }; unsafe { &*slot }.store(fo_ptr, Ordering::Relaxed); @@ -971,7 +980,7 @@ pub fn reinit_frame_slot_after_fork(vm: &VirtualMachine) { core::ptr::null_mut() } else { match unsafe { (*top_iframe).frame_obj() } { - Some(fo) => core::ptr::from_ref::(fo).cast_mut(), + Some(fo) => fo as *const Py as *mut Py, None => core::ptr::null_mut(), } } @@ -1163,6 +1172,8 @@ impl VirtualMachine { state: self.state.clone(), initialized: self.initialized, recursion_depth: Cell::new(0), + #[cfg(any(miri, target_env = "musl"))] + native_recursion_depth: Cell::new(0), c_stack_soft_limit: Cell::new(Self::calculate_c_stack_soft_limit()), async_gen_firstiter: RefCell::new(None), async_gen_finalizer: RefCell::new(None), diff --git a/crates/vm/src/vm/vm_ops.rs b/crates/vm/src/vm/vm_ops.rs index 692444fc7..dc31e5082 100644 --- a/crates/vm/src/vm/vm_ops.rs +++ b/crates/vm/src/vm/vm_ops.rs @@ -168,6 +168,27 @@ impl VirtualMachine { } } + /// `vec![0; len]` for a length that came from Python, where a request too + /// large to satisfy is a `MemoryError` rather than an aborted process. + /// + /// The bytes are left for the allocator to zero, so a large request costs + /// no more than the pages that are actually written to. + pub fn new_zeroed_bytes(&self, len: usize) -> PyResult> { + if len == 0 { + return Ok(Vec::new()); + } + let layout = + core::alloc::Layout::array::(len).map_err(|_| self.new_memory_error(""))?; + // SAFETY: `len` is not zero, so neither is the layout's size. + let ptr = unsafe { alloc::alloc::alloc_zeroed(layout) }; + if ptr.is_null() { + return Err(self.new_memory_error("")); + } + // SAFETY: `ptr` was just allocated by the global allocator for exactly + // this many bytes, and every one of them is initialized to zero. + Ok(unsafe { Vec::from_raw_parts(ptr, len, len) }) + } + /// Calling scheme used for binary operations: /// /// Order operations are tried until either a valid result or error: diff --git a/extra_tests/snippets/builtin_bytes.py b/extra_tests/snippets/builtin_bytes.py index 4f8613644..3cbed79c0 100644 --- a/extra_tests/snippets/builtin_bytes.py +++ b/extra_tests/snippets/builtin_bytes.py @@ -747,3 +747,22 @@ assert not s.istitle(), f"{s}" assert "123A".istitle(), f"{s}" assert not "123a".istitle(), f"{s}" assert not "123A\ta".istitle(), f"{s}" + + +def test_huge_size(): + # sizes that cannot be allocated are MemoryError, not an aborted process + for factory in (bytes, bytearray): + assert_raises(MemoryError, lambda factory=factory: factory(2**62)) + for meth in ("center", "ljust", "rjust", "zfill"): + assert_raises( + MemoryError, + lambda factory=factory, meth=meth: getattr(factory(b"a"), meth)( + 1 << 62 + ), + ) + assert_raises( + OverflowError, lambda factory=factory: factory(b"\ta").expandtabs(2**31) + ) + + +test_huge_size() diff --git a/extra_tests/snippets/builtin_hash.py b/extra_tests/snippets/builtin_hash.py index b3128cecc..818ee523f 100644 --- a/extra_tests/snippets/builtin_hash.py +++ b/extra_tests/snippets/builtin_hash.py @@ -35,9 +35,10 @@ with assert_raises(TypeError): # slot dispatch is what recurses, so that is where the depth is checked. if sys.implementation.name == "rustpython": - # CPython, which also runs this snippet, survives this depth unguarded. + # Deep enough to reach the native stack guard; CPython, which also runs + # this snippet, dies on the same value. deep_tuple = () - for _ in range(sys.getrecursionlimit() * 2): + for _ in range(100_000): deep_tuple = (deep_tuple,) with assert_raises(RecursionError): hash(deep_tuple) diff --git a/extra_tests/snippets/builtin_memoryview.py b/extra_tests/snippets/builtin_memoryview.py index 8a3a194d9..34928041c 100644 --- a/extra_tests/snippets/builtin_memoryview.py +++ b/extra_tests/snippets/builtin_memoryview.py @@ -514,3 +514,164 @@ def test_fortran_contiguity(): test_fortran_contiguity() + + +def test_cast_arguments(): + # cast() takes a native single character format, optionally '@'-prefixed; + # a zero-size format used to reach a division by zero. + assert memoryview(b"abcd").cast("@i").itemsize == 4 + for fmt in ("0s", "4s", " 0; a 0 used to divide by zero while + # checking the product against SSIZE_MAX + for shape in ([0], [0, 4], [4, 0], [-1, 4], [0, 0]): + assert_raises( + ValueError, lambda shape=shape: memoryview(b"abcd").cast("B", shape) + ) + + class Index: + def __index__(self): + return 4 + + for shape in ([2.0, 2], [Index()], ["4"]): + assert_raises( + TypeError, lambda shape=shape: memoryview(b"abcd").cast("B", shape) + ) + + assert memoryview(b"abcd").cast("B", [True, 4]).tolist() == [[97, 98, 99, 100]] + + +test_cast_arguments() + + +def test_negative_stride(): + # A reversed view starts at its last byte, so walking it from there runs + # off the front of the exported slice. + assert memoryview(b"dcba") == memoryview(b"abcd")[::-1] + assert memoryview(b"abcd")[::-1] == memoryview(b"dcba") + assert not memoryview(b"abcd") == memoryview(b"abcd")[::-1] + + b = bytearray(b"____") + memoryview(b)[0:4] = memoryview(b"abcd")[::-1] + assert b == bytearray(b"dcba"), b + + a = array.array("i", [1, 2, 3]) + assert memoryview(array.array("i", [3, 2, 1])) == memoryview(a)[::-1] + assert memoryview(a)[::-1].tolist() == [3, 2, 1] + + +test_negative_stride() + + +def test_write_through_same_object(): + # Reading the source and writing the destination lock the same object + # when they overlap, and converting a value runs Python that can reach it. + b = bytearray(b"abcd") + memoryview(b)[0:4] = b + assert b == bytearray(b"abcd"), b + + b = bytearray(b"abcd") + memoryview(b)[0:4] = memoryview(b)[::-1] + assert b == bytearray(b"dcba"), b + + b = bytearray(b"abcd") + memoryview(b)[0:2] = memoryview(b)[2:4] + assert b == bytearray(b"cdcd"), b + + b = bytearray(b"abcd") + view = memoryview(b) + + class Index: + def __index__(self): + view[1] = 66 + return 65 + + view[0] = Index() + assert b == bytearray(b"ABcd"), b + + +test_write_through_same_object() + + +def test_cast_between_non_byte_formats(): + # A cast re-divides bytes into items; going from one item type straight to + # another would reinterpret what is already there. + view = memoryview(b"abcd").cast("i") + for fmt in ("h", "i", "f"): + try: + view.cast(fmt) + except TypeError as e: + assert "cannot cast between two non-byte formats" in str(e), e + else: + raise AssertionError(f"expected TypeError for cast to {fmt!r}") + + # Either side being bytes is allowed. + assert view.cast("B").tolist() == [97, 98, 99, 100] + assert view.cast("b").format == "b" + assert view.cast("c").tolist() == [b"a", b"b", b"c", b"d"] + assert memoryview(b"abcd").cast("c").cast("i").format == "i" + + +def test_cast_to_zero_dim(): + # A zero-dimensional view holds exactly one item, so the buffer has to be + # that one item and no more. + assert memoryview(b"abcd").cast("I", shape=()).tobytes() == b"abcd" + assert memoryview(b"a").cast("B", shape=()).tobytes() == b"a" + + for source, fmt in ((b"abcd", "B"), (b"abcdefgh", "I"), (b"ab", "b")): + try: + memoryview(source).cast(fmt, shape=()) + except TypeError as e: + assert "product(shape) * itemsize != buffer size" in str(e), e + else: + raise AssertionError(f"expected TypeError for {source!r} as {fmt!r}") + + +def test_hash_restricted_to_byte_formats(): + # The hash is over the bytes, so it agrees with the hash of those bytes + # only where an item is a byte. + data = b"abcdefgh" + assert hash(memoryview(data)) == hash(data) + assert hash(memoryview(data).cast("c")) == hash(data) + assert hash(memoryview(data).cast("b")) == hash(data) + + for fmt in ("I", "i", "h", "d"): + try: + hash(memoryview(data).cast(fmt)) + except ValueError as e: + assert "hashing is restricted to formats" in str(e), e + else: + raise AssertionError(f"expected ValueError for format {fmt!r}") + + +def test_tobytes_order(): + view = memoryview(b"abcdefgh") + for order in (None, "C", "F", "A"): + assert view.tobytes(order=order) == b"abcdefgh", order + + # A multidimensional view is laid out C-contiguously, so a Fortran-ordered + # copy walks it down the columns instead. + grid = memoryview(b"abcdefgh").cast("B", shape=(2, 4)) + assert grid.tolist() == [[97, 98, 99, 100], [101, 102, 103, 104]] + assert grid.tobytes() == b"abcdefgh" + assert grid.tobytes(order="C") == b"abcdefgh" + assert grid.tobytes(order="A") == b"abcdefgh" + assert grid.tobytes(order="F") == b"aebfcgdh" + + cube = memoryview(b"abcdefgh").cast("B", shape=(2, 2, 2)) + assert cube.tobytes(order="F") == b"aecgbfdh" + + for order in ("Z", "c", "f", ""): + try: + view.tobytes(order=order) + except ValueError as e: + assert str(e) == "order must be 'C', 'F' or 'A'", e + else: + raise AssertionError(f"expected ValueError for order {order!r}") + + +test_cast_between_non_byte_formats() +test_cast_to_zero_dim() +test_hash_restricted_to_byte_formats() +test_tobytes_order() diff --git a/extra_tests/snippets/builtin_str.py b/extra_tests/snippets/builtin_str.py index 6eead5ddb..684bd66a1 100644 --- a/extra_tests/snippets/builtin_str.py +++ b/extra_tests/snippets/builtin_str.py @@ -900,3 +900,18 @@ assert id(b) != id(b * 0) assert id(b) != id(b * 1) assert id(b) != id(1 * b) assert id(b) != id(b * 2) + + +def test_huge_width(): + # A width that cannot be allocated is a MemoryError, not an aborted + # process, and a tabsize wider than a C int does not fit at all. + for meth in ("center", "ljust", "rjust", "zfill"): + assert_raises(MemoryError, lambda meth=meth: getattr("a", meth)(1 << 62)) + assert_raises(OverflowError, lambda: "\ta".expandtabs(1 << 62)) + assert_raises(OverflowError, lambda: "\ta".expandtabs(2**31)) + # The widest tabsize that still fits is accepted. With no tab to expand + # there is nothing to lay out, so the width is never allocated. + assert "a".expandtabs(2**31 - 1) == "a" + + +test_huge_width() diff --git a/extra_tests/snippets/builtin_type.py b/extra_tests/snippets/builtin_type.py index 8cb0a09a2..15a330aea 100644 --- a/extra_tests/snippets/builtin_type.py +++ b/extra_tests/snippets/builtin_type.py @@ -687,3 +687,33 @@ def foo(): code = compile(stmts, "", "exec") assert code.co_names == ("blah", "foo") + + +# A slot descriptor carries the layout it was defined for. Reached from another +# class, it has to report that rather than read the slot at its own offset, +# whether the access is fresh or has been seen often enough to be specialized. + + +class WideSlots: + __slots__ = ("s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7") + + +class NarrowSlots: + __slots__ = ("only",) + + +class NoSlots: + __slots__ = () + + +NarrowSlots.borrowed = WideSlots.__dict__["s7"] +NoSlots.borrowed = WideSlots.__dict__["s7"] + +for owner in (NarrowSlots(), NoSlots()): + for _ in range(1000): + with assert_raises(TypeError): + owner.borrowed + with assert_raises(TypeError): + owner.borrowed = 1 + with assert_raises(TypeError): + del owner.borrowed diff --git a/extra_tests/snippets/recursion.py b/extra_tests/snippets/recursion.py index 2d3b2205d..4b61a74b4 100644 --- a/extra_tests/snippets/recursion.py +++ b/extra_tests/snippets/recursion.py @@ -11,3 +11,36 @@ foo = Foo() # Since the default __str__ implementation calls __repr__ and __repr__ is # actually __str__, str(foo) should raise a RecursionError. assert_raises(RecursionError, str, foo) + + +# A __call__ that is the object being called dispatches through the call slot +# again, and none of that pushes a Python frame. + + +class Caller: + pass + + +caller = Caller() +Caller.__call__ = caller +assert_raises(RecursionError, caller) + + +# The same shape through the descriptor protocol: resolving the attribute +# fetches __get__, which is the descriptor itself. + + +class Descr: + pass + + +descr = Descr() +Descr.__get__ = descr +Descr.x = descr +try: + descr.x +except (RecursionError, TypeError): + # RecursionError here, TypeError from the call of a non-callable elsewhere + pass +else: + raise AssertionError("descr.x should not resolve") diff --git a/extra_tests/snippets/stdlib_array.py b/extra_tests/snippets/stdlib_array.py index ed2a8f223..c2de6ac1e 100644 --- a/extra_tests/snippets/stdlib_array.py +++ b/extra_tests/snippets/stdlib_array.py @@ -143,3 +143,36 @@ class _ReenteringWriter: arr = array("b", range(128)) arr.tofile(_ReenteringWriter(arr)) assert len(arr) == 129 + + +def test_setitem_reentrant(): + # Converting the value runs Python, which can reach the array, so the + # array is not locked while it happens. + a = array("i", [1, 2, 3]) + + class Index: + def __index__(self): + a[1] = 9 + return 7 + + a[0] = Index() + assert a == array("i", [7, 9, 3]), a + + +test_setitem_reentrant() + + +def test_frombytes_of_itself(): + # Resizing is refused while a buffer is exported, before any lock is taken. + # The typecode is "b" so the view's items are bytes and the resize is what + # the call is refused for. + a = array("b", [1, 2, 3]) + m = memoryview(a) + with assert_raises(BufferError): + a.frombytes(m) + del m + + # A view of wider items is not a source of bytes at all. + wide = array("i", [1, 2, 3]) + with assert_raises(TypeError): + wide.frombytes(memoryview(wide)) diff --git a/extra_tests/snippets/stdlib_asyncio.py b/extra_tests/snippets/stdlib_asyncio.py index d54f84564..a6a555090 100644 --- a/extra_tests/snippets/stdlib_asyncio.py +++ b/extra_tests/snippets/stdlib_asyncio.py @@ -72,4 +72,31 @@ finally: asyncio.InvalidStateError = saved_invalid_state_error asyncio.exceptions.InvalidStateError = saved_invalid_state_error +# The awaited-by set is built with the waiter's __hash__, which can come back +# to the same future; the field must not be locked while that runs. + + +class Reentrant: + def __hash__(self): + _asyncio.future_add_to_awaited_by(awaited, Reentrant()) + return 1 + + def __eq__(self, other): + return self is other + + +awaited = _asyncio.Future(loop=object()) +_asyncio.future_add_to_awaited_by(awaited, Reentrant()) +with assert_raises(RecursionError): + # converting the single waiter into a set hashes both of them + _asyncio.future_add_to_awaited_by(awaited, Reentrant()) + +plain = _asyncio.Future(loop=object()) +waiter = object() +_asyncio.future_add_to_awaited_by(plain, waiter) +_asyncio.future_add_to_awaited_by(plain, object()) +assert waiter in plain._asyncio_awaited_by +_asyncio.future_discard_from_awaited_by(plain, waiter) +assert waiter not in plain._asyncio_awaited_by + print("ok") diff --git a/extra_tests/snippets/stdlib_atexit.py b/extra_tests/snippets/stdlib_atexit.py new file mode 100644 index 000000000..de490c569 --- /dev/null +++ b/extra_tests/snippets/stdlib_atexit.py @@ -0,0 +1,101 @@ +"""atexit.unregister() compares callbacks with arbitrary Python code. + +The comparison runs with the callback list unlocked, so __eq__ may clear it +and register something new. unregister() then has to tell whether the entry +it compared is still there, and must not mistake a later registration that +happens to occupy the same storage for that entry. +""" + +import atexit + + +def make(name): + def f(): + ran.append(name) + + f.tag = name + return f + + +ran = [] +a, b, c, d = (make(n) for n in "abcd") + + +class Probe: + def __init__(self, action=None, result=True): + self.action = action + self.result = result + self.seen = [] + + def __eq__(self, other): + self.seen.append(getattr(other, "tag", "?")) + if self.action is not None: + self.action() + return self.result + + +def remaining(): + del ran[:] + atexit._run_exitfuncs() + return list(ran) + + +# A callback the probe does not match is left alone. +atexit._clear() +atexit.register(a) +atexit.register(b) +probe = Probe(result=False) +atexit.unregister(probe) +assert probe.seen == ["a", "b"], probe.seen +assert remaining() == ["b", "a"], ran + +# Matching callbacks are dropped, oldest compared first. +atexit._clear() +atexit.register(a) +atexit.register(b) +atexit.register(c) +probe = Probe(result=True) +atexit.unregister(probe) +assert probe.seen == ["a", "b", "c"], probe.seen +assert atexit._ncallbacks() == 0 +assert remaining() == [], ran + +# __eq__ empties the list: there is nothing left to drop. +atexit._clear() +atexit.register(a) +atexit.register(b) +probe = Probe(action=atexit._clear, result=True) +atexit.unregister(probe) +assert probe.seen == ["a"], probe.seen +assert remaining() == [], ran + +# __eq__ empties the list and registers a replacement. The replacement is a +# different callback, so it survives however its storage was reused. +atexit._clear() +atexit.register(a) +atexit.register(b) +atexit.register(c) + + +def replace(): + atexit._clear() + atexit.register(d) + + +probe = Probe(action=replace, result=True) +atexit.unregister(probe) +assert probe.seen == ["a", "d"], probe.seen +assert remaining() == ["d"], ran + +# __eq__ registers without clearing: every entry the walk had already passed +# stays, and so does each newly registered one. +atexit._clear() +atexit.register(a) +atexit.register(b) +probe = Probe(action=lambda: atexit.register(c), result=True) +atexit.unregister(probe) +assert probe.seen == ["a", "c"], probe.seen +assert remaining() == ["c", "c", "b", "a"], ran + +atexit._clear() +print("ok") diff --git a/extra_tests/snippets/stdlib_hashlib.py b/extra_tests/snippets/stdlib_hashlib.py index a463941b2..f3400aed5 100644 --- a/extra_tests/snippets/stdlib_hashlib.py +++ b/extra_tests/snippets/stdlib_hashlib.py @@ -2,6 +2,8 @@ import _md5 import _sha1 import hashlib +from testutils import assert_raises + # print(hashlib.md5) h = hashlib.md5() h.update(b"a") @@ -56,3 +58,9 @@ assert ( assert _md5.md5(b"").hexdigest() == "d41d8cd98f00b204e9800998ecf8427e" assert _sha1.sha1(b"").hexdigest() == "da39a3ee5e6b4b0d3255bfef95601890afd80709" + +# a derived key wider than a C int does not fit, and never gets allocated. +# Which OverflowError comes out depends on the width of a C long: where it is +# narrower than the length asked for, converting the argument fails first. +with assert_raises(OverflowError): + hashlib.pbkdf2_hmac("sha256", b"password", b"salt", 1, 2**62) diff --git a/extra_tests/snippets/stdlib_io.py b/extra_tests/snippets/stdlib_io.py index f17eae5b1..8346ddbb6 100644 --- a/extra_tests/snippets/stdlib_io.py +++ b/extra_tests/snippets/stdlib_io.py @@ -197,3 +197,48 @@ expect_value_error( f"cannot fit '{truncated_non_ascii_type_name}' into an index-sized integer", lambda: setattr(textio, "_CHUNK_SIZE", NonAsciiNamedChunkSize()), ) + + +# A buffer size or read size that cannot be allocated is a MemoryError, not an +# aborted process. +assert_raises(MemoryError, lambda: BufferedReader(BytesIO(b"a"), buffer_size=2**62)) +assert_raises(MemoryError, lambda: BufferedReader(BytesIO(b"a")).read(2**62)) +assert_raises(MemoryError, lambda: BufferedReader(BytesIO(b"a")).read1(2**62)) + + +def _text_cookie( + start_pos=0, + dec_flags=0, + bytes_to_feed=0, + chars_to_skip=0, + need_eof=0, + bytes_to_skip=0, +): + packed = ( + start_pos.to_bytes(8, "little", signed=True) + + dec_flags.to_bytes(4, "little", signed=True) + + bytes_to_feed.to_bytes(4, "little", signed=True) + + chars_to_skip.to_bytes(4, "little", signed=True) + + bytes([need_eof]) + + bytes_to_skip.to_bytes(4, "little", signed=True) + ) + return int.from_bytes(packed, "little") + + +# A cookie names a position both in characters and in bytes, and everything +# read back from it indexes what was decoded, so a position past the end is +# refused rather than stored. +for _bad in ( + _text_cookie(bytes_to_feed=10, chars_to_skip=1000, bytes_to_skip=0), + _text_cookie(bytes_to_feed=10, chars_to_skip=100000, bytes_to_skip=3), + _text_cookie(bytes_to_feed=10, chars_to_skip=1, bytes_to_skip=1000), +): + _textio = TextIOWrapper(BytesIO(b"hello world " * 20), encoding="utf-8") + _textio.read(1) + try: + _textio.seek(_bad) + except (OSError, OverflowError): + pass + else: + assert _textio.read(50) is not None + _textio.tell() diff --git a/extra_tests/snippets/stdlib_io_blocking_buffer.py b/extra_tests/snippets/stdlib_io_blocking_buffer.py new file mode 100644 index 000000000..2119111dc --- /dev/null +++ b/extra_tests/snippets/stdlib_io_blocking_buffer.py @@ -0,0 +1,176 @@ +"""Transfers that wait for a peer must not hold the buffer they were given. + +A pipe or a socket answers when the other end does, which may be never. The +buffer is exported for the whole call, so it cannot be resized meanwhile, but +nothing else about it changes: another thread can still read it, write to it, +and the interpreter can still stop the world. An implementation that holds the +buffer's storage for the duration of the wait takes all of that away, and a +thread parked on that storage never reaches a safepoint, so a collection that +wants every thread stopped ends up waiting for the peer too. +""" + +import gc +import os +import socket +import threading +import time + +# The peer acts after DELAY; the checks below have to finish well inside it. +DELAY = 1.0 +SLACK = DELAY / 2 + + +def measure(buf, expected_len, writable): + """Time each operation on `buf` that does not need the peer, separately, so + a failure names the one that waited rather than the group.""" + elapsed = {} + + def timed(name, operation): + start = time.monotonic() + value = operation() + elapsed[name] = time.monotonic() - start + return value + + assert timed("len", lambda: len(buf)) == expected_len, len(buf) + assert isinstance(timed("bytes", lambda: bytes(buf)), bytes) + if writable: + timed("setitem", lambda: buf.__setitem__(0, buf[0])) + timed("gc.collect", gc.collect) + return elapsed + + +def run(buf, blocking_call, release_peer, writable): + started = threading.Event() + expected_len = len(buf) + result = [] + + def transfer(): + started.set() + result.append(blocking_call(buf)) + + def peer(): + time.sleep(DELAY) + release_peer() + + threads = [threading.Thread(target=transfer), threading.Thread(target=peer)] + for t in threads: + t.start() + started.wait() + time.sleep(0.2) # the transfer is now waiting on its peer + + elapsed = measure(buf, expected_len, writable) + waited = ["%s %.2fs" % item for item in elapsed.items() if item[1] >= SLACK] + assert not waited, "waited on the peer: " + ", ".join(waited) + + # The transfer is still in flight, so its export is still held and the + # buffer cannot be resized. An operating system that took the whole + # transfer without a peer leaves nothing here to observe. + assert not result, "the transfer finished without its peer" + try: + buf.append(0) + except BufferError: + pass + else: + raise AssertionError("append during an export should raise BufferError") + + for t in threads: + t.join() + return result[0] + + +# --- reading: the buffer is written into, so nothing else may touch it at all + + +read_fd, write_fd = os.pipe() +pipe = open(read_fd, "rb", buffering=0) +try: + target = bytearray(16) + n = run(target, pipe.readinto, lambda: os.write(write_fd, b"pipe"), writable=False) + assert n == 4, n + assert bytes(target[:4]) == b"pipe", bytes(target) +finally: + pipe.close() + os.close(write_fd) + +if hasattr(socket, "socketpair"): + left, right = socket.socketpair() + try: + target = bytearray(16) + n = run(target, left.recv_into, lambda: right.send(b"socket"), writable=False) + assert n == 6, n + assert bytes(target[:6]) == b"socket", bytes(target) + finally: + left.close() + right.close() + + +# --- writing: the buffer is only read, so it stays writable meanwhile + + +read_fd, write_fd = os.pipe() +sink = open(write_fd, "wb", buffering=0) +try: + # More than any pipe will hold, so the write cannot finish on its own. + source = bytearray(4 * 1024 * 1024) + drained = [] + + def drain(): + with open(read_fd, "rb", buffering=0) as f: + while True: + chunk = f.read(1 << 16) + if not chunk: + break + drained.append(len(chunk)) + + reader = threading.Thread(target=drain, daemon=True) + # One unbuffered write() reports what it transferred, which a signal can + # cut short, so the reader is measured against that rather than the source. + written = run(source, sink.write, reader.start, writable=True) + sink.close() + reader.join() + assert sum(drained) == written, (sum(drained), written) +finally: + if not sink.closed: + sink.close() + +if hasattr(socket, "socketpair"): + left, right = socket.socketpair() + try: + # How much a connection holds before it makes the sender wait is the + # operating system's to decide, and asking for a small send buffer does + # not settle it -- a socketpair is already connected, and on Windows it + # is a loopback pair whose receiver has a window of its own. So fill it + # until it refuses rather than guess a size that outruns it. + left.setblocking(False) + filled = 0 + while True: + try: + filled += left.send(bytes(1 << 16)) + except (BlockingIOError, InterruptedError): + break + left.setblocking(True) + + source = bytearray(1 << 16) + received = [] + + def receive(): + wanted = filled + len(source) + while sum(received) < wanted: + chunk = right.recv(1 << 16) + if not chunk: + break + received.append(len(chunk)) + + reader = threading.Thread(target=receive, daemon=True) + run(source, left.sendall, reader.start, writable=True) + reader.join() + assert sum(received) == filled + len(source), ( + sum(received), + filled, + len(source), + ) + finally: + left.close() + right.close() + +print("ok") diff --git a/extra_tests/snippets/stdlib_io_bytesio.py b/extra_tests/snippets/stdlib_io_bytesio.py index ba8ae2001..9344c50d9 100644 --- a/extra_tests/snippets/stdlib_io_bytesio.py +++ b/extra_tests/snippets/stdlib_io_bytesio.py @@ -106,3 +106,11 @@ if __name__ == "__main__": test_05() test_06() test_07() + + +# Reading into a buffer that views this same object locks it twice unless the +# read finishes first. +_bio = BytesIO(b"x" * 60) +assert _bio.readinto(_bio.getbuffer()) == 60 +_bio = BytesIO(b"x" * 60) +assert _bio.readinto(memoryview(_bio.getbuffer())) == 60 diff --git a/extra_tests/snippets/stdlib_marshal.py b/extra_tests/snippets/stdlib_marshal.py index 8881d3e0a..4e224fb31 100644 --- a/extra_tests/snippets/stdlib_marshal.py +++ b/extra_tests/snippets/stdlib_marshal.py @@ -96,5 +96,63 @@ class MarshalTests(unittest.TestCase): self.assertIs(loaded_code.co_consts[0], loaded_shared) +class AllowCodeTests(unittest.TestCase): + """allow_code is answered where a code object is written or read, so a + graph that walks back on itself is not a second walk of its own.""" + + def test_recursive_value(self): + recursive = [] + recursive.append(recursive) + loaded = marshal.loads( + marshal.dumps(recursive, allow_code=False), allow_code=False + ) + self.assertIs(loaded[0], loaded) + + def test_too_deeply_nested(self): + nested = [] + for _ in range(100_000): + nested = [nested] + with self.assertRaises(ValueError): + marshal.dumps(nested, allow_code=False) + + def test_code_is_rejected(self): + code = compile("1", "", "exec") + for value in (code, [code], (code,), {0: code}): + with self.assertRaises(ValueError): + marshal.dumps(value, allow_code=False) + data = marshal.dumps(value) + with self.assertRaises(ValueError): + marshal.loads(data, allow_code=False) + + +class BadDataTests(unittest.TestCase): + def test_container_size_out_of_range(self): + import struct + + # a length is signed, so the top bit set is out of range rather than + # four billion items to reserve room for + for marker in b"([<>": + data = bytes([marker | 0x80]) + struct.pack("H", "'H' format requires 0 <= number <= 65535"), + (">i", "'i' format requires -2147483648 <= number <= 2147483647"), + ("N", "'N' format requires 0 <= number <= 18446744073709551615"), + ("P", "int too large to convert"), +): + try: + struct.pack(fmt, 10**30) + except struct.error as e: + assert str(e) == message, (fmt, str(e)) + else: + raise AssertionError(f"expected struct.error for {fmt!r}") + +try: + struct.pack("B", "x") +except struct.error as e: + assert str(e) == "required argument is not an integer", e +else: + raise AssertionError("expected struct.error") + + +# __init__ reads a new format into a Struct that already holds one. +s = struct.Struct(">h") +s.__init__(">hh") +assert s.format == ">hh" +assert s.size == 4 +assert s.pack(1, 2) == b"\x00\x01\x00\x02" +assert s.unpack(b"\x00\x01\x00\x02") == (1, 2) + +# A format that cannot be read leaves the Struct as it was. +for bad in ("\udc00", "$"): + with assert_raises((UnicodeEncodeError, struct.error)): + s.__init__(bad) + assert s.format == ">hh" + assert s.pack(1, 2) == b"\x00\x01\x00\x02" + + +# A subclass may do its own __init__ and pass the format up. +class BigShort(struct.Struct): + def __init__(self): + super().__init__(">h") + + +assert BigShort().pack(12345) == b"\x30\x39" + +# Until __init__ runs there is no format to answer with. +blank = struct.Struct.__new__(struct.Struct) +assert blank.size == -1 +for call in ( + lambda: blank.format, + lambda: blank.pack(1), + lambda: blank.unpack(b"aa"), + lambda: blank.unpack_from(b"aaaa"), + lambda: blank.pack_into(bytearray(4), 0, 1), + lambda: blank.iter_unpack(b"aa"), + lambda: repr(blank), +): + with assert_raises(RuntimeError): + call() diff --git a/extra_tests/snippets/stdlib_threading_current_frames.py b/extra_tests/snippets/stdlib_threading_current_frames.py new file mode 100644 index 000000000..e93a22214 --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_current_frames.py @@ -0,0 +1,100 @@ +"""Take sys._current_frames() while other threads are running Python. + +The frame each thread is executing is published for cross-thread readers, and +_current_frames() takes a reference to it with the world stopped. A reader that +disagrees with the publisher about what the published pointer addresses reads +and reference-counts the wrong memory, which corrupts a neighbouring object +rather than failing at the read: the damage surfaces later, in the thread that +owns it, as a crash or a wedge. + +Workers therefore run ordinary Python calls (which publish a frame) in a tight +loop while the main thread hammers _current_frames(). +""" + +import sys +import threading +import time + +DURATION = 1.5 + + +def leaf(): + return sum(range(8)) + + +def nest(n): + if n: + return nest(n - 1) + return leaf() + + +def worker(stop): + while not stop.is_set(): + nest(16) + + +def frames_are_sane(frames): + # Every key is a thread id, every value a frame of this process. + for tid, frame in frames.items(): + assert isinstance(tid, int), tid + assert tid > 0, tid + assert type(frame).__name__ == "frame", frame + assert isinstance(frame.f_lineno, int), frame + assert isinstance(frame.f_code.co_name, str), frame + + +# The main thread sees itself where it stands. +me = sys._current_frames()[threading.get_ident()] +assert me is sys._getframe(), me + +stop = threading.Event() +threads = [threading.Thread(target=worker, args=(stop,)) for _ in range(4)] +for t in threads: + t.start() + +deadline = time.time() + DURATION +calls = 0 +while time.time() < deadline: + frames_are_sane(sys._current_frames()) + calls += 1 +stop.set() +for t in threads: + t.join() + +assert calls > 0, calls + + +# A thread parked in a call the main thread can name is reported inside it, +# with its callers reachable through f_back. +entered = threading.Event() +leave = threading.Event() +seen = [] + + +def g456(): + seen.append(threading.get_ident()) + entered.set() + leave.wait() + + +def f123(): + g456() + + +t = threading.Thread(target=f123) +t.start() +entered.wait() +try: + chain = [] + frame = sys._current_frames()[seen[0]] + while frame is not None: + chain.append(frame.f_code.co_name) + frame = frame.f_back + assert "g456" in chain, chain + assert "f123" in chain, chain + assert chain.index("g456") < chain.index("f123"), chain +finally: + leave.set() + t.join() + +print("ok") diff --git a/extra_tests/snippets/stdlib_types.py b/extra_tests/snippets/stdlib_types.py index 335069811..4bccd2985 100644 --- a/extra_tests/snippets/stdlib_types.py +++ b/extra_tests/snippets/stdlib_types.py @@ -47,14 +47,14 @@ if sys.implementation.name == "rustpython": list[self_referential] nested = [0] - for _ in range(sys.getrecursionlimit() * 2): + for _ in range(100_000): nested = [nested] with assert_raises(RecursionError): list[nested] # hashing an alias walks the same shape deep_alias = int - for _ in range(sys.getrecursionlimit() * 2): + for _ in range(100_000): deep_alias = list[deep_alias] with assert_raises(RecursionError): hash(deep_alias) diff --git a/extra_tests/snippets/stdlib_typing.py b/extra_tests/snippets/stdlib_typing.py index 98d368c02..4082d683f 100644 --- a/extra_tests/snippets/stdlib_typing.py +++ b/extra_tests/snippets/stdlib_typing.py @@ -45,3 +45,21 @@ class ClassWithUnionParams: assert _typing._idfunc(1) == 1 with assert_raises(TypeError): _typing._idfunc() + + +# ParamSpecArgs shows a non-ParamSpec origin by its repr, which is where the +# recursion guard lives; nesting them deeply must not walk the native stack. + +from typing import ParamSpec, ParamSpecArgs + +spec = ParamSpec("spec") +assert repr(spec.args) == "spec.args" +assert repr(spec.kwargs) == "spec.kwargs" + +nested = object() +for _ in range(2000): + nested = ParamSpecArgs(nested) +try: + repr(nested) +except RecursionError: + pass From 819b4b86fda641ff239d5298144f3f3c344573da Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:33:56 +0900 Subject: [PATCH 88/94] Fix malformed Unicode error constructors (#8497) * Fix malformed Unicode error constructors Replace message-only Unicode decode and encode errors with fully initialized exceptions, preserve source objects and failure ranges, and remove the obsolete constructors. Assisted-by: Codex:gpt-5 * Fix Windows Unicode CI regressions Assisted-by: Codex:gpt-5 --- Lib/test/test_codeccallbacks.py | 3 +- crates/capi/src/pyerrors.rs | 2 +- crates/host_env/src/posix.rs | 28 +++++++++--- crates/stdlib/src/array.rs | 34 +++++++++++++-- crates/stdlib/src/csv.rs | 2 +- crates/stdlib/src/socket.rs | 4 +- crates/stdlib/src/tkinter.rs | 15 +++++-- crates/vm/src/codecs.rs | 2 +- crates/vm/src/function/fspath.rs | 2 +- crates/vm/src/stdlib/_codecs.rs | 51 ++++++++++++---------- crates/vm/src/stdlib/nt.rs | 73 +++++++++++++++----------------- crates/vm/src/stdlib/os.rs | 2 +- crates/vm/src/stdlib/posix.rs | 18 ++++++-- crates/vm/src/vm/vm_new.rs | 8 +--- 14 files changed, 149 insertions(+), 95 deletions(-) diff --git a/Lib/test/test_codeccallbacks.py b/Lib/test/test_codeccallbacks.py index 763146c94..28ddf0a63 100644 --- a/Lib/test/test_codeccallbacks.py +++ b/Lib/test/test_codeccallbacks.py @@ -1067,8 +1067,7 @@ class CodecCallbackTest(unittest.TestCase): decoded = input.decode(enc, "test.bug36819") self.assertEqual(decoded, 'abcdx' * 51) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailureIf(sys.platform != "win32", "TODO: RUSTPYTHON") def test_encodehelper_bug36819(self): handler = RepeatedPosReturn() codecs.register_error("test.bug36819", handler.handle) diff --git a/crates/capi/src/pyerrors.rs b/crates/capi/src/pyerrors.rs index 25b76f333..a4ead856b 100644 --- a/crates/capi/src/pyerrors.rs +++ b/crates/capi/src/pyerrors.rs @@ -347,7 +347,7 @@ pub unsafe extern "C" fn PyUnicodeDecodeError_Create( unsafe { slice::from_raw_parts(object.cast::(), length) }.to_vec() }; - let exc = vm.new_unicode_decode_error_real( + let exc = vm.new_unicode_decode_error( vm.ctx.new_str(encoding), vm.ctx.new_bytes(bytes), start, diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index 50d3f52a6..1e8d4cabe 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -1,5 +1,4 @@ use alloc::ffi::CString; -#[cfg(all(unix, not(target_os = "redox")))] use alloc::vec::Vec; use core::ffi::CStr; #[cfg(all(unix, not(target_os = "redox")))] @@ -22,6 +21,12 @@ pub struct UnameInfo { pub machine: String, } +#[derive(Debug)] +pub struct UnameDecodeError { + pub bytes: Vec, + pub error: core::str::Utf8Error, +} + #[cfg(all(unix, not(target_os = "redox")))] #[derive(Clone, Copy, Debug)] pub struct StatVfsInfo { @@ -354,14 +359,23 @@ pub fn fchownat( .map_err(std::io::Error::from) } -pub fn uname_info() -> Result { +pub fn uname_info() -> Result { + fn decode(value: &CStr) -> Result { + core::str::from_utf8(value.to_bytes()) + .map(str::to_owned) + .map_err(|error| UnameDecodeError { + bytes: value.to_bytes().to_vec(), + error, + }) + } + let info = rustix::system::uname(); Ok(UnameInfo { - sysname: info.sysname().to_str()?.into(), - nodename: info.nodename().to_str()?.into(), - release: info.release().to_str()?.into(), - version: info.version().to_str()?.into(), - machine: info.machine().to_str()?.into(), + sysname: decode(info.sysname())?, + nodename: decode(info.nodename())?, + release: decode(info.release())?, + version: decode(info.version())?, + machine: decode(info.machine())?, }) } diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index 0f652efd3..b1fa925e1 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -657,7 +657,7 @@ pub mod array { impl ToPyResult for WideChar { fn to_pyresult(self, vm: &VirtualMachine) -> PyResult { Ok(CodePoint::try_from(self) - .map_err(|e| vm.new_unicode_encode_error(e))? + .map_err(|e| vm.new_value_error(e))? .to_pyobject(vm)) } } @@ -1755,8 +1755,17 @@ pub mod array { })?, MachineFormatCode::Utf16 { big_endian } => { let utf16: Vec<_> = chunks.map(|b| chunk_to_obj!(b, u16, big_endian)).collect(); - let s = String::from_utf16(&utf16) - .map_err(|_| vm.new_unicode_encode_error("items cannot decode as utf16"))?; + let s = String::from_utf16(&utf16).map_err(|_| { + let (index, reason) = invalid_utf16(&utf16).unwrap(); + vm.new_unicode_decode_error( + vm.ctx + .new_str(if big_endian { "utf-16-be" } else { "utf-16-le" }), + args.items.clone(), + index * 2, + index * 2 + 2, + vm.ctx.new_str(reason), + ) + })?; let bytes = PyArray::_unicode_to_wchar_bytes((*s).as_ref(), array.itemsize()); array.frombytes_move(bytes); } @@ -1772,6 +1781,25 @@ pub mod array { PyArray::from(array).into_ref_with_type(vm, cls) } + fn invalid_utf16(units: &[u16]) -> Option<(usize, &'static str)> { + let mut index = 0; + while index < units.len() { + let unit = units[index]; + if (0xd800..=0xdbff).contains(&unit) { + match units.get(index + 1) { + Some(next) if (0xdc00..=0xdfff).contains(next) => index += 2, + Some(_) => return Some((index, "illegal UTF-16 surrogate")), + None => return Some((index, "unexpected end of data")), + } + } else if (0xdc00..=0xdfff).contains(&unit) { + return Some((index, "illegal encoding")); + } else { + index += 1; + } + } + None + } + // Register array.array as collections.abc.MutableSequence pub(crate) fn module_exec( vm: &VirtualMachine, diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index 3471f7a28..5697689ae 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -64,7 +64,7 @@ mod _csv { bytes: &[u8], err: core::str::Utf8Error, ) -> PyBaseExceptionRef { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(bytes.to_vec()), err.valid_up_to(), diff --git a/crates/stdlib/src/socket.rs b/crates/stdlib/src/socket.rs index 4e02dca45..a1998ba7c 100644 --- a/crates/stdlib/src/socket.rs +++ b/crates/stdlib/src/socket.rs @@ -2624,7 +2624,7 @@ mod _socket { Some(ArgStrOrBytesLike::Buf(b)) => { let bytes = b.borrow_buf(); let host_str = core::str::from_utf8(&bytes).map_err(|e| { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(bytes.to_vec()), e.valid_up_to(), @@ -2666,7 +2666,7 @@ mod _socket { let bytes = b.borrow_buf(); core::str::from_utf8(&bytes) .map_err(|e| { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(bytes.to_vec()), e.valid_up_to(), diff --git a/crates/stdlib/src/tkinter.rs b/crates/stdlib/src/tkinter.rs index 653d6edb7..ca70561b3 100644 --- a/crates/stdlib/src/tkinter.rs +++ b/crates/stdlib/src/tkinter.rs @@ -162,9 +162,18 @@ mod _tkinter { if let Some(tcl_obj) = obj.downcast_ref::() { let c_str = unsafe { tk_sys::Tcl_GetString(tcl_obj.value) }; - let varname = unsafe { ffi::CStr::from_ptr(c_str as _) } - .to_str() - .map_err(|e| vm.new_unicode_decode_error(e.to_string()))? + let bytes = unsafe { ffi::CStr::from_ptr(c_str as _) }.to_bytes(); + let varname = core::str::from_utf8(bytes) + .map_err(|e| { + vm.new_unicode_decode_error( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(bytes.to_vec()), + e.valid_up_to(), + e.error_len() + .map_or(bytes.len(), |len| e.valid_up_to() + len), + vm.ctx.new_str(e.to_string()), + ) + })? .to_owned(); return Ok(varname); } diff --git a/crates/vm/src/codecs.rs b/crates/vm/src/codecs.rs index c06caefef..073fd102f 100644 --- a/crates/vm/src/codecs.rs +++ b/crates/vm/src/codecs.rs @@ -802,7 +802,7 @@ impl DecodeContext for PyDecodeContext<'_> { } else { vm.ctx.new_bytes(self.data.to_vec()) }; - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str(self.encoding), data, byte_range.start, diff --git a/crates/vm/src/function/fspath.rs b/crates/vm/src/function/fspath.rs index 954c82cb7..f0ab29090 100644 --- a/crates/vm/src/function/fspath.rs +++ b/crates/vm/src/function/fspath.rs @@ -125,7 +125,7 @@ impl FsPath { pub fn bytes_as_os_str<'a>(b: &'a [u8], vm: &VirtualMachine) -> PyResult<&'a std::ffi::OsStr> { rustpython_host_env::os::bytes_as_os_str(b).map_err(|e| { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(b.to_vec()), e.valid_up_to(), diff --git a/crates/vm/src/stdlib/_codecs.rs b/crates/vm/src/stdlib/_codecs.rs index 497d62fcc..8f6ea5f19 100644 --- a/crates/vm/src/stdlib/_codecs.rs +++ b/crates/vm/src/stdlib/_codecs.rs @@ -382,6 +382,23 @@ mod _codecs_windows { use crate::{builtins::PyStrRef, builtins::PyUtf8StrRef, function::ArgBytesLike}; use rustpython_host_env::windows as host_windows; + fn string_from_utf16( + encoding: &str, + data: &[u8], + wide: &[u16], + vm: &VirtualMachine, + ) -> PyResult { + String::from_utf16(wide).map_err(|err| { + vm.new_unicode_decode_error( + vm.ctx.new_str(encoding), + vm.ctx.new_bytes(data.to_vec()), + 0, + data.len(), + vm.ctx.new_str(format!("{encoding}_decode failed: {err}")), + ) + }) + } + #[derive(FromArgs)] struct MbcsEncodeArgs { #[pyarg(positional)] @@ -399,9 +416,7 @@ mod _codecs_windows { Some(s) => s, None => { // String contains surrogates - not encodable with mbcs - return Err(vm.new_unicode_encode_error( - "'mbcs' codec can't encode character: surrogates not allowed", - )); + return encode_code_page_errors(host_windows::CP_ACP, &args.s, errors, "mbcs", vm); } }; let char_len = args.s.char_len(); @@ -433,9 +448,7 @@ mod _codecs_windows { .map_err(|err| vm.new_os_error(format!("mbcs_encode failed: {err}")))?; if errors == "strict" && used_default_char { - return Err(vm.new_unicode_encode_error( - "'mbcs' codec can't encode characters: invalid character", - )); + return encode_code_page_errors(host_windows::CP_ACP, &args.s, errors, "mbcs", vm); } buffer.truncate(result); @@ -484,8 +497,7 @@ mod _codecs_windows { ) .map_err(|err| vm.new_os_error(format!("mbcs_decode failed: {err}")))?; buffer.truncate(result); - let s = String::from_utf16(&buffer) - .map_err(|e| vm.new_unicode_decode_error(format!("mbcs_decode failed: {e}")))?; + let s = string_from_utf16("mbcs", data.as_ref(), &buffer, vm)?; return Ok((s, len)); } @@ -500,8 +512,7 @@ mod _codecs_windows { ) .map_err(|err| vm.new_os_error(format!("mbcs_decode failed: {err}")))?; buffer.truncate(result); - let s = String::from_utf16(&buffer) - .map_err(|e| vm.new_unicode_decode_error(format!("mbcs_decode failed: {e}")))?; + let s = string_from_utf16("mbcs", data.as_ref(), &buffer, vm)?; Ok((s, len)) } @@ -523,9 +534,7 @@ mod _codecs_windows { Some(s) => s, None => { // String contains surrogates - not encodable with oem - return Err(vm.new_unicode_encode_error( - "'oem' codec can't encode character: surrogates not allowed", - )); + return encode_code_page_errors(host_windows::CP_OEMCP, &args.s, errors, "oem", vm); } }; let char_len = args.s.char_len(); @@ -557,9 +566,7 @@ mod _codecs_windows { .map_err(|err| vm.new_os_error(format!("oem_encode failed: {err}")))?; if errors == "strict" && used_default_char { - return Err(vm.new_unicode_encode_error( - "'oem' codec can't encode characters: invalid character", - )); + return encode_code_page_errors(host_windows::CP_OEMCP, &args.s, errors, "oem", vm); } buffer.truncate(result); @@ -609,8 +616,7 @@ mod _codecs_windows { ) .map_err(|err| vm.new_os_error(format!("oem_decode failed: {err}")))?; buffer.truncate(result); - let s = String::from_utf16(&buffer) - .map_err(|e| vm.new_unicode_decode_error(format!("oem_decode failed: {e}")))?; + let s = string_from_utf16("oem", data.as_ref(), &buffer, vm)?; return Ok((s, len)); } @@ -625,8 +631,7 @@ mod _codecs_windows { ) .map_err(|err| vm.new_os_error(format!("oem_decode failed: {err}")))?; buffer.truncate(result); - let s = String::from_utf16(&buffer) - .map_err(|e| vm.new_unicode_decode_error(format!("oem_decode failed: {e}")))?; + let s = string_from_utf16("oem", data.as_ref(), &buffer, vm)?; Ok((s, len)) } @@ -1024,7 +1029,7 @@ mod _codecs_windows { } } let object = vm.ctx.new_bytes(data.to_vec()); - return Err(vm.new_unicode_decode_error_real( + return Err(vm.new_unicode_decode_error( encoding_str, object, fail_pos, @@ -1115,7 +1120,7 @@ mod _codecs_windows { } "strict" => { let object = vm.ctx.new_bytes(data.to_vec()); - return Err(vm.new_unicode_decode_error_real( + return Err(vm.new_unicode_decode_error( encoding_str, object, pos, @@ -1126,7 +1131,7 @@ mod _codecs_windows { _ => { // Custom error handler let object = vm.ctx.new_bytes(data.to_vec()); - let exc = vm.new_unicode_decode_error_real( + let exc = vm.new_unicode_decode_error( encoding_str.clone(), object, pos, diff --git a/crates/vm/src/stdlib/nt.rs b/crates/vm/src/stdlib/nt.rs index 31a08195c..26412e352 100644 --- a/crates/vm/src/stdlib/nt.rs +++ b/crates/vm/src/stdlib/nt.rs @@ -19,7 +19,7 @@ pub(crate) mod module { use libc::intptr_t; use rustpython_common::wtf8::Wtf8Buf; use rustpython_host_env::nt as host_nt; - use std::os::windows::ffi::OsStringExt; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; use std::os::windows::io::AsRawHandle; #[pyattr] @@ -49,6 +49,26 @@ pub(crate) mod module { #[pyattr] const TMP_MAX: i32 = i32::MAX; + fn utf8_from_bytes<'a>(bytes: &'a [u8], vm: &VirtualMachine) -> PyResult<&'a str> { + core::str::from_utf8(bytes).map_err(|err| { + let reason = match err.error_len() { + None => "unexpected end of data", + Some(_) => match bytes[err.valid_up_to()] { + 0xc2..=0xf4 => "invalid continuation byte", + _ => "invalid start byte", + }, + }; + vm.new_unicode_decode_error( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(bytes.to_vec()), + err.valid_up_to(), + err.error_len() + .map_or(bytes.len(), |len| err.valid_up_to() + len), + vm.ctx.new_str(reason), + ) + }) + } + #[pyattr] use host_nt::{ LOAD_LIBRARY_SEARCH_APPLICATION_DIR as _LOAD_LIBRARY_SEARCH_APPLICATION_DIR, @@ -214,11 +234,8 @@ pub(crate) mod module { fn _findfirstfile(path: OsPath, vm: &VirtualMachine) -> PyResult { let filename = host_nt::find_first_file_name(path.as_ref()) .map_err(|err| OSErrorBuilder::with_filename(&err, path.clone(), vm))?; - let filename_str = filename - .to_str() - .ok_or_else(|| vm.new_unicode_decode_error("filename contains invalid UTF-8"))?; - - Ok(vm.ctx.new_str(filename_str)) + let filename_wide: Vec<_> = filename.encode_wide().collect(); + Ok(vm.ctx.new_str(Wtf8Buf::from_wide(&filename_wide))) } #[derive(FromArgs)] @@ -689,17 +706,7 @@ pub(crate) mod module { (wide, false) } else if let Some(b) = path.downcast_ref::() { // On Windows, bytes must be valid UTF-8 - this raises UnicodeDecodeError if not - let s = core::str::from_utf8(b.as_bytes()).map_err(|e| { - vm.new_exception_msg( - vm.ctx.exceptions.unicode_decode_error.to_owned(), - format!( - "'utf-8' codec can't decode byte {:#x} in position {}: invalid start byte", - b.as_bytes().get(e.valid_up_to()).copied().unwrap_or(0), - e.valid_up_to() - ) - .into(), - ) - })?; + let s = utf8_from_bytes(b.as_bytes(), vm)?; let wide: Vec = s.encode_utf16().collect(); (wide, true) } else { @@ -720,16 +727,13 @@ pub(crate) mod module { // Return as bytes if input was bytes, preserving the original content if is_bytes { // Convert UTF-16 back to UTF-8 for bytes output - let drv = String::from_utf16(&wide[..drv_size]) - .map_err(|e| vm.new_unicode_decode_error(e.to_string()))?; - let root = String::from_utf16(&wide[drv_size..drv_size + root_size]) - .map_err(|e| vm.new_unicode_decode_error(e.to_string()))?; - let tail = String::from_utf16(&wide[drv_size + root_size..]) - .map_err(|e| vm.new_unicode_decode_error(e.to_string()))?; + let drv = Wtf8Buf::from_wide(&wide[..drv_size]).into_bytes(); + let root = Wtf8Buf::from_wide(&wide[drv_size..drv_size + root_size]).into_bytes(); + let tail = Wtf8Buf::from_wide(&wide[drv_size + root_size..]).into_bytes(); Ok(vm.ctx.new_tuple(vec![ - vm.ctx.new_bytes(drv.into_bytes()).into(), - vm.ctx.new_bytes(root.into_bytes()).into(), - vm.ctx.new_bytes(tail.into_bytes()).into(), + vm.ctx.new_bytes(drv).into(), + vm.ctx.new_bytes(root).into(), + vm.ctx.new_bytes(tail).into(), ])) } else { // For str output, use WTF-8 to handle surrogates @@ -913,17 +917,7 @@ pub(crate) mod module { let wide: Vec = s.as_wtf8().encode_wide().collect(); (wide, false) } else if let Some(b) = path.downcast_ref::() { - let s = core::str::from_utf8(b.as_bytes()).map_err(|e| { - vm.new_exception_msg( - vm.ctx.exceptions.unicode_decode_error.to_owned(), - format!( - "'utf-8' codec can't decode byte {:#x} in position {}: invalid start byte", - b.as_bytes().get(e.valid_up_to()).copied().unwrap_or(0), - e.valid_up_to() - ) - .into(), - ) - })?; + let s = utf8_from_bytes(b.as_bytes(), vm)?; let wide: Vec = s.encode_utf16().collect(); (wide, true) } else { @@ -936,9 +930,8 @@ pub(crate) mod module { let normalized = normpath_wide(&wide); if is_bytes { - let s = String::from_utf16(&normalized) - .map_err(|e| vm.new_unicode_decode_error(e.to_string()))?; - Ok(vm.ctx.new_bytes(s.into_bytes()).into()) + let bytes = Wtf8Buf::from_wide(&normalized).into_bytes(); + Ok(vm.ctx.new_bytes(bytes).into()) } else { let s = Wtf8Buf::from_wide(&normalized); Ok(vm.ctx.new_str(s).into()) diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index 9156c9fc0..dccc0ae7e 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -131,7 +131,7 @@ pub(super) struct FollowSymlinks( #[cfg(not(windows))] fn bytes_as_os_str<'a>(b: &'a [u8], vm: &VirtualMachine) -> PyResult<&'a std::ffi::OsStr> { rustpython_host_env::os::bytes_as_os_str(b).map_err(|e| { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(b.to_vec()), e.valid_up_to(), diff --git a/crates/vm/src/stdlib/posix.rs b/crates/vm/src/stdlib/posix.rs index f4d5a70db..9233af9fb 100644 --- a/crates/vm/src/stdlib/posix.rs +++ b/crates/vm/src/stdlib/posix.rs @@ -1340,8 +1340,20 @@ pub mod module { #[pyfunction] fn uname(vm: &VirtualMachine) -> PyResult<_os::UnameResultData> { - let info = rustpython_host_env::posix::uname_info() - .map_err(|err| vm.new_unicode_decode_error(err.to_string()))?; + let info = rustpython_host_env::posix::uname_info().map_err(|err| { + let start = err.error.valid_up_to(); + let end = err + .error + .error_len() + .map_or(err.bytes.len(), |len| start + len); + vm.new_unicode_decode_error( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(err.bytes), + start, + end, + vm.ctx.new_str(err.error.to_string()), + ) + })?; Ok(_os::UnameResultData { sysname: info.sysname, nodename: info.nodename, @@ -1791,7 +1803,7 @@ pub mod module { return Err(vm.new_os_error("unable to determine login name")); }; login.to_str().map(|s| s.to_owned()).map_err(|e| { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(login.as_bytes().to_vec()), e.valid_up_to(), diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index 6110a3d5b..82f382ca6 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -517,7 +517,7 @@ impl VirtualMachine { self.new_os_subtype_error(exc_type.to_owned(), Some(errno), msg) } - pub fn new_unicode_decode_error_real( + pub fn new_unicode_decode_error( &self, encoding: PyStrRef, object: PyBytesRef, @@ -996,12 +996,6 @@ impl VirtualMachine { define_exception_fn!(fn new_type_error, type_error, TypeError); define_exception_fn!(fn new_system_error, system_error, SystemError); - // TODO: remove & replace with new_unicode_decode_error_real - define_exception_fn!(fn new_unicode_decode_error, unicode_decode_error, UnicodeDecodeError); - - // TODO: remove & replace with new_unicode_encode_error_real - define_exception_fn!(fn new_unicode_encode_error, unicode_encode_error, UnicodeEncodeError); - define_exception_fn!(fn new_value_error, value_error, ValueError); define_exception_fn!(fn new_buffer_error, buffer_error, BufferError); From cd935b8ef6f1e624e6c47948f486bb56a552ce14 Mon Sep 17 00:00:00 2001 From: sijun-yang Date: Mon, 17 Aug 2026 10:11:09 +0900 Subject: [PATCH 89/94] Expose symbol scopes through _symtable (#8538) Pack symbol scopes with definition flags in _symtable.symbols for Lib/symtable.py. Assisted-by: Codex:gpt-5 --- Lib/test/test_symtable.py | 6 ------ crates/vm/src/stdlib/_symtable.rs | 5 +++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Lib/test/test_symtable.py b/Lib/test/test_symtable.py index 8cd1da1e9..16204bc45 100644 --- a/Lib/test/test_symtable.py +++ b/Lib/test/test_symtable.py @@ -247,7 +247,6 @@ class SymtableTest(unittest.TestCase): self.assertEqual(self.top.get_lineno(), 0) self.assertEqual(self.spam.get_lineno(), 14) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Lists differ: [] != ['a', 'b', 'internal', 'kw', 'other_internal', 'some_var', 'var', 'x'] def test_function_info(self): func = self.spam self.assertEqual(sorted(func.get_parameters()), ["a", "b", "kw", "var"]) @@ -256,7 +255,6 @@ class SymtableTest(unittest.TestCase): self.assertEqual(sorted(func.get_globals()), ["bar", "glob", "some_assigned_global_var"]) self.assertEqual(self.internal.get_frees(), ("x",)) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_globals(self): self.assertTrue(self.spam.lookup("glob").is_global()) self.assertFalse(self.spam.lookup("glob").is_declared_global()) @@ -275,7 +273,6 @@ class SymtableTest(unittest.TestCase): expected = ("some_var",) self.assertEqual(self.other_internal.get_nonlocals(), expected) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_local(self): self.assertTrue(self.spam.lookup("x").is_local()) self.assertFalse(self.spam.lookup("bar").is_local()) @@ -283,7 +280,6 @@ class SymtableTest(unittest.TestCase): self.assertTrue(self.top.lookup("some_non_assigned_global_var").is_local()) self.assertTrue(self.top.lookup("some_assigned_global_var").is_local()) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_free(self): self.assertTrue(self.internal.lookup("x").is_free()) @@ -328,7 +324,6 @@ class SymtableTest(unittest.TestCase): self.assertTrue(self.Mine.lookup("a_method").is_assigned()) self.assertFalse(self.internal.lookup("x").is_assigned()) - @unittest.expectedFailure # TODO: RUSTPYTHON; IndexError: list index out of range def test_annotated(self): st1 = symtable.symtable('def f():\n x: int\n', 'test', 'exec') st2 = st1.get_children()[1] @@ -493,7 +488,6 @@ class SymtableTest(unittest.TestCase): self.assertEqual(str(self.top), "") self.assertEqual(str(self.spam), "") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: AssertionError: "" != "" def test_symbol_repr(self): self.assertEqual(repr(self.spam.lookup("glob")), "") diff --git a/crates/vm/src/stdlib/_symtable.rs b/crates/vm/src/stdlib/_symtable.rs index c4a4a7a20..eb0ecaa87 100644 --- a/crates/vm/src/stdlib/_symtable.rs +++ b/crates/vm/src/stdlib/_symtable.rs @@ -185,8 +185,9 @@ mod _symtable { fn symbols(&self, vm: &VirtualMachine) -> PyDictRef { let dict = vm.ctx.new_dict(); for (name, symbol) in &self.symtable.symbols { - dict.set_item(name, vm.new_pyobj(symbol.flags.bits()), vm) - .unwrap(); + let packed_flags = + i32::from(symbol.flags.bits()) | (symbol.scope.as_i32() << SCOPE_OFFSET); + dict.set_item(name, vm.new_pyobj(packed_flags), vm).unwrap(); } dict } From 62f4c9895a7000399e0457bc647914fbb5727ef3 Mon Sep 17 00:00:00 2001 From: Kwak Byoung Min <67214970+doma17@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:38:16 +0900 Subject: [PATCH 90/94] Add StringIO newline tracking (#8539) * Report observed StringIO newline types Constraint: Match CPython StringIO newline reporting without changing configured newline translation. Rejected: Separate newline-tracking representation | reuse the existing SeenNewline bitflags. Confidence: high Scope-risk: narrow Directive: Keep observed newline state separate from the configured newline mode. Tested: prek run --all-files; test_memoryio; cargo clippy -p rustpython-vm --lib -- -D warnings; workspace tests excluding the macOS C-API baseline SIGSEGV. Not-tested: Full macOS workspace suite is blocked by the existing rustpython-capi SIGSEGV; the Linux suite is running. Assisted-by: Codex:gpt-5.6-sol * Preserve StringIO state boundaries Reset observed newline state when StringIO is reinitialized and keep the property subject to closed-stream checks. Constraint: Match CPython StringIO reinitialization and closed-stream behavior. Rejected: Returning stale or cached newline state | violates StringIO lifecycle semantics. Confidence: high Scope-risk: narrow Directive: Keep observed newline state tied to the current open StringIO contents. Tested: cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher; cargo run --release -- extra_tests/snippets/stdlib_io_stringio.py; cargo run --release -- -m test test_memoryio; prek run --all-files; cargo clippy -p rustpython-vm --lib -- -D warnings. Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_memoryio.py | 8 -- crates/vm/src/stdlib/_io.rs | 95 ++++++++++++++++------ extra_tests/snippets/stdlib_io_stringio.py | 16 ++++ 3 files changed, 84 insertions(+), 35 deletions(-) diff --git a/Lib/test/test_memoryio.py b/Lib/test/test_memoryio.py index 7f57d20f2..7ad3aa8a5 100644 --- a/Lib/test/test_memoryio.py +++ b/Lib/test/test_memoryio.py @@ -996,10 +996,6 @@ class CStringIOTest(PyStringIOTest): def test_flags(self): return super().test_flags() - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'StringIO' object has no attribute 'newlines'. Did you mean: 'readlines'? - def test_newlines_property(self): - return super().test_newlines_property() - class CStringIOPickleTest(PyStringIOPickleTest): UnsupportedOperation = io.UnsupportedOperation @@ -1009,9 +1005,5 @@ class CStringIOPickleTest(PyStringIOPickleTest): def __init__(self, *args, **kwargs): pass - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'StringIO' object has no attribute 'newlines'. Did you mean: 'readlines'? - def test_newlines_property(self): - return super().test_newlines_property() - if __name__ == '__main__': unittest.main() diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 9aad883b0..1b4439007 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -4193,6 +4193,37 @@ mod _io { } } + impl SeenNewline { + fn observe(&mut self, text: &Wtf8) { + let bytes = text.as_bytes(); + let mut matches = memchr::memchr2_iter(b'\r', b'\n', bytes); + while !self.is_all() { + let Some(i) = matches.next() else { break }; + match bytes[i] { + b'\n' => self.insert(Self::LF), + _ if bytes.get(i + 1) == Some(&b'\n') => { + matches.next(); + self.insert(Self::CRLF); + } + _ => self.insert(Self::CR), + } + } + } + + fn to_pyobject(self, vm: &VirtualMachine) -> PyObjectRef { + match self.bits() { + 1 => "\n".to_pyobject(vm), + 2 => "\r".to_pyobject(vm), + 3 => ("\r", "\n").to_pyobject(vm), + 4 => "\r\n".to_pyobject(vm), + 5 => ("\n", "\r\n").to_pyobject(vm), + 6 => ("\r", "\r\n").to_pyobject(vm), + 7 => ("\r", "\n", "\r\n").to_pyobject(vm), + _ => vm.ctx.none(), + } + } + } + impl DefaultConstructor for IncrementalNewlineDecoder {} #[derive(FromArgs)] @@ -4284,16 +4315,7 @@ mod _io { #[pygetset] fn newlines(&self, vm: &VirtualMachine) -> PyResult { let data = self.lock(vm)?; - Ok(match data.seennl.bits() { - 1 => "\n".to_pyobject(vm), - 2 => "\r".to_pyobject(vm), - 3 => ("\r", "\n").to_pyobject(vm), - 4 => "\r\n".to_pyobject(vm), - 5 => ("\n", "\r\n").to_pyobject(vm), - 6 => ("\r", "\r\n").to_pyobject(vm), - 7 => ("\r", "\n", "\r\n").to_pyobject(vm), - _ => vm.ctx.none(), - }) + Ok(data.seennl.to_pyobject(vm)) } } @@ -4340,20 +4362,7 @@ mod _io { self.seennl.insert(SeenNewline::LF); } } else if !self.translate { - let output = output.as_bytes(); - let mut matches = memchr::memchr2_iter(b'\r', b'\n', output); - while !self.seennl.is_all() { - let Some(i) = matches.next() else { break }; - match output[i] { - b'\n' => self.seennl.insert(SeenNewline::LF), - // if c isn't \n, it can only be \r - _ if output.get(i + 1) == Some(&b'\n') => { - matches.next(); - self.seennl.insert(SeenNewline::CRLF); - } - _ => self.seennl.insert(SeenNewline::CR), - } - } + self.seennl.observe(&output); } else { let bytes = output.as_bytes(); let mut matches = memchr::memchr2_iter(b'\r', b'\n', bytes); @@ -4396,6 +4405,7 @@ mod _io { _base: _TextIOBase, buffer: PyRwLock, newline: AtomicCell, + seennl: AtomicCell, closed: AtomicCell, } @@ -4416,6 +4426,7 @@ mod _io { _base: Default::default(), buffer: PyRwLock::new(BufferedIO::new(Cursor::new(Vec::new()))), newline: AtomicCell::new(Newlines::Lf), + seennl: AtomicCell::new(SeenNewline::empty()), closed: AtomicCell::new(false), }) } @@ -4434,11 +4445,16 @@ mod _io { OptionalArg::Present(None) => Newlines::Universal, OptionalArg::Present(Some(newline)) => newline, }; - let raw_bytes = object.flatten().map_or_else(Vec::new, |v| { + let object = object.flatten(); + let raw_bytes = object.as_ref().map_or_else(Vec::new, |v| { Self::translate_newlines(v.as_wtf8(), newline).into_bytes() }); *zelf.buffer.write() = BufferedIO::new(Cursor::new(raw_bytes)); zelf.newline.store(newline); + zelf.seennl.store(SeenNewline::empty()); + if let Some(object) = object { + zelf.observe_newlines(object.as_wtf8(), newline); + } Ok(()) } } @@ -4463,6 +4479,14 @@ mod _io { } } + fn observe_newlines(&self, data: &Wtf8, newline: Newlines) { + if matches!(newline, Newlines::Universal | Newlines::Passthrough) { + let mut seennl = self.seennl.load(); + seennl.observe(data); + self.seennl.store(seennl); + } + } + fn text(bytes: &[u8]) -> &Wtf8 { // SAFETY: StringIO is populated only from PyStr values, which are valid WTF-8. unsafe { Wtf8::from_bytes_unchecked(bytes) } @@ -4515,6 +4539,15 @@ mod _io { self.closed.load() } + #[pygetset] + fn newlines(&self, vm: &VirtualMachine) -> PyResult { + if self.closed.load() { + Err(io_closed_error(vm)) + } else { + Ok(self.seennl.load().to_pyobject(vm)) + } + } + #[pymethod] fn close(&self) { self.closed.store(true); @@ -4523,8 +4556,11 @@ mod _io { // write string to underlying vector #[pymethod] fn write(&self, data: PyStrRef, vm: &VirtualMachine) -> PyResult { - let bytes = Self::translate_newlines(data.as_wtf8(), self.newline.load()).into_bytes(); - self.buffer(vm)? + let newline = self.newline.load(); + let bytes = Self::translate_newlines(data.as_wtf8(), newline).into_bytes(); + let mut buffer = self.buffer(vm)?; + self.observe_newlines(data.as_wtf8(), newline); + buffer .write(&bytes) .ok_or_else(|| vm.new_type_error("Error Writing String"))?; Ok(data.char_len() as u64) @@ -4684,6 +4720,11 @@ mod _io { .map_err(|err| os_err(vm, err))?; drop(buffer); zelf.newline.store(newline); + let mut seennl = SeenNewline::empty(); + if matches!(newline, Newlines::Universal | Newlines::Passthrough) { + seennl.observe(content.as_wtf8()); + } + zelf.seennl.store(seennl); // Set __dict__ if provided if !vm.is_none(dict) { diff --git a/extra_tests/snippets/stdlib_io_stringio.py b/extra_tests/snippets/stdlib_io_stringio.py index 5419eef2b..0adf0edac 100644 --- a/extra_tests/snippets/stdlib_io_stringio.py +++ b/extra_tests/snippets/stdlib_io_stringio.py @@ -69,9 +69,25 @@ def test_05(): assert f.readline() == "" +def test_06(): + f = StringIO(newline=None) + f.write("\r") + f.__init__("x\n", newline=None) + assert f.newlines == "\n" + + f.close() + try: + f.newlines + except ValueError: + pass + else: + assert False + + if __name__ == "__main__": test_01() test_02() test_03() test_04() test_05() + test_06() From 51f8433d61bdd96d2ef4bd3b8e97c3df300f540e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:38:51 +0900 Subject: [PATCH 91/94] build(deps): bump reviewdog/action-actionlint from 1.73.0 to 1.73.1 (#8541) Bumps [reviewdog/action-actionlint](https://github.com/reviewdog/action-actionlint) from 1.73.0 to 1.73.1. - [Release notes](https://github.com/reviewdog/action-actionlint/releases) - [Commits](https://github.com/reviewdog/action-actionlint/compare/50842263c20a7c46bd0065b9e624d3c569db061e...d63ba7532e0942965320cd8d73cbae4c7b3c5283) --- updated-dependencies: - dependency-name: reviewdog/action-actionlint dependency-version: 1.73.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d832398eb..5a8daae06 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -554,7 +554,7 @@ jobs: components: rustfmt - name: actionlint - uses: reviewdog/action-actionlint@50842263c20a7c46bd0065b9e624d3c569db061e # v1.73.0 + uses: reviewdog/action-actionlint@d63ba7532e0942965320cd8d73cbae4c7b3c5283 # v1.73.1 - name: zizmor uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 From 8e35bc1d4b0c819042f39b4913f1333d324006c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:39:03 +0900 Subject: [PATCH 92/94] build(deps): bump https://github.com/astral-sh/ruff-pre-commit (#8542) Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.16.1 to 0.16.2. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.1...v0.16.2) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.16.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9ffc4b8d4..750f64940 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.1 + rev: v0.16.2 hooks: - id: ruff-format priority: 0 From c02effc0229089de8413b01035f07eed0d168ee5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:39:14 +0900 Subject: [PATCH 93/94] build(deps): bump taiki-e/install-action from 2.85.2 to 2.85.11 (#8543) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.85.2 to 2.85.11. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/41049aa56687c35e0afa74eed4f09cec4f9afabf...7f4eb899022d8fe70b20c4f3de697aa85c309026) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.11 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index b93a90e8d..e3557e3e0 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -33,7 +33,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2 + - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2.85.11 with: tool: cargo-llvm-cov From 2ddf8fec6745b5ac4b99f35054ec74feac4abb6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:39:22 +0900 Subject: [PATCH 94/94] build(deps): bump num-integer in the num group across 1 directory (#8544) Bumps the num group with 1 update in the / directory: [num-integer](https://github.com/rust-num/num-integer). Updates `num-integer` from 0.1.46 to 0.1.47 - [Changelog](https://github.com/rust-num/num-integer/blob/main/RELEASES.md) - [Commits](https://github.com/rust-num/num-integer/compare/num-integer-0.1.46...num-integer-0.1.47) --- updated-dependencies: - dependency-name: num-integer dependency-version: 0.1.47 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: num ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a9181d7c..fa5e7b522 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2307,9 +2307,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ]