From b9a4ddffeb26da3d68f4140d1a6fc37f01bcaf00 Mon Sep 17 00:00:00 2001 From: dot Date: Wed, 4 Nov 2026 13:37:36 +0900 Subject: [PATCH 1/5] Align shutdown flush and unraisable reporting with CPython Share the fallible default writer, retain the selected output stream across callbacks, and keep user hooks available during finalization. Flush after exit callbacks and propagate either stream failure to exit status 120. Assisted-by: OpenAI Codex:model version not exposed --- crates/vm/src/exceptions.rs | 20 +++-- crates/vm/src/stdlib/sys.rs | 134 +++++++++++++++----------------- crates/vm/src/vm/interpreter.rs | 18 +++-- crates/vm/src/vm/mod.rs | 97 +++++++---------------- crates/vm/src/vm/vm_object.rs | 41 +++++----- 5 files changed, 135 insertions(+), 175 deletions(-) diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index 95d95bf3a34..c2207cbae94 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -194,12 +194,7 @@ impl VirtualMachine { /// Display `exc` through `traceback._print_exception_bltin`, falling back /// to the native printer writing to `file`. pub(crate) fn display_exception_to(&self, file: &PyObject, exc: &Py) { - if let Ok(print_exception) = self - .import("traceback", 0) - .and_then(|traceback| traceback.get_attr("_print_exception_bltin", self)) - && print_exception.is_callable() - && print_exception.call((exc.to_owned(),), self).is_ok() - { + if self.try_display_exception_to(file, exc.as_object()) { return; } if self @@ -212,6 +207,19 @@ impl VirtualMachine { let _ = self.call_method(file, "flush", ()); } + /// Try the shared Python formatter with the caller's retained output file. + /// Reporting paths provide their own native fallback if it is unavailable. + pub(crate) fn try_display_exception_to(&self, file: &PyObject, exc: &PyObject) -> bool { + self.import("traceback", 0) + .and_then(|traceback| traceback.get_attr("_print_exception_bltin", self)) + .is_ok_and(|print_exception| { + print_exception.is_callable() + && print_exception + .call((exc.to_owned(), file.to_owned()), self) + .is_ok() + }) + } + pub fn write_exception( &self, output: &mut W, diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index ccc5e438ae3..95ee3d7632e 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -45,7 +45,7 @@ pub mod sys { }, convert::ToPyObject, frame::FrameObjectRef, - function::{FuncArgs, KwArgs, OptionalArg, PosArgs}, + function::{FuncArgs, OptionalArg, PosArgs}, stdlib::_warnings::warn, types::PyStructSequence, version, @@ -1314,110 +1314,100 @@ pub mod sys { Ok(PyWindowsVersion::from_data(winver, vm)) } - fn _unraisablehook(unraisable: UnraisableHookArgsData, vm: &VirtualMachine) -> PyResult<()> { - use super::PyStderr; + /// Shared by sys.__unraisablehook__ and the fallback when a user hook fails. + /// Only the outer unraisable dispatcher discards reporting failures. + pub(crate) fn write_unraisable( + unraisable: UnraisableHookArgsData, + vm: &VirtualMachine, + ) -> PyResult<()> { + let Some(file) = vm.sys_module.dict().get_item_opt("stderr", vm)? else { + return Ok(()); + }; + if vm.is_none(&file) { + return Ok(()); + } + let write = |value: PyObjectRef| vm.call_method(&file, "write", (value,)).map(drop); + let text = |value: &str| write(vm.ctx.new_str(value).into()); - let stderr = PyStderr(vm); if !vm.is_none(&unraisable.object) { if !vm.is_none(&unraisable.err_msg) { - write!(stderr, "{}: ", unraisable.err_msg.str(vm)?); + write(unraisable.err_msg.str(vm)?.into())?; + text(": ")?; } else { - write!(stderr, "Exception ignored in: "); + text("Exception ignored in: ")?; } - // exception in del will be ignored but printed - let repr = &unraisable.object.repr(vm); - let str = match repr { - Ok(v) => v.to_string(), - Err(_) => format!( - "", - unraisable.object.class().name() - ), - }; - writeln!(stderr, "{str}"); + if unraisable + .object + .repr(vm) + .and_then(|value| write(value.into())) + .is_err() + { + text("")?; + } + text("\n")?; } else if !vm.is_none(&unraisable.err_msg) { - writeln!(stderr, "{}:", unraisable.err_msg.str(vm)?); + write(unraisable.err_msg.str(vm)?.into())?; + text(":\n")?; } - // Print traceback (using actual exc_traceback, not current stack) - if !vm.is_none(&unraisable.exc_traceback) { - let tb_module = vm.import("traceback", 0)?; - let print_tb = tb_module.get_attr("print_tb", vm)?; - let stderr_obj = super::get_stderr(vm)?; - let kwargs: KwArgs = core::iter::once(("file".to_string(), stderr_obj)).collect(); - let _ = print_tb.call( - FuncArgs::new(vec![unraisable.exc_traceback.clone()], kwargs), - vm, - ); + if vm.try_display_exception_to(&file, &unraisable.exc_value) { + return Ok(()); } - // Check exc_type - if vm.is_none(unraisable.exc_type.as_object()) { - return Ok(()); + // Imports can fail during teardown. Keep a native fallback, and keep + // printing the exception even if writing its traceback fails. + if let Ok(traceback) = unraisable + .exc_traceback + .downcast::() + { + let mut writer = crate::py_io::PyWriter(file.clone(), vm); + let _ = crate::exceptions::write_traceback(&mut writer, &traceback); } - assert!( - unraisable - .exc_type - .fast_issubclass(vm.ctx.exceptions.base_exception_type) - ); - - // Print module name (if not builtins or __main__). - // A failed lookup or a non-str `__module__` is discarded: print - // `` with no trailing `.`, then the qualname. - let module_name = unraisable.exc_type.__module__(vm).ok(); + + let module_name = unraisable + .exc_type + .as_object() + .get_attr("__module__", vm) + .ok(); if let Some(module_str) = module_name .as_ref() .and_then(|name| name.downcast_ref::()) { let module = module_str.as_wtf8(); if module != "builtins" && module != "__main__" { - write!(stderr, "{module}."); + write(module_str.to_owned().into())?; + text(".")?; } } else { - write!(stderr, ""); + text("")?; } - // Print qualname let qualname = unraisable.exc_type.__qualname__(vm); if let Ok(qualname_str) = qualname.downcast::() { - write!(stderr, "{}", qualname_str.as_wtf8()); + write(qualname_str.into())?; } else { - write!(stderr, "{}", unraisable.exc_type.name()); + text("")?; } - // Print exception value if !vm.is_none(&unraisable.exc_value) { - write!(stderr, ": "); - if let Ok(str) = unraisable.exc_value.str(vm) { - write!(stderr, "{}", str.as_wtf8()); - } else { - write!(stderr, ""); + text(": ")?; + if unraisable + .exc_value + .str(vm) + .and_then(|value| write(value.into())) + .is_err() + { + text("")?; } } - writeln!(stderr); - - // Flush stderr - if let Ok(stderr_obj) = super::get_stderr(vm) - && let Ok(flush) = stderr_obj.get_attr("flush", vm) - { - let _ = flush.call((), vm); - } - - Ok(()) + text("\n")?; + vm.call_method(&file, "flush", ()).map(drop) } #[pyattr] #[pyfunction(name = "__unraisablehook__")] - fn unraisablehook(unraisable: UnraisableHookArgsData, vm: &VirtualMachine) { - if let Err(e) = _unraisablehook(unraisable, vm) { - let stderr = super::PyStderr(vm); - writeln!( - stderr, - "{}", - e.as_object() - .repr(vm) - .unwrap_or_else(|_| vm.ctx.empty_str.to_owned()) - ); - } + fn unraisablehook(unraisable: UnraisableHookArgsData, vm: &VirtualMachine) -> PyResult<()> { + write_unraisable(unraisable, vm) } #[pyattr] diff --git a/crates/vm/src/vm/interpreter.rs b/crates/vm/src/vm/interpreter.rs index 87137ba1cf1..b68109b4f70 100644 --- a/crates/vm/src/vm/interpreter.rs +++ b/crates/vm/src/vm/interpreter.rs @@ -732,11 +732,11 @@ impl Interpreter { /// Finalize vm and turns an exception to exit code. /// /// Finalization steps (matching Py_FinalizeEx): - /// 1. Flush stdout and stderr. /// 1. Handle exit exception and turn it to exit code. /// 1. Call threading._shutdown() to join non-daemon threads. /// 1. Run atexit exit functions. /// 1. Set finalizing flag and hang remaining daemon threads. + /// 1. Flush stdout and stderr. /// 1. Forced GC collection pass (collect cycles while builtins are available). /// 1. Module finalization (finalize_modules). /// 1. Clear interpreter-owned cross-interpreter data. @@ -756,13 +756,11 @@ impl Interpreter { fn finalize_inner(self, exit_code: impl FnOnce(&VirtualMachine) -> u32) -> u32 { self.enter(|vm| { - let flush_status = vm.flush_std(); - // See if any exception leaked out: let exit_code = exit_code(vm); - // Match CPython: if exit_code is 0 and stdout flush failed, exit 120 - if vm.finalize_ex(flush_status) < 0 && exit_code == 0 { + // A failed shutdown flush overrides even an explicit nonzero exit code. + if vm.finalize_ex() < 0 { EXITCODE_FLUSH_FAILURE } else { exit_code @@ -779,7 +777,7 @@ impl VirtualMachine { if self.state.thread_is_running_main() { self.state.set_not_running_main(); } - let status = if self.finalize_ex(self.flush_std()) < 0 { + let status = if self.finalize_ex() < 0 { EXITCODE_FLUSH_FAILURE } else { status @@ -787,9 +785,9 @@ impl VirtualMachine { crate::host_env::os::exit(status as i32) } - /// The steps of Py_FinalizeEx after the first flush of stdout and stderr. + /// The steps of Py_FinalizeEx. /// Return a negative value if any flush failed. - fn finalize_ex(&self, mut flush_status: i32) -> i32 { + fn finalize_ex(&self) -> i32 { let vm = self; // Wait for thread shutdown - call threading._shutdown() if available. // This waits for all non-daemon threads to complete. @@ -843,6 +841,10 @@ impl VirtualMachine { vm.state.finalizing.store(true, Ordering::Release); } + // Exit callbacks have finished, but modules and the user unraisable + // hook are still available to report a failed stream flush. + let mut flush_status = vm.flush_std(); + // GC pass - collect cycles before module cleanup vm.state.gc.collect_force(2); diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 18659df67e4..522835fbf79 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -1905,17 +1905,7 @@ impl VirtualMachine { object: PyObjectRef, ) { let write_original = || { - let msg = msg.as_ref().map(|msg| msg.to_string_lossy()); - self.write_unraisable_to_stderr(&e, msg.as_deref(), &object); - }; - // During finalization the hook may no longer exist. - if self.state.finalizing.load(Ordering::Acquire) { - write_original(); - return; - } - let Some(unraisablehook) = self.sys_module.get_attr("unraisablehook", self).ok() else { - write_original(); - return; + self.write_unraisable_to_stderr(&e, msg.clone(), &object); }; let args = stdlib::sys::UnraisableHookArgsData { exc_type: e.class().to_owned(), @@ -1925,12 +1915,27 @@ impl VirtualMachine { object: object.clone(), } .to_pyobject(self); + let unraisablehook = match self.sys_module.dict().get_item_opt("unraisablehook", self) { + Ok(Some(hook)) => hook, + Ok(None) => { + write_original(); + return; + } + Err(error) => { + self.write_unraisable_to_stderr( + &error, + Some(self.ctx.new_str("Exception ignored in sys.unraisablehook")), + &self.ctx.none(), + ); + return; + } + }; if let Err(error) = self.audit("sys.unraisablehook", || { (unraisablehook.clone(), args.clone()) }) { self.write_unraisable_to_stderr( &error, - Some("Exception ignored in audit hook"), + Some(self.ctx.new_str("Exception ignored in audit hook")), &self.ctx.none(), ); return; @@ -1942,7 +1947,7 @@ impl VirtualMachine { if let Err(error) = unraisablehook.call((args,), self) { self.write_unraisable_to_stderr( &error, - Some("Exception ignored in sys.unraisablehook"), + Some(self.ctx.new_str("Exception ignored in sys.unraisablehook")), &unraisablehook, ); } @@ -1953,67 +1958,17 @@ impl VirtualMachine { fn write_unraisable_to_stderr( &self, e: &Py, - msg: Option<&str>, + msg: Option, object: &PyObject, ) { - // Get stderr once and reuse it - let stderr = crate::stdlib::sys::get_stderr(self).ok(); - - let write_to_stderr = |s: &Wtf8, stderr: &Option, vm: &Self| { - if let Some(stderr) = stderr { - let _ = vm.call_method(stderr, "write", (s.to_owned(),)); - } else { - eprint!("{s}"); - } - }; - - if self.is_none(object) { - if let Some(msg) = msg { - write_to_stderr(&wtf8_concat!(msg, ":\n"), &stderr, self); - } - } else { - let msg_str = if let Some(msg) = msg { - format!("{msg}: ") - } else { - "Exception ignored in: ".to_owned() - }; - write_to_stderr(msg_str.as_str().into(), &stderr, self); - - let repr_result = object.repr(self); - let repr_wtf8 = repr_result - .as_ref() - .map_or_else(|_| "".as_ref(), |s| s.as_wtf8()); - write_to_stderr(&wtf8_concat!(repr_wtf8, "\n"), &stderr, self); - } - - // Print the existing traceback without importing Python modules; - // this path must also work while the interpreter is finalizing. - if let Some(traceback) = e.traceback() { - if let Some(stderr) = &stderr { - let mut writer = crate::py_io::PyWriter(stderr.clone(), self); - let _ = crate::exceptions::write_traceback(&mut writer, &traceback, self); - } else { - let mut writer = crate::py_io::IoWriter(std::io::stderr()); - let _ = crate::exceptions::write_traceback(&mut writer, &traceback, self); - } - } - let exc_type_name = e - .class() - .fully_qualified_name(self) - .unwrap_or_else(|_| (&*e.class().name()).into()); - let message = match e.as_object().str(self) { - Ok(value) if !value.as_wtf8().is_empty() => { - wtf8_concat!(exc_type_name, ": ", value.as_wtf8(), "\n") - } - Ok(_) => wtf8_concat!(exc_type_name, "\n"), - Err(_) => wtf8_concat!(exc_type_name, ": \n"), + let args = stdlib::sys::UnraisableHookArgsData { + exc_type: e.class().to_owned(), + exc_value: e.to_owned().into(), + exc_traceback: e.traceback().to_pyobject(self), + err_msg: msg.to_pyobject(self), + object: object.to_owned(), }; - write_to_stderr(&message, &stderr, self); - - // Flush stderr to ensure output is visible - if let Some(ref stderr) = stderr { - let _ = self.call_method(stderr, "flush", ()); - } + let _ = stdlib::sys::sys::write_unraisable(args, self); } /// Store a callee frame pointer for the trampoline to pick up after diff --git a/crates/vm/src/vm/vm_object.rs b/crates/vm/src/vm/vm_object.rs index f17cf862720..261f4d69fbc 100644 --- a/crates/vm/src/vm/vm_object.rs +++ b/crates/vm/src/vm/vm_object.rs @@ -3,7 +3,6 @@ use crate::{ builtins::{PyBaseExceptionRef, PyList, PyListRef, PyStrInterned, pystr::AsPyStr}, function::IntoFuncArgs, object::{AsObject, PyObject, PyObjectRef, PyResult}, - stdlib::sys, vm::VirtualMachine, }; @@ -68,23 +67,29 @@ impl VirtualMachine { } pub(crate) fn flush_std(&self) -> i32 { - let vm = self; - let status = if let Ok(stdout) = sys::get_stdout(vm) - && !vm.is_none(&stdout) - && !vm.file_is_closed(&stdout) - && let Err(e) = vm.call_method(&stdout, identifier!(vm, flush).as_str(), ()) - { - vm.run_unraisable(e, None, stdout); - -1 - } else { - 0 - }; - - if let Ok(stderr) = sys::get_stderr(vm) - && !vm.is_none(&stderr) - && !vm.file_is_closed(&stderr) - { - let _ = vm.call_method(&stderr, identifier!(vm, flush).as_str(), ()); + let mut status = 0; + for name in ["stdout", "stderr"] { + // Keep the stream alive until its failure has been reported: a + // flush or hook can replace the reference stored in sys.__dict__. + let file = self.sys_module.dict().get_item_opt(name, self); + let result = match &file { + Ok(Some(file)) if !self.is_none(file) && !self.file_is_closed(file) => { + self.call_method(file, "flush", ()).map(drop) + } + Ok(_) => Ok(()), + Err(error) => Err(error.clone()), + }; + if let Err(error) = result { + status = -1; + if name == "stdout" { + self.run_unraisable( + error, + Some("Exception ignored while flushing sys.stdout".to_owned()), + self.ctx.none(), + ); + } + // Reporting a stderr failure could require the broken stream. + } } status } From 7731dac776b67b7110a7772f0ce8f837fc8536b1 Mon Sep 17 00:00:00 2001 From: dot Date: Thu, 5 Nov 2026 11:40:12 +0900 Subject: [PATCH 2/5] Fix shutdown CI locks and preserve input read-ahead Bound shutdown I/O lock waits before issuing a callback-free fatal diagnostic. Preserve the interactive reader across input calls so queued lines are not discarded, remove the stdout flush xfail observed passing on Linux and Windows, and repair existing Element Clippy warnings. Local validation: independent source review, rustfmt, diff check, and normal hooks. Final native compilation and runtime tests remain unrun pending verified safety setup; hosted CI must validate this repair. Assisted-by: OpenAI Codex:model version not exposed --- Lib/test/test_cmd_line.py | 1 - crates/common/src/lock/thread_mutex.rs | 58 +++++++++++++++- crates/vm/src/stdlib/_io.rs | 91 ++++++++++++++++++++++++++ crates/vm/src/stdlib/builtins.rs | 35 +++++++++- 4 files changed, 181 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_cmd_line.py b/Lib/test/test_cmd_line.py index 3f4384007f5..6d3ce6f7f75 100644 --- a/Lib/test/test_cmd_line.py +++ b/Lib/test/test_cmd_line.py @@ -562,7 +562,6 @@ def test_unmached_quote(self): self.assertRegex(err.decode('ascii', 'ignore'), 'SyntaxError') self.assertEqual(b'', out) - @unittest.expectedFailure # TODO: RUSTPYTHON @force_not_colorized def test_stdout_flush_at_shutdown(self): # Issue #5319: if stdout.flush() fails at shutdown, an error should diff --git a/crates/common/src/lock/thread_mutex.rs b/crates/common/src/lock/thread_mutex.rs index c996ff80289..440156f09f5 100644 --- a/crates/common/src/lock/thread_mutex.rs +++ b/crates/common/src/lock/thread_mutex.rs @@ -8,7 +8,7 @@ use core::{ ptr::NonNull, sync::atomic::{AtomicUsize, Ordering}, }; -use lock_api::{GetThreadId, GuardNoSend, RawMutex}; +use lock_api::{GetThreadId, GuardNoSend, RawMutex, RawMutexTimed}; // based off ReentrantMutex from lock_api @@ -175,6 +175,30 @@ impl ThreadMutex { } } +impl ThreadMutex { + /// Wait at most `timeout`, wrapping only the blocking acquisition. + pub fn try_lock_for_wrapped bool) -> bool>( + &self, + timeout: R::Duration, + wrap_fn: F, + ) -> Result, TryLockThreadError> + where + R::Duration: Copy, + { + match self + .raw + .lock_internal(|| wrap_fn(&|| self.raw.mutex.try_lock_for(timeout))) + { + Some(true) => Ok(ThreadMutexGuard { + mu: self, + marker: PhantomData, + }), + Some(false) => Err(TryLockThreadError::Other), + None => Err(TryLockThreadError::Current), + } + } +} + #[derive(Clone, Copy)] pub enum TryLockThreadError { /// Failed to lock because mutex was already locked on another thread. @@ -353,3 +377,35 @@ impl fmt::Debug fmt::Debug::fmt(&**self, f) } } + +#[cfg(all(test, feature = "threading"))] +mod tests { + use super::TryLockThreadError; + use crate::lock::PyThreadMutex; + use core::time::Duration; + + #[test] + fn timed_lock_preserves_ownership() { + let mutex = PyThreadMutex::new(0); + let guard = mutex.lock().unwrap(); + assert!(matches!( + mutex.try_lock_for_wrapped(Duration::ZERO, |_| panic!("reentrant wait")), + Err(TryLockThreadError::Current) + )); + std::thread::scope(|scope| { + scope.spawn(|| { + assert!(matches!( + mutex.try_lock_for_wrapped(Duration::from_millis(1), |wait| wait()), + Err(TryLockThreadError::Other) + )); + }); + }); + drop(guard); + let mut guard = mutex + .try_lock_for_wrapped(Duration::ZERO, |wait| wait()) + .unwrap_or_else(|_| panic!("unlocked mutex")); + *guard = 1; + drop(guard); + assert_eq!(*mutex.lock().unwrap(), 1); + } +} diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 0ab17695db8..e49377e741c 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -1788,6 +1788,63 @@ mod _io { } } + #[cfg(feature = "threading")] + fn shutdown_buffer_description( + class_name: &str, + raw: Option, + vm: &VirtualMachine, + ) -> String { + // Fatal diagnostics must not acquire an I/O lock or invoke user code. + // Inspect dictionary entries directly, avoiding custom attribute, + // equality, and repr implementations even on unusual raw streams. + // Limit diagnostic metadata; standard stream names are short and are + // among the first attributes installed on their FileIO objects. + let name = raw.and_then(|raw| raw.dict()).and_then(|dict| { + dict.into_iter().take(16).find_map(|(key, value)| { + let key = key.downcast_ref::()?; + if key.as_wtf8() != "name" || !value.class().is(vm.ctx.types.str_type) { + return None; + } + let name = value.downcast_ref::()?; + if name.byte_len() > 1024 { + return None; + } + PyStr::repr(name, vm).ok() + }) + }); + match name { + Some(name) => format!("<_io.{class_name} name={name}>"), + None => format!("<_io.{class_name}>"), + } + } + + #[cfg(feature = "threading")] + fn shutdown_io_lock<'a, T>( + mutex: &'a PyThreadMutex, + vm: &VirtualMachine, + reentrant_error: &'static str, + describe: impl FnOnce() -> String, + ) -> PyResult> { + use crate::common::lock::TryLockThreadError; + // Daemon threads may have parked permanently while owning this lock. + // Match _enter_buffered_busy's one-second grace period, including for + // RustPython's additional TextIOWrapper lock. + match mutex.try_lock_for_wrapped(core::time::Duration::from_secs(1), |wait| { + vm.allow_threads(wait) + }) { + Ok(guard) => Ok(guard), + Err(TryLockThreadError::Current) => Err(vm.new_runtime_error(reentrant_error)), + Err(TryLockThreadError::Other) => { + let message = format!( + "Fatal Python error: _enter_buffered_busy: could not acquire lock for {} at interpreter shutdown, possibly due to daemon threads\n", + describe() + ); + let _ = crate::host_env::crt_fd::write_noraise(2, message.as_bytes()); + crate::host_env::os::abort() + } + } + } + #[pyclass] trait BufferedMixin: PyPayload + StaticType { const CLASS_NAME: &'static str; @@ -1801,6 +1858,21 @@ mod _io { fn finalizing(&self) -> &AtomicBool; fn lock(&self, vm: &VirtualMachine) -> PyResult> { + #[cfg(feature = "threading")] + if vm.state.finalizing.load(Ordering::Acquire) { + return shutdown_io_lock( + self.data(), + vm, + "reentrant call inside buffered io", + || { + shutdown_buffer_description( + Self::CLASS_NAME, + self.raw_cell().load_owned(), + vm, + ) + }, + ); + } self.data() .lock_wrapped(|do_lock| vm.allow_threads(do_lock)) .ok_or_else(|| vm.new_runtime_error("reentrant call inside buffered io")) @@ -3209,6 +3281,25 @@ mod _io { &self, vm: &VirtualMachine, ) -> PyResult>> { + #[cfg(feature = "threading")] + if vm.state.finalizing.load(Ordering::Acquire) { + return shutdown_io_lock(&self.data, vm, "reentrant call inside textio", || { + let Some(buffer) = self.buffer.load_owned() else { + return "<_io.TextIOWrapper>".to_owned(); + }; + let (class_name, raw) = + if let Some(buffer) = buffer.downcast_ref::() { + (BufferedWriter::CLASS_NAME, buffer.raw.load_owned()) + } else if let Some(buffer) = buffer.downcast_ref::() { + (BufferedReader::CLASS_NAME, buffer.raw.load_owned()) + } else if let Some(buffer) = buffer.downcast_ref::() { + (BufferedRandom::CLASS_NAME, buffer.raw.load_owned()) + } else { + return format!("<{}>", buffer.class().slot_name()); + }; + shutdown_buffer_description(class_name, raw, vm) + }); + } self.data .lock_wrapped(|do_lock| vm.allow_threads(do_lock)) .ok_or_else(|| vm.new_runtime_error("reentrant call inside textio")) diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index b4f2be7318e..1b57b22b748 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -816,6 +816,25 @@ mod builtins { obj.get_id() } + thread_local! { + // Rustyline retains input read ahead past the current line. Keep the + // editor across input() calls so that queued lines are not discarded. + // It contains no Python objects or interpreter-specific helper state. + static INPUT_READLINE: std::cell::RefCell>> = + std::cell::RefCell::new(Some(Readline::new(()))); + } + + struct InputReadlineGuard<'a> { + slot: &'a std::cell::RefCell>>, + reader: Option>, + } + + impl Drop for InputReadlineGuard<'_> { + fn drop(&mut self) { + *self.slot.borrow_mut() = self.reader.take(); + } + } + #[pyfunction] fn input(args: InputArgs, vm: &VirtualMachine) -> PyResult { let prompt = args.prompt; @@ -867,8 +886,20 @@ mod builtins { if use_rustyline { let prompt = prompt_str.unwrap(); - let mut readline = Readline::new(()); - match readline.readline(prompt) { + let result = INPUT_READLINE.with(|slot| -> PyResult<_> { + // Do not keep a RefCell borrow alive while reading. An empty + // slot means a nested call, which must not create another + // editor and consume or discard the outer reader's input. + let reader = slot.borrow_mut().take(); + let reader = + reader.ok_or_else(|| vm.new_runtime_error("reentrant call inside input"))?; + let mut guard = InputReadlineGuard { + slot, + reader: Some(reader), + }; + Ok(guard.reader.as_mut().unwrap().readline(prompt)) + })?; + match result { ReadlineResult::Line(s) => Ok(vm.ctx.new_str(s).into()), ReadlineResult::Eof => { Err(vm.new_exception_empty(vm.ctx.exceptions.eof_error.to_owned())) From 63bd25117e2c69cba9a145a54249a24c1fb9e64b Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 7 Nov 2026 15:22:17 +0900 Subject: [PATCH 3/5] Adapt unraisable fallback to current traceback writer Pass the VM required by the updated native traceback writer and drop imports from the replaced duplicate reporting path. Assisted-by: OpenAI Codex:model-version-unavailable --- crates/vm/src/stdlib/sys.rs | 2 +- crates/vm/src/vm/mod.rs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 95ee3d7632e..8dd2f66584a 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -1361,7 +1361,7 @@ pub mod sys { .downcast::() { let mut writer = crate::py_io::PyWriter(file.clone(), vm); - let _ = crate::exceptions::write_traceback(&mut writer, &traceback); + let _ = crate::exceptions::write_traceback(&mut writer, &traceback, vm); } let module_name = unraisable diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 522835fbf79..19b0716cdd1 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -3,8 +3,6 @@ //! See also: //! -use rustpython_common::wtf8::{Wtf8, wtf8_concat}; - mod async_exception; #[cfg(feature = "rustpython-compiler")] mod compile; From 3735687d45f46096c7e602590a80e43c6f097dfe Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 7 Nov 2026 16:53:42 +0900 Subject: [PATCH 4/5] Use core RefCell for retained input reader Follow the current workspace Clippy policy while preserving the identical RefCell implementation. Assisted-by: OpenAI Codex:model-version-unavailable --- crates/vm/src/stdlib/builtins.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 1b57b22b748..36ca42b7025 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -820,12 +820,12 @@ mod builtins { // Rustyline retains input read ahead past the current line. Keep the // editor across input() calls so that queued lines are not discarded. // It contains no Python objects or interpreter-specific helper state. - static INPUT_READLINE: std::cell::RefCell>> = - std::cell::RefCell::new(Some(Readline::new(()))); + static INPUT_READLINE: core::cell::RefCell>> = + core::cell::RefCell::new(Some(Readline::new(()))); } struct InputReadlineGuard<'a> { - slot: &'a std::cell::RefCell>>, + slot: &'a core::cell::RefCell>>, reader: Option>, } From 4f9197e4e4050d53035dc89b6390b918998819aa Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 7 Nov 2026 16:57:33 +0900 Subject: [PATCH 5/5] Mark timed mutex tests with explicit test cfg Keep the threading-only regression coverage while satisfying the current tests_outside_test_module Clippy lint. Assisted-by: OpenAI Codex:model-version-unavailable --- crates/common/src/lock/thread_mutex.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/common/src/lock/thread_mutex.rs b/crates/common/src/lock/thread_mutex.rs index 440156f09f5..4137127eea0 100644 --- a/crates/common/src/lock/thread_mutex.rs +++ b/crates/common/src/lock/thread_mutex.rs @@ -378,7 +378,8 @@ impl fmt::Debug } } -#[cfg(all(test, feature = "threading"))] +#[cfg(test)] +#[cfg(feature = "threading")] mod tests { use super::TryLockThreadError; use crate::lock::PyThreadMutex;