Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions Lib/test/test_warnings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -834,8 +834,6 @@ class CWCmdLineTests(WCmdLineTests, unittest.TestCase):
class PyWCmdLineTests(WCmdLineTests, unittest.TestCase):
module = py_warnings

# TODO: RUSTPYTHON
@unittest.expectedFailure
def test_improper_option(self):
# Same as above, but check that the message is printed out when
# the interpreter is executed. This also checks that options are
Expand Down
55 changes: 20 additions & 35 deletions crates/vm/src/vm/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,11 @@ impl Interpreter {

/// Finalize vm and turns an exception to exit code.
///
/// Finalization steps including 5 steps:
/// Finalization steps (matching Py_FinalizeEx):
/// 1. Flush stdout and stderr.
/// 1. Handle exit exception and turn it to exit code.
/// 1. Wait for non-daemon threads (threading._shutdown).
/// 1. Wait for thread shutdown (call threading._shutdown).
/// 1. Mark vm as finalizing.
/// 1. Run atexit exit functions.
/// 1. Mark vm as finalized.
///
Expand All @@ -129,49 +130,33 @@ impl Interpreter {
0
};

// Wait for non-daemon threads (wait_for_thread_shutdown)
wait_for_thread_shutdown(vm);
// Wait for thread shutdown - call threading._shutdown() if available.
// This waits for all non-daemon threads to complete.
// threading module may not be imported, so ignore import errors.
if let Ok(threading) = vm.import("threading", 0)
&& let Ok(shutdown) = threading.get_attr("_shutdown", vm)
&& let Err(e) = shutdown.call((), vm)
{
vm.run_unraisable(
e,
Some("Exception ignored in threading shutdown".to_owned()),
threading,
);
}

// Mark as finalizing AFTER thread shutdown
vm.state.finalizing.store(true, Ordering::Release);

// Run atexit exit functions
atexit::_run_exitfuncs(vm);

vm.state.finalizing.store(true, Ordering::Release);

vm.flush_std();

exit_code
})
}
}

/// Wait until threading._shutdown completes, provided
/// the threading module was imported in the first place.
/// The shutdown routine will wait until all non-daemon
/// "threading" threads have completed.
fn wait_for_thread_shutdown(vm: &VirtualMachine) {
// Try to get the threading module if it was already imported
// Use sys.modules.get("threading") like PyImport_GetModule
let threading = match (|| -> PyResult<_> {
let sys_modules = vm.sys_module.get_attr("modules", vm)?;
let threading = sys_modules.get_item("threading", vm)?;
Ok(threading)
})() {
Ok(module) => module,
Err(_) => {
// threading not imported, nothing to do
return;
}
};

// Call threading._shutdown()
if let Err(e) = vm.call_method(&threading, "_shutdown", ()) {
vm.run_unraisable(
e,
Some("Exception ignored on threading shutdown".to_owned()),
threading,
);
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
63 changes: 63 additions & 0 deletions crates/vm/src/vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,18 @@ impl VirtualMachine {

#[cold]
pub fn run_unraisable(&self, e: PyBaseExceptionRef, msg: Option<String>, object: PyObjectRef) {
// During interpreter finalization, sys.unraisablehook may not be available,
// but we still need to report exceptions (especially from atexit callbacks).
// Write directly to stderr like PyErr_FormatUnraisable.
if self
.state
.finalizing
.load(std::sync::atomic::Ordering::Acquire)
{
self.write_unraisable_to_stderr(&e, msg.as_deref(), &object);
return;
}
Comment on lines 552 to 563
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's understand the context of this code
# Find where the finalizing flag is set and where atexit runs

cd crates/vm/src/vm
grep -n "finalizing" mod.rs | head -20

Repository: RustPython/RustPython

Length of output: 184


🏁 Script executed:

# Look for atexit-related code in the vm
grep -r "atexit" crates/vm/src --include="*.rs" | head -20

Repository: RustPython/RustPython

Length of output: 1449


🏁 Script executed:

# Find where finalizing is set to understand the order of operations
grep -r "finalizing.*=" crates/ --include="*.rs" -B2 -A2 | head -40

Repository: RustPython/RustPython

Length of output: 1454


🏁 Script executed:

# Look at the vm initialization and shutdown sequence
grep -n "fn run_unraisable\|fn finalize\|fn exit" crates/vm/src/vm/mod.rs | head -20

Repository: RustPython/RustPython

Length of output: 168


🏁 Script executed:

# Look at the finalization sequence in interpreter.rs
grep -n "finalizing\|atexit" crates/vm/src/vm/interpreter.rs | head -30

Repository: RustPython/RustPython

Length of output: 435


🏁 Script executed:

# Get more context around the finalization and atexit calls
sed -n '1,300p' crates/vm/src/vm/interpreter.rs | tail -100

Repository: RustPython/RustPython

Length of output: 3393


🏁 Script executed:

# Look at the full shutdown/exit handling sequence
rg "fn.*exit|fn.*finali" crates/vm/src/vm/interpreter.rs -A 10 | head -50

Repository: RustPython/RustPython

Length of output: 389


🌐 Web query:

CPython Py_FinalizeEx atexit exceptions handling unraisable hook

💡 Result:

Short summary — how CPython handles exceptions during Py_FinalizeEx / atexit and the unraisable hook

  • Py_FinalizeEx runs Python-level shutdown which includes running atexit handlers. Exceptions raised in atexit handlers produce a printed traceback (to sys.stderr) and the last non-SystemExit exception raised is re-raised after all handlers have run. [1][2]

  • C-level exit handlers (Py_AtExit) are separate: they are called by Py_FinalizeEx but run after Python internal finalization; they must not call Python APIs. Py_AtExit handlers are for C code, not Python-level cleanup. [3]

  • sys.unraisablehook (added in Python 3.8) is the configurable hook used for "unraisable exceptions" (e.g., exceptions in del, weakref callbacks, GC callbacks). By default Python logs these to sys.stderr; you can override sys.unraisablehook to customize handling. [4]

  • Important caveat: some "unraisable" errors occur very late in interpreter finalization (after core modules like sys and stderr have been cleared). In that case the custom sys.unraisablehook cannot be called and the error may go unreported (PyErr_WriteUnraisable may be a no‑op if stderr/sys are gone). This limitation is documented and tracked in the issue thread that introduced sys.unraisablehook. [4][5]

Practical implications

  • Do not call Py_FinalizeEx recursively or from code that may run during shutdown (atexit handlers, del, etc.). The docs explicitly warn against that. [3]
  • If you need reliable reporting of errors during shutdown, avoid relying on very-late finalizers; prefer earlier, explicit cleanup or handle errors before finalization. [3][1][4]

Sources
[1] atexit — Exit handlers (Python docs).
[2] atexit behavior / examples (Python docs, PyMotW).
[3] Initialization, Finalization, and Threads / Py_AtExit (CPython C-API docs).
[4] Issue and PR discussion and docs for sys.unraisablehook (bpo-36829 / sys docs / Victor Stinner blog).
[5] Python tracker discussion about very-late unraisable exceptions during finalization.


Atexit exceptions are being suppressed during finalization, which differs from CPython's behavior.

RustPython sets the finalizing flag at line 143, before calling atexit::_run_exitfuncs(vm) at line 146. This causes the run_unraisable call in atexit handlers to return early and suppress exception reporting. CPython still prints atexit handler exceptions to stderr during finalization, making them visible for debugging. Consider either moving the finalizing flag to after atexit completion, or adding a special case in run_unraisable to allow atexit exceptions to bypass the finalization suppression.

🤖 Prompt for AI Agents
In `@crates/vm/src/vm/mod.rs` around lines 552 - 562, The finalizing flag is set
before calling atexit::_run_exitfuncs, causing run_unraisable to early-return
and suppress atexit handler exceptions; either move the store to
state.finalizing to after atexit::_run_exitfuncs(vm) so finalization suppression
happens post-atexit, or add a small atexit bypass: introduce an atexit-running
marker on VM state (e.g., vm.state.atexit_running) set around
atexit::_run_exitfuncs and change run_unraisable to allow reporting when that
marker is true (while still suppressing in other finalization contexts). Ensure
references are to run_unraisable, atexit::_run_exitfuncs, and state.finalizing
(or the new state.atexit_running) so reviewers can find and apply the change.


let sys_module = self.import("sys", 0).unwrap();
let unraisablehook = sys_module.get_attr("unraisablehook", self).unwrap();

Expand All @@ -568,6 +580,57 @@ impl VirtualMachine {
}
}

/// Write unraisable exception to stderr during finalization.
/// Similar to _PyErr_WriteUnraisableDefaultHook in CPython.
fn write_unraisable_to_stderr(
&self,
e: &PyBaseExceptionRef,
msg: Option<&str>,
object: &PyObjectRef,
) {
// Get stderr once and reuse it
let stderr = crate::stdlib::sys::get_stderr(self).ok();

let write_to_stderr = |s: &str, stderr: &Option<PyObjectRef>, vm: &VirtualMachine| {
if let Some(stderr) = stderr {
let _ = vm.call_method(stderr, "write", (s.to_owned(),));
} else {
eprint!("{}", s);
}
};

// Format: "Exception ignored {msg} {object_repr}\n"
if let Some(msg) = msg {
write_to_stderr(&format!("Exception ignored {}", msg), &stderr, self);
} else {
write_to_stderr("Exception ignored in: ", &stderr, self);
}

if let Ok(repr) = object.repr(self) {
write_to_stderr(&format!("{}\n", repr.as_str()), &stderr, self);
} else {
write_to_stderr("<object repr failed>\n", &stderr, self);
}

// Write exception type and message
let exc_type_name = e.class().name();
if let Ok(exc_str) = e.as_object().str(self) {
let exc_str = exc_str.as_str();
if exc_str.is_empty() {
write_to_stderr(&format!("{}\n", exc_type_name), &stderr, self);
} else {
write_to_stderr(&format!("{}: {}\n", exc_type_name, exc_str), &stderr, self);
}
} else {
write_to_stderr(&format!("{}\n", exc_type_name), &stderr, self);
}

// Flush stderr to ensure output is visible
if let Some(ref stderr) = stderr {
let _ = self.call_method(stderr, "flush", ());
}
}

#[inline(always)]
pub fn run_frame(&self, frame: FrameRef) -> PyResult {
match self.with_frame(frame, |f| f.run(self))? {
Expand Down
Loading