Fix atexit unraisable exception message format (#7399)

* Fix atexit unraisable exception message format

Match PyErr_FormatUnraisable behavior: use
"Exception ignored in atexit callback {func!r}" as err_msg
and pass None as object instead of the callback function.

* Fix atexit unregister deadlock with reentrant __eq__

Release the lock during equality comparison in unregister so
that __eq__ can safely call atexit.unregister or atexit._clear.
Store callbacks in LIFO order (insert at front) and use
identity-based search after comparison to handle list mutations,
matching atexitmodule.c behavior.

Also pass None as err_msg when func.repr() fails, matching
CPython's PyErr_FormatUnraisable fallback.
This commit is contained in:
Jeong, YunWon authored and GitHub committed 2026-03-11 22:20:37 +09:00
1 parent 3ef45abbce
commit e97f1c8bdf
3 files changed
+52 -20

No files matched your search

-7
View File
@@ -47,7 +47,6 @@ class GeneralTest(unittest.TestCase):
('func2', (), {}),
('func1', (1, 2), {})])
@unittest.expectedFailure # TODO: RUSTPYTHON
def test_badargs(self):
def func():
pass
@@ -55,14 +54,12 @@ class GeneralTest(unittest.TestCase):
# func() has no parameter, but it's called with 2 parameters
self.assert_raises_unraisable(TypeError, func, 1 ,2)
@unittest.expectedFailure # TODO: RUSTPYTHON
def test_raise(self):
def raise_type_error():
raise TypeError
self.assert_raises_unraisable(TypeError, raise_type_error)
@unittest.expectedFailure # TODO: RUSTPYTHON
def test_raise_unnormalized(self):
# bpo-10756: Make sure that an unnormalized exception is handled
# properly.
@@ -71,7 +68,6 @@ class GeneralTest(unittest.TestCase):
self.assert_raises_unraisable(ZeroDivisionError, div_zero)
@unittest.expectedFailure # TODO: RUSTPYTHON
def test_exit(self):
self.assert_raises_unraisable(SystemExit, sys.exit)
@@ -122,7 +118,6 @@ class GeneralTest(unittest.TestCase):
atexit._run_exitfuncs()
self.assertEqual(l, [5])
@unittest.expectedFailure # TODO: RUSTPYTHON
def test_atexit_with_unregistered_function(self):
# See bpo-46025 for more info
def func():
@@ -140,7 +135,6 @@ class GeneralTest(unittest.TestCase):
finally:
atexit.unregister(func)
@unittest.skip("TODO: RUSTPYTHON; Hangs")
def test_eq_unregister_clear(self):
# Issue #112127: callback's __eq__ may call unregister or _clear
class Evil:
@@ -154,7 +148,6 @@ class GeneralTest(unittest.TestCase):
atexit.unregister(Evil())
atexit._clear()
@unittest.skip("TODO: RUSTPYTHON; Hangs")
def test_eq_unregister(self):
# Issue #112127: callback's __eq__ may call unregister
def f1():
+51 -12
View File
@@ -7,7 +7,11 @@ mod atexit {
#[pyfunction]
fn register(func: PyObjectRef, args: FuncArgs, vm: &VirtualMachine) -> PyObjectRef {
vm.state.atexit_funcs.lock().push((func.clone(), args));
// Callbacks go in LIFO order (insert at front)
vm.state
.atexit_funcs
.lock()
.insert(0, Box::new((func.clone(), args)));
func
}
@@ -18,27 +22,62 @@ mod atexit {
#[pyfunction]
fn unregister(func: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> {
let mut funcs = vm.state.atexit_funcs.lock();
let mut i = 0;
while i < funcs.len() {
if vm.bool_eq(&funcs[i].0, &func)? {
funcs.remove(i);
} else {
i += 1;
// Iterate backward (oldest to newest in LIFO list).
// Release the lock during comparison so __eq__ can call atexit functions.
let mut i = {
let funcs = vm.state.atexit_funcs.lock();
funcs.len() as isize - 1
};
while i >= 0 {
let (cb, entry_ptr) = {
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))
};
// Lock released: __eq__ can safely call atexit functions
let eq = vm.bool_eq(&func, &cb)?;
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) {
funcs.remove(j as usize);
i = j;
break;
}
j -= 1;
}
}
{
let funcs = vm.state.atexit_funcs.lock();
if i as usize >= funcs.len() {
i = funcs.len() as isize;
}
}
i -= 1;
}
Ok(())
}
#[pyfunction]
pub fn _run_exitfuncs(vm: &VirtualMachine) {
let funcs: Vec<_> = core::mem::take(&mut *vm.state.atexit_funcs.lock());
for (func, args) in funcs.into_iter().rev() {
// Callbacks stored in LIFO order, iterate forward
for entry in funcs.into_iter() {
let (func, args) = *entry;
if let Err(e) = func.call(args, vm) {
let exit = e.fast_isinstance(vm.ctx.exceptions.system_exit);
vm.run_unraisable(e, Some("Error in atexit._run_exitfuncs".to_owned()), func);
let msg = func
.repr(vm)
.ok()
.map(|r| format!("Exception ignored in atexit callback {}", r.as_wtf8()));
vm.run_unraisable(e, msg, vm.ctx.none());
if exit {
break;
}
+1 -1
View File
@@ -585,7 +585,7 @@ pub struct PyGlobalState {
pub stacksize: AtomicCell<usize>,
pub thread_count: AtomicCell<usize>,
pub hash_secret: HashSecret,
pub atexit_funcs: PyMutex<Vec<(PyObjectRef, FuncArgs)>>,
pub atexit_funcs: PyMutex<Vec<Box<(PyObjectRef, FuncArgs)>>>,
pub codec_registry: CodecsRegistry,
pub finalizing: AtomicBool,
pub warnings: WarningsState,