Visitar URL original
Fix crashes found hunting the last open fuzzing record by youknowone · Pull Request #8524 · RustPython/RustPython · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f24691e
specialize: check the member descriptor's type before caching its slo…
youknowone Aug 14, 2026
40e67e1
socket: reserve recv()'s buffer fallibly
youknowone Aug 14, 2026
40a839b
types: count the __call__ and __get__ slot dispatches as recursion
youknowone Aug 14, 2026
bb744d6
typevar: show a ParamSpecArgs origin by its repr
youknowone Aug 14, 2026
7e4b96e
Do not hold a lock across a call back into Python
youknowone Aug 14, 2026
6e47646
Validate memoryview.cast() arguments and export negative strides corr…
youknowone Aug 14, 2026
2432fd7
Charge native recursion to the stack, not to the frame limit
youknowone Aug 14, 2026
02a6193
Report a size that cannot be allocated instead of aborting on it
youknowone Aug 14, 2026
ff52fe0
marshal: answer allow_code where a code object is written or read
youknowone Aug 14, 2026
d7cf971
Do not lock an object while running code that can reach it
youknowone Aug 14, 2026
61b989b
Stop asserting a pbkdf2 message that depends on the width of a C long
youknowone Aug 14, 2026
c9449a6
Publish and read the same pointer for a thread's top frame
youknowone Aug 15, 2026
ee782e0
Decide stop-the-world parking under the thread registry lock
youknowone Aug 15, 2026
fab2b3b
Keep an atexit callback alive while it is being compared
youknowone Aug 15, 2026
e3dcd8e
Hold atexit entries in PyRc rather than Arc
youknowone Aug 15, 2026
773ae23
Do not hold a buffer's storage while waiting for a peer
youknowone Aug 15, 2026
125d5aa
Do not hold a buffer's storage while waiting to hand it over
youknowone Aug 15, 2026
62ed618
Check select()'s descriptor limit while the sequence is walked
youknowone Aug 15, 2026
c65fe42
Bound native recursion where the C stack cannot be measured
youknowone Aug 15, 2026
00b64c3
Report the failure to allocate pbkdf2's key and Take.readinto's scratch
youknowone Aug 15, 2026
7c40cb9
Read the frozen-code tuple length as a signed length
youknowone Aug 15, 2026
ce43008
Make the snippets from the fuzzer sweep assert what they check
youknowone Aug 15, 2026
3d69690
Ask for a marshal container's room instead of assuming it
youknowone Aug 15, 2026
366ef3e
Compare the blocking-buffer snippet against something
youknowone Aug 15, 2026
ab0d4ff
Refuse array.frombytes() a source whose items are not bytes
youknowone Aug 15, 2026
7fad3b0
Tell apart the ways marshal data can be bad
youknowone Aug 15, 2026
a7f7021
Hash the collector's tables by address rather than by SipHash
youknowone Aug 16, 2026
691d6ca
Keep the collection's candidates and their counts in one table
youknowone Aug 16, 2026
3139b95
gc: collect referents into one buffer instead of a vector per object
youknowone Aug 16, 2026
dce42a3
memoryview and struct: match the checks and errors of the reference
youknowone Aug 16, 2026
5a5ba17
io: decide the readinto path by file type, not by seekability
youknowone Aug 16, 2026
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
Prev Previous commit
Next Next commit
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
  • Loading branch information
youknowone committed Aug 16, 2026
commit fab2b3b8ff21db3aa5d05105606c12270ec253a4
17 changes: 10 additions & 7 deletions crates/vm/src/stdlib/atexit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ pub(crate) use atexit::module_def;
#[pymodule]
mod atexit {
use crate::{AsObject, PyObjectRef, PyResult, VirtualMachine, function::FuncArgs};
use alloc::sync::Arc;

#[pyfunction]
fn register(func: PyObjectRef, args: FuncArgs, vm: &VirtualMachine) -> PyObjectRef {
// Callbacks go in LIFO order (insert at front)
vm.state
.atexit_funcs
.lock()
.insert(0, Box::new((func.clone(), args)));
.insert(0, Arc::new((func.clone(), args)));
func
}

Expand All @@ -29,24 +30,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 Arc::ptr_eq(funcs.get(j as usize).unwrap(), &entry) {
funcs.remove(j as usize);
i = j;
break;
Expand All @@ -70,7 +73,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) = Arc::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
Expand Down
7 changes: 5 additions & 2 deletions crates/vm/src/vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ use crate::{
stdlib,
warn::WarningsState,
};
use alloc::{borrow::Cow, collections::BTreeMap};
use alloc::{borrow::Cow, collections::BTreeMap, sync::Arc};
#[cfg(all(not(unix), feature = "threading"))]
use core::ptr::NonNull;
#[cfg(feature = "threading")]
Expand Down Expand Up @@ -759,7 +759,10 @@ pub struct PyGlobalState {
pub stacksize: AtomicCell<usize>,
pub thread_count: AtomicCell<usize>,
pub hash_secret: HashSecret,
pub atexit_funcs: PyMutex<Vec<Box<(PyObjectRef, FuncArgs)>>>,
/// 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<Vec<Arc<(PyObjectRef, FuncArgs)>>>,
pub codec_registry: CodecsRegistry,
pub finalizing: AtomicBool,
pub warnings: WarningsState,
Expand Down
101 changes: 101 additions & 0 deletions extra_tests/snippets/stdlib_atexit.py
Original file line number Diff line number Diff line change
@@ -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")