A standalone synthesis of the fusil-vs-RustPython fuzzing campaign, written to be the primary
design input for rustpy-review-toolkit (a Claude Code plugin that statically analyzes
RustPython for defects, the way cpython-review-toolkit analyzes CPython).
- Build under test: RustPython 0.5.0 (
heads/master:a9c2c529b, Jul 13 2026), the defaultcargo install rustpythonbinary. - Oracle: CPython 3.14.3 (
~/venvs/fusil_np_verify). - Findings: 24 catalogued (
RUSTPY-0001..0024), all inreports/, all with reproducers. - Source of truth:
catalog/known_panics.tsv+reports/*/meta.json; per-fleet narrative inINDEX.md; class notes innotes/. - Companion tool:
tools/unwrap_scan/— asyn-based static scanner that already prototypes the single most valuable analysis this toolkit should industrialize (Python-reachable panic sites).
Over nine fuzzing fleets we drove hostile Python at RustPython's Rust-implemented stdlib and VM and catalogued 24 distinct ways to abort or segfault the interpreter from pure Python where CPython raises a normal, catchable exception (or works). The findings cluster into a small number of RustPython-specific classes that a static analyzer can target directly:
| # | Class | Findings | Crash kind |
|---|---|---|---|
| A | Python-reachable .unwrap() / .expect() on a PyResult/Option/downcast |
0003, 0004, 0006, 0009, 0011, 0017, 0020, 0021 | panic (abort) |
| B | Unchecked indexing / missing arity check (zelf[i], args.args[N], buf[i-1]) |
0002, 0005, 0010, 0022 | panic (abort) |
| C | unsafe unsoundness / type confusion (a wrong pointer cast in a Debug impl) |
0018 | SIGSEGV |
| D | Unguarded native recursion on a protocol path (hash/eq/repr) → native stack overflow | 0007a | SIGSEGV |
| E | Uninitialized native object via T.__new__(T) → a protocol slot reads its payload |
0008 | SIGSEGV |
| F | Thread-safety: RefCell/Cell/non-atomic state on a thread-shareable object |
0001, 0019, 0020, 0022, 0023 | panic / SIGSEGV |
| G | Unbounded eager-collect of an untrusted iterable (parity gap: CPython type-checks first) | 0012–0016 | OOM abort |
| H | ctypes/FFI argument marshalling (accepts what CPython rejects) |
0017, 0024, 0015 | panic / SIGSEGV |
| I | Rust Debug ({:?}) used to format a Python object in a user-facing message |
0018 | garbage msg / SIGSEGV |
| J | abort-vs-MemoryError (Rust aborts on OOM; architectural, KNOWN upstream) |
(broad) | OOM abort |
The central result (Section 2): CPython's crash corpus does not transfer — 0 of 138 filed
CPython crashers reproduce natively on RustPython. The two interpreters fail in different layers.
Everything a memory-safe Rust rewrite buys (no C refcount races, no UAF, no NULL-deref in
interpreter internals) it partly gives back as a new surface: panic!-family aborts, unsafe
soundness bugs, RefCell misuse for shared state, and parity gaps where Rust's "accept any iterator"
ergonomics skip a check CPython performs. A review toolkit for RustPython must be built around these
classes, not around CPython's (refcount/GIL/NULL) classes.
RustPython is a from-scratch Python interpreter in safe-by-default Rust. Two model facts dominate everything below:
PyRc = Arc— reference counts are atomic; object graphs areArc/Weak. There is no non-atomic C refcount to race, and no manualPy_INCREF/DECREFto get wrong. CPython's entire free-threading data-race class (non-atomic refcount/GC) is prevented by construction. (This is exactly why the fusil--tsanmode — which found ~50 CPython FT races — is inapplicable to RustPython; seenotes/cpython-crash-corpus-vs-rustpython.md.)- Safe Rust internals — collections are bounds-checked, there is no raw pointer arithmetic on
object structs, and "invalid state" is usually a
Result/Option, not a dangling pointer. The CPython C-internal NULL-deref / OOB / C-assertion class is likewise prevented — the hostile input that corrupts a CPython C struct raises a clean Rust error or just works.
What replaces them (the whole point of this report):
panic!-family aborts. Rust's safety valve for "impossible" states ispanic!(.unwrap(),.expect(),unreachable!,todo!, out-of-bounds[]). When a Python-controlled value reaches one of these, a would-beTypeError/ValueErrorbecomes an interpreter abort. This is the single largest class (A+B: 12 of 24 findings). A panic is "safe" (no UB) but still a hard crash of the whole process — and on a worker thread it can poison state and hang.unsafeislands. RustPython hasunsafewhere it bridges the object model, FFI, and atomics. Bugs there are real memory-unsafety (SIGSEGV), not panics.RUSTPY-0018(anunsafeDebugimpl castingPy<T>toT) is the exemplar and the highest-severity single finding.- The wrong synchronization primitive. Python objects are shareable across threads, but several
are built on
RefCell/Cell(single-threaded interior mutability). Under free-threading (PYTHON_GIL=0), concurrent access tripsRefCell's runtime borrow check →BorrowMutErrorpanic, or races a non-atomic index → OOB panic, or corrupts VM frame state → SIGSEGV. (Class F: 0001, 0019, 0020, 0022, 0023.) - Ergonomic parity gaps. Rust's
impl IntoIteratorergonomics make "accept any iterable and collect it" the path of least resistance. CPython, at many argument positions, requires a sized container and type-checks first. RustPython collecting an infinite iterable → unbounded memory → OOM abort. (Class G: 0012–0016.) - FFI (
ctypes). The one place RustPython is also memory-unsafe by nature. Its argument marshalling accepts values CPython rejects (RUSTPY-0024: afloatmarshalled to a pointer → SIGSEGV where CPython raisesArgumentError).
Design consequence for the toolkit: do not port cpython-review-toolkit's agents 1:1. A
"refcount-auditor" or "GIL-discipline-checker" has almost nothing to do on RustPython. The high-value
agents are panic-site, unsafe-soundness, thread-safety (RefCell-for-shared-state),
eager-collect-parity, recursion-guard, uninitialized-object, debug-format, and ctypes-FFI —
each mapped to a concrete Rust idiom in Section 5, and each grounded in a real finding.
Understanding where findings came from tells the toolkit designer which classes a static pass can catch cheaply and which need a differential/dynamic step.
fusil (a generational fuzzer) with a --rustpython plugin generates a Python script per session
that imports a target module and hammers its functions/classes/methods with hostile arguments:
wrong types, wrong arity, "bomb" objects whose dunders raise or return wrong-typed values, recursive
object graphs, huge ints, surrogates, and (crucially) wrong-type-return objects (RustPython often
.unwrap()s a downcast, so an object returning the wrong type from a dunder trips it). A crash =
the child aborts/segfaults; the session dir is kept and labelled.
- Default — per-call hostile args across all discovered modules. Found the panic/index classes (0001–0011).
--modules-file rust_modules.txt— restrict to the ~108 Rust-implemented modules (fromunwrap_scan), where the crash density is. This is static-guided targeting and is the direct ancestor of what this toolkit should do.--new-uninit— callT.__new__(T)on every native type (bypassing__init__) then poke its protocol slots. Found the uninitialized-object segv class (0008). Still under-mined — the remaining unmined variant.--concurrency-stress— a barrier-released multi-thread op-mix (attribute churn, gc, weakref, shared-container mutation, shared-iterator advance, read-while-mutate) over a few shared objects. This is the free-threading analogue and found the entire thread-safety class (0019, 0020, 0022, 0023) plus, via its ctypes objects, 0024. Most productive variant once the primary mode converged.
Runs are fleets of parallel instances. An in-loop deduper (fusil/python/rustpython_dedup.py) parses
each crash's stdout into a panic signature — crates/<path>.rs:<line> (column dropped, absolute
paths normalized) — and labels the dir RUSTPY-00NN (known) / rustpyNEW (new site) / rustpySEGV
(no panic message → needs gdb). A read-only snapshot (catalog/known_panics.tsv) drives it. Key
property: the panic signature is the dedup key, so the panic classes (A/B/F-panic) dedup cleanly,
but segfaults have no panic line and fall into an undifferentiated rustpySEGV bucket — which is
where the two highest-value findings (0018, 0024) hid until a gdb-resolve pass. (Lesson in §6.)
Fleets 07–08 converged (primary mode mined out: ~14k sessions, 0 new). The variant surfaces did not:
fleet 09 (--concurrency-stress) produced 6 new findings. Thesis: the productive surface is the
mode, not more sessions. A static toolkit inverts this — it can enumerate the whole surface at
once instead of waiting for a fuzzer to stumble onto each site.
Every finding is confirmed by running the same reproducer under CPython 3.14.3 and recording that
CPython raises/handles it. This CPython-divergence check is the crux of "is it a bug?" — and it is the
single most important thing the toolkit must automate (Section 5, cpython-parity-differential).
tools/unwrap_scan/ (Rust, syn-based) walks crates/vm/src/stdlib, crates/stdlib/src,
crates/vm/src/builtins, crates/vm/src/types; attributes every .unwrap()/.expect(/panic!/
unreachable!/unimplemented!/todo!/.args[ to the function it lives in; and classifies each by
Python-reachability: py (directly #[pyfunction]/#[pymethod]/#[pygetset]/#[pyslot]),
protocol (a slot of a with(Trait) protocol impl), or internal (a helper reached transitively).
Current surface: 973 risky lines across 108 modules (686 internal, 159 py, 126 protocol, +1).
This is the seed of the toolkit's flagship agent — see §5.1 and §7.
Each class below gives the mechanism, the static-detection signature (the Rust idiom an analyzer keys on), the CPython contrast (the oracle), the findings, and the fix pattern. Findings can belong to more than one class (noted).
Mechanism. A native function computes a Result/Option whose Err/None case is
Python-reachable (a raising dunder, an unregistered key, an out-of-range int, an escalated warning)
and calls .unwrap()/.expect() instead of propagating with ?. The Err/None becomes a panic →
abort.
Static signature. .unwrap() / .expect( on an expression of type PyResult<_> or
Option<_> inside a py/protocol-reachable function, especially right after a call that runs
Python (.repr(vm), .str(vm), vm.call_method, .downcast, a warn(...), a hashmap .get). The
tell is that the fallible value is derived from a Python object or Python-controlled input.
CPython contrast. CPython propagates the inner exception (repr that raises) or raises
TypeError/ValueError/KeyError.
Findings.
- 0003
class.rs:87—unwrap_or_else(|| fail(...))on an uninitialized static type panics ("static type has not been initialized"). Reproimport _md5; _md5.md5(). (KNOWN upstream #5210.) - 0004
csv.rs:805(+:748/:1070) —get_lineterminatordoes aHASHMAP…get(name).unwrap()on an unregistered dialect. Reproimport _csv; _csv.reader([]).__next__(). - 0006
builtins.rs:557/607—compile/evalexpect_strpanics on a surrogate. Reproeval(chr(0xd800)). - 0009
staticmethod.rs:182—repr(staticmethod(obj))doesobj.repr(vm).unwrap(); a raising__repr__panics. 0011classmethod.rs:198is the exact sibling one type over. (File as one.repr(vm).unwrap()fix.) - 0017
_ctypes/simple.rs:908(+:895/921/759/775/791/807) — every ctypes simple int/pointer type does.to_usize()/.to_i128().expect("int too large"). Reproctypes.c_char_p(2**64). CPython masks to the C width. (Also Class H.) - 0020
utils.rs:61—collection_reprdoesiter.next()…expect("this is not called for empty collection")after a non-empty check; a concurrent empty makesnext()→None→ panic. (Also Class F.) - 0021
sys.rs:874—sys.breakpointhook()on an unimportable$PYTHONBREAKPOINTcallswarn(RuntimeWarning, …).unwrap(); under warnings-as-errorwarn()returnsErr→ panic. Fix pattern. Replace.unwrap()/.expect()with?(propagate), or map to a propervm.new_*_error(...). For the.repr(vm).unwrap()idiom:obj.repr(vm)?.
Mechanism. A native function indexes a slice/struct-seq/args tuple with an index it didn't
bounds-check, on a Python-controllable length. zelf[i], args.args[N], buf[in_idx - 1] with
in_idx == 0 (usize underflow → astronomically OOB).
Static signature. Direct [ indexing (not .get(i)) on args.args, a struct-seq buffer, or a
Vec whose length is Python-controlled, in a py/protocol function — plus any x - 1 used as an
index without an x > 0 guard. (unwrap_scan flags .args[ explicitly; a fuller AST pass should
flag all index-expressions in reachable fns.)
CPython contrast. TypeError (wrong arity) / IndexError / a correct empty-case result.
Findings. 0002 structseq.rs:311 (pwd.struct_passwd().pw_name on an empty struct-seq);
0005 _typing.rs:43 (_typing._idfunc() indexes args.args[0] with no arity check); 0010
binascii.rs:507 (b2a_qp(b'\n') → buf[in_idx-1] underflow); 0022 itertools.rs:282 (cycle
cache OOB — non-atomic, so also Class F).
Fix pattern. .get(i) + explicit error; arity-check args before indexing; guard i > 0 before
i - 1.
Mechanism. An unsafe block makes an invalid assumption about memory layout. RUSTPY-0018:
PyAtomicRef<T>'s Debug impl does self.inner.load().cast::<T>().as_ref().fmt(f) — but inner
points at a Py<T> (object header + payload), and every other method of the type casts to
Py<T>. The Debug impl alone casts to bare T, skipping the header, so it reads misaligned fields;
for PyFunction.code: PyAtomicRef<PyCode>, CodeObject::Debug then dereferences garbage heap
pointers → SIGSEGV.
Static signature. unsafe blocks that .cast::<_>() / transmute / as *const/*mut an object
pointer; inconsistency between how one method interprets a stored pointer and how its siblings do
(here Deref/load_raw/From/swap all use Py<T>, Debug uses T). Also: any unsafe Debug
impl at all is a smell (Debug should never be unsafe).
CPython contrast. N/A (this is a Rust-internal soundness bug); functionally CPython prints a
bounded repr.
Fix pattern. Make the cast consistent (.cast::<T>() → .cast::<Py<T>>()); prefer a safe
accessor.
Mechanism. A protocol operation (hash, richcompare, repr, genericalias parameter walk) recurses
in Rust following a Python object graph with no recursion depth guard; a deep/cyclic object
overflows the native stack → SIGSEGV (not a catchable RecursionError).
Static signature. A #[pymethod]/protocol impl for __hash__/__eq__/__repr__/__reduce__/
parameter-collection that calls back into the same operation on a contained object without
entering a recursion guard (CPython uses Py_EnterRecursiveCall; RustPython has
vm.with_recursion(...)-style helpers that these paths omit).
CPython contrast. RecursionError.
Findings. 0007a (umbrella; KNOWN upstream #2796). Per-area fixes have landed (json, AST,
parser) but hash/compare/genericalias remain.
Fix pattern. Wrap the recursive descent in the VM recursion guard.
Mechanism. Most native types have no own Constructor/DISALLOW_INSTANTIATION, so T.__new__(T)
yields a type-confused instance with a default (PyBaseObject) payload. Ordinary #[pymethod]s
cleanly downcast-fail (TypeError), but a protocol slot that touches the payload without
re-downcasting reads garbage → SIGSEGV. RUSTPY-0008: re.Match's AsMapping subscript reads
uninitialized regs/string.
Static signature. A type whose AsMapping/AsSequence/AsNumber/iterator slot accesses
zelf.payload/fields without the guard its #[pymethod]s use, and the type has no
Constructor that forbids __new__. (Cross-reference: types missing
impl Constructor/DISALLOW_INSTANTIATION whose slots read payload.)
CPython contrast. Segfault-safe (CPython's slots re-check or the type forbids __new__).
Findings. 0008 (_sre Match::as_mapping). Minimized: M=type(re.match('a','a')); M.__new__(M)[0].
Fix pattern. Add the payload/init guard to the slot (as group()/repr already have), or forbid
bare __new__.
Mechanism. A Python object reachable across threads holds state in RefCell/Cell (single-thread
interior mutability) or advances a non-atomic index, or the VM's frame/coroutine machinery has a
race. Under PYTHON_GIL=0:
RefCellconcurrent borrow →BorrowMutErrorpanic. 0001 (_thread._localLOCAL_GUARDS), 0019 (contextvarsRefCell<Hamt>). These are the same class —RefCellused for a thread-shareable Python object. 0001 also has a re-entrancy flavour (a__del__re-enters theRefCellwhile a borrow is held), which is the one CPython-corpus shape worth hand-porting.- TOCTOU on shared state. 0020 (
collection_repr: non-empty check thennext().expect(), raced by a concurrentclear), 0022 (itertools.cycle:saved[index.fetch_add(1)]with a separate non-atomic reset → OOB when two threads race past the wrap). - VM machinery race. 0023 (concurrent generator resume underflows the frame value stack →
frame.rs:10092 fatal "tried to pop from empty stack"→ SIGSEGV). TheCororunningCAS guards simultaneous resume butsend/send_nonereadframe.lasti()before it; the exact window is a sub-1% race (measured 3/500 on the vehicle, 0/1400 on synthetics — see §6). Static signature.RefCell<_>/Cell<_>/AtomicCell<_>fields on a#[pyclass]type that isSend + Sync(i.e., not#[pyclass(unsendable)]) — those objects can be shared, so a!Synccell is a latent double-borrow. Also:fetch_add/load+storesequences that implement a read-modify-write in separate atomic ops (non-atomic RMW → race). Also: a non-empty/emptiness check followed by an.expect()/[0]on the same shared container (TOCTOU). CPython contrast. Thread-safe (GIL, or FT-guarded); raisesRuntimeError("changed size during iteration") /ValueError("generator already executing") at worst. Fix pattern.Mutex/RwLock/atomic Hamt instead ofRefCell; single-atomic RMW ((i+1)%lenvia CAS); re-check after the borrow; recompute rather than trust a prior length. And: never run Python (Drop→__del__) while holding aRefCellborrow (the 0001 re-entrancy rule).
Mechanism. A native function takes Vec<PyObjectRef>/ArgIterable<T>/Vec<PyStrRef> and
collects the whole Python argument up front with no length/type check. CPython, at the same
position, requires a concrete sized container and raises TypeError/ValueError before consuming
it. So an infinite/huge iterable (itertools.count(), a generator, a lying __getitem__) makes
RustPython balloon (~0.2–1 GiB/s) → OOM abort, where CPython rejects in O(1). No concurrency
needed; a single direct call does it.
Static signature. A #[pyfunction]/#[pymethod]/Constructor argument typed Vec<PyObjectRef>
/ ArgIterable<_> / Vec<PyRef<_>> that is .collect()ed / iterated before a length or
element-type validation — cross-referenced against CPython's signature for that call (does CPython
require a list/tuple/sequence there?). Note the negative set is large and important:
*args varargs (bounded by the finite call), lazily-wrapped Vec<PyIter> (zip/map), and
Either<PyListRef, PyTupleRef> (already rejects iterators) are safe — see
notes/unbounded-eager-collect-parity-class.md for the full SAFE list.
CPython contrast. TypeError: … must be a list/sequence / ValueError before consuming.
Findings. 0012 suggestions.rs:11 (_suggestions._generate_suggestions(count(), "x")),
0013 lzma.rs:340 (filters=), 0014 exception_group.rs (ExceptionGroup("m", count())),
0015 _ctypes/array.rs:977 + function.rs (slice-assign / argtypes; also Class H), 0016
posix.rs (posix_spawn argv / setgroups / setsigdef).
Fix pattern. Take a concrete container type (PyListRef/Either<PyListRef,PyTupleRef>) or
validate length/type before collecting. (Distinct from Class J — here CPython rejects, not just
"catchably runs out of memory".)
Mechanism. ctypes is the one place RustPython is inherently memory-unsafe (it calls real C via
libffi). Two sub-bugs found: 0017 — simple-type constructors .expect("int too large") on huge
ints (Class A panic; CPython masks to width); 0024 — the no-argtypes argument converter
(conv_param, _ctypes/function.rs:182) has a float → f64 branch CPython has no equivalent of,
and the FFI dispatch then treats it as a pointer → SIGSEGV, where CPython raises ArgumentError
("Don't know how to convert parameter 1"). (ctypes.CDLL("libc.so.6").strlen(1.5).)
Static signature. In _ctypes/*.rs: argument-conversion branches (conv_param,
convert_to_pointer) that accept a Python type CPython's converter rejects; .expect()/.unwrap()
on int→C-width conversions; eager .collect() of argtypes/array-slice (Class G). Compare against
CPython Modules/_ctypes conversion rules.
CPython contrast. ArgumentError / width-masking / bounds-check.
Fix pattern. Match CPython's converter acceptance set (drop the float branch; raise "Don't know
how to convert"); mask instead of .expect().
Caveat. ctypes crashes need triage: passing a small int as a pointer segfaults in both
interpreters (generic "int as pointer") — that's not a bug. Only a divergence (CPython raises,
RustPython crashes) counts. This is a recurring toolkit gotcha (§6).
Mechanism. A native error message formats a Python object with {:?} (Rust Debug) instead of
the Python repr. Two harms: (1) the message is a multi-KB dump of the object's internal Rust struct;
(2) it can reach the unsound PyAtomicRef Debug (Class C) → SIGSEGV. RUSTPY-0018's trigger is
_asyncio._enter_task's format!("Cannot enter into task {:?} …", task, current_task).
Static signature. format!/write!/vm.new_*_error(format!(...)) where a {:?} (or {:#?})
formats a PyObjectRef/PyRef<_>/Py<_>. Grep-able; high-precision. (Also flag {:?} on any type
that transitively contains a PyAtomicRef.)
CPython contrast. %R (Python repr) — bounded and safe.
Fix pattern. obj.repr(vm)? in the message.
Mechanism. Every eager iterable consumer balloons on an infinite iterable in both
interpreters; RustPython aborts (handle_alloc_error) where CPython raises a catchable
MemoryError. Not a parity gap (CPython also collects). KNOWN OPEN upstream: RustPython #3493
("MemoryError/fallible allocations"), #1779. It persists because it's pervasive (needs
try_reserve/fallible allocation threaded everywhere), not overlooked.
Toolkit stance. Do not report instances as findings. A scanner may tag allocation-heavy
sites for the #3493 effort, but every sched/multiprocessing/statistics OOM-abort we saw is this
class. (28 of fleet-09's 49 segv-bucket dirs were exactly this.)
The toolkit should be a panel of static-analysis agents (mirroring cpython-review-toolkit's
structure — a scanner script + an LLM triage agent per dimension), but keyed to the Rust/RustPython
idioms above. Below: the agents to build, in priority order, each with its scan target, triage
heuristics, and the finding(s) that validate it.
Analogous to cpython-review-toolkit's include-graph-mapper. It must teach the other agents
RustPython's macro object model, because Python-name attribution is non-trivial:
#[pymodule] mod foo→ modulefoo;#[pyclass] impl/struct→ a type;#[pyfunction]/#[pymethod]/#[pygetset]/#[pyslot]/#[pystaticmethod]/#[pyclassmethod]→ exposed callables. The Python name is the attr'sname = "..."override, else the Rust ident;#[pymethod(magic)]→__ident__.#[pyclass(with(Trait, …))]wires protocol impls (Representable,AsMapping,AsSequence,AsNumber,Iterable,Constructor,Hashable,Comparable, …) — these are Python-reachable via the protocol (repr/subscript/iter/hash/…) even though they aren't#[pymethod]s. 0008 and 0009 live here — a toolkit that only looks at#[pymethod]misses them. The name→trait mapping is defined incrates/derive-impl/src/{pymodule,pyclass,util}.rs; pre-index allimpl Trait for Xblocks once so aprotocolsite resolves toX's Python protocol.- Reachability tiers (from
unwrap_scan):py(direct attr) >protocol(slot) >internal(transitive helper). Triage priority follows this order;internalis real but "one call away". - What NOT to build: no refcount-auditor, no GIL-lock-pairing-auditor, no NULL-deref-in-internals
auditor.
PyRc = Arc+ safe Rust make those empty. (Confirmed: 0/138 CPython crashers transfer.)
5.1 panic-site-auditor — the flagship (Classes A + B; validates 0002–0006, 0009–0011, 0017, 0020, 0021)
This is the single highest-value agent. Industrialize unwrap_scan:
- Scan:
.unwrap(),.expect(,panic!,unreachable!,unimplemented!,todo!, and all index expressions (x[i], especiallyargs.args[N]and anyi - 1index) inpy/protocolfunctions ofcrates/vm/src/stdlib,crates/stdlib/src,crates/vm/src/builtins,crates/vm/src/types(and_ctypes). - Triage (the LLM step): the 973-site raw surface is mostly noise —
.unwrap()is idiomatic on statically-known invariants. The signal is Python-reachability of theErr/None/OOB case. Rank a site UP when the fallible value derives from: a Python argument, a.repr(vm)/.str(vm)/vm.call_methodresult, adowncast, a user-registered map.get, an int→width conversion, awarn(...), or an index whose bound is a Python-controlled length. Rank DOWN when it's on a compile-time constant, a just-inserted map key, or a value the function itself just validated. - Differential confirm: for each high-ranked site, synthesize the Python call that reaches it and diff CPython (§5.9).
- Yield estimate: 159
py+ 126protocolsites; the campaign converted ~12 of these to findings by fuzzing. A guided static+differential pass should convert many more.
- Scan: every
unsafeblock incrates/vm/src/object/,crates/vm/src/builtins/, and anywhere touchingPy<T>/PyObject/PyAtomicRef/payload. Flag pointer.cast::<_>(),transmute,as *const/*mut, andunsafe impl Debug. - Triage: the killer heuristic is cross-method inconsistency — when one method interprets a
stored pointer as
Py<T>and a sibling interprets it asT(0018 exactly). Also: anyDebugthat isunsafe; any pointer cast whose target type differs from the type theFrom/constructor stored. - Note: this class is small but each instance is a true SIGSEGV. Worth a deep, precise pass.
- Scan:
RefCell<_>/Cell<_>fields on#[pyclass]types that are NOTunsendable(shareable objects with a!Synccell); non-atomic read-modify-write via separate atomic ops (fetch_addthen a conditionalstore); emptiness/length checks followed by.expect()/[0]/next()on the same shared container; andDrop/__del__-reachable code that runs while aRefCellborrow is held (0001). - Triage: "can two threads reach this object's mutable state at once?" For a
#[pyclass]that isn'tunsendable/frozen: yes. Rank theRefCellfields highest (0001/0019 are guaranteed BorrowMutErrors under contention). Non-atomic RMW and TOCTOU need the shared-across-threads argument. - Confirm: these need a concurrency differential (spawn threads), not a single-shot diff.
- Scan:
#[pyfunction]/#[pymethod]/Constructorparams typedVec<PyObjectRef>/ArgIterable<_>/Vec<PyRef<_>>that are collected/iterated before validation. - Triage: this agent must consult CPython's signature for the same call — the bug only exists
where CPython requires a sized container at that position. Maintain (or fetch) CPython's argument
spec. Filter the large SAFE set (varargs, lazy
Vec<PyIter>,Either<PyListRef,PyTupleRef>). - Confirm: differential with an infinite iterable under a memory cap (
ulimit -v): RustPython aborts, CPython raises fast.
- Scan: protocol impls for
__hash__/__eq__/__repr__/__reduce__/genericalias parameter walks that recurse into contained objects without avm.with_recursion-style guard. - Triage: compare against the paths that do guard (json/AST already fixed); flag the unguarded hash/compare/genericalias descents. Known umbrella #2796 — still worth enumerating the remaining sites precisely.
- Scan: types without an
impl Constructorthat forbids__new__(DISALLOW_INSTANTIATION) whose protocol slots (AsMapping/AsSequence/AsNumber/iterator) readzelfpayload/fields without the guard the type's#[pymethod]s use. - Triage: the vulnerable shape is "slot touches payload,
__new__allowed, no re-check". Enumerate native types and diff their slot bodies against their method bodies for the missing guard.
- Scan:
format!/write!/vm.new_*_error(format!(...))with a{:?}/{:#?}whose argument is aPyObjectRef/PyRef<_>/Py<_>(or a struct transitively containing aPyAtomicRef). - Triage: nearly all true positives — user-facing messages should use
repr, notDebug. Grep_asyncio.rs,typevar.rs,os.rsfirst (known instances:_asyncio.rs:2492,typevar.rs:933/996,os.rs:946/1069). Low effort, immediate value.
- Scan:
_ctypes/*.rsargument converters (conv_param,convert_to_pointer), int→C-width.expect()/.unwrap(), andargtypes/array-slice eager collects. - Triage: diff the acceptance set against CPython's
Modules/_ctypesconverter (0024 = a float branch CPython lacks). Apply the small-int-as-pointer filter (crashes both → not a finding).
The one piece that turns "a panic exists" into "a bug exists". For a candidate site, synthesize the
Python that reaches it and run it under both RustPython and CPython 3.14+, classifying:
RustPython crash + CPython raises/works = finding; both crash = not a finding (e.g.
int-as-pointer, huge-int OOM); both work = false positive. This is exactly the campaign's
verification step and should be a reusable harness (guarded: ulimit -v, timeout, setarch -R,
PYTHON_GIL=0 for FT/concurrency checks — see §6). It is what elevates the panic-site agent from "285
suspicious lines" to "N confirmed divergences".
- The huge
internal-tier.unwrap()surface where theErrcase is a real invariant. - abort-vs-
MemoryError(Class J) — architectural, known #3493/#1779. both crashdifferentials (int-as-pointer, huge-int → OOM in both).- Subinterpreter-specific issues (out of scope by policy, cf. CPython #143232 for the FT-subinterp line).
- Anything already fixed after
a9c2c529b— pin the analyzed checkout and record provenance (asunwrap_scandoes in its TSV header).
These are the traps that cost time; the toolkit should encode them.
- The
rustpySEGVbucket needs a gdb-resolve pass — segfaults have no dedup signature. The two highest-value findings (0018, 0024) hid in an undifferentiated segv bucket because panic-site dedup can't see inside a segfault. A static toolkit sidesteps this (it reads source, not stdout), but any dynamic confirmation step must gdb-resolve segvs to a frame, not lump them. - Single-thread-reduce concurrency crashes before assuming it's a race. 0024 surfaced only in the
--concurrency-stressfleet, but the root cause is single-threaded (afloatarg). Always try to strip concurrency; many "concurrency" crashes have a simpler core. - Small-int-as-pointer segfaults in both interpreters — not a bug. ctypes lets you pass an int as a pointer everywhere; only a divergence counts. Build the both-crash filter into the ctypes/FFI oracle.
{:?}Debug is a landmine because ofPyAtomicRef. A "harmless" debug-format in an error message reached an unsoundDebugimpl and segfaulted (0018). Treat{:?}-on-PyObject as potentially fatal, not cosmetic.- Beware generic fatal sinks when deduping.
frame.rs:10092(ExecutingFrame::fatal, "pop from empty stack") is where many unrelated corruptions funnel; we deliberately excluded it from the dedup catalog to avoid over-matching. Static analysis should likewise treat such sinks as symptoms, not root-cause identities. - Rare races: measure a baseline, don't over-invest. 0023 reproduces 3/500 (~0.6%) on its exact vehicle and 0/1400 across seven synthetic shapes. Some VM-machinery races are real but sub-1% and have no minimal repro; record the measured rate and move on rather than chasing a synthetic.
- CPython's crash corpus is worthless as a seed (0/138). Don't design the toolkit around CPython's
crash patterns. The one shape worth hand-porting is FT re-entrancy (
__del__re-entering thread-locals → the 0001RefCellclass). - Memory balloons need a hard cap. RustPython's
Arcmodel plus runaway threads plus the Class G eager-collects make OOM-abort the loudest (and least interesting) signal. The fuzzer needed a realRLIMIT_AScap to keep the balloon from filling swap; a differential harness needsulimit -vtoo. - Dedup catalog staleness re-flags known bugs as NEW. Fleet 09 re-flagged 0019/0020 as
rustpyNEWbecause it ran an older snapshot. If the toolkit keeps a "known findings" list, version it against the analyzed checkout. - Free-threading env matters. Concurrency findings need
PYTHON_GIL=0; the FT build also wantssetarch -R(disable ASLR for stability) and unlimitedRLIMIT_ASlifted only under a watchdog. - Name attribution is the hard part of static analysis here. A risky line's owning Python name
lives behind the
derive-implmacro system (module/class/method attrs,with(Trait)protocol wiring,name=overrides,magic→ dunder). Get this right first (§5.0) or every agent mis-attributes.
| id | class | kind | site | one-line reproducer | CPython |
|---|---|---|---|---|---|
| 0001 | F | panic | _thread.rs:977 |
global _local + stored value whose __del__ re-enters _local, across threads |
thread-safe |
| 0002 | B | panic | structseq.rs:311 |
import pwd; pwd.struct_passwd().pw_name |
TypeError |
| 0003 | A | panic | class.rs:87 |
import _md5; _md5.md5() |
works (#5210) |
| 0004 | A | panic | csv.rs:805 |
import _csv; _csv.reader([]).__next__() |
works |
| 0005 | B | panic | _typing.rs:43 |
import _typing; _typing._idfunc() |
TypeError |
| 0006 | A | panic | builtins.rs:557 |
eval(chr(0xd800)) |
UnicodeEncodeError |
| 0007a | D | segv | recursion (hash/eq/repr) | deep/cyclic object hashed/compared | RecursionError (#2796) |
| 0008 | E | segv | _sre Match::as_mapping |
M=type(re.match('a','a')); M.__new__(M)[0] |
safe |
| 0009 | A | panic | staticmethod.rs:182 |
repr(staticmethod(o)) where o.__repr__ raises |
propagates |
| 0010 | B | panic | binascii.rs:507 |
import binascii; binascii.b2a_qp(b'\n') |
returns b'\n' |
| 0011 | A | panic | classmethod.rs:198 |
repr(classmethod(o)) where o.__repr__ raises |
propagates |
| 0012 | G | abort | suggestions.rs:11 |
_suggestions._generate_suggestions(itertools.count(),'x') |
TypeError |
| 0013 | G | abort | lzma.rs:340 |
lzma.LZMACompressor(FORMAT_RAW, filters=(… for _ in count())) |
TypeError |
| 0014 | G | abort | exception_group.rs |
ExceptionGroup('m', itertools.count()) |
TypeError |
| 0015 | G/H | abort | _ctypes/array.rs:977 |
a=(ctypes.c_int*3)(); a[0:3]=itertools.count() |
ValueError |
| 0016 | G | abort | posix.rs:1403 |
os.posix_spawn('/bin/true', ('/bin/true' for _ in count()), os.environ) |
TypeError |
| 0017 | A/H | panic | _ctypes/simple.rs:908 |
import ctypes; ctypes.c_char_p(2**64) |
masks to width |
| 0018 | C/I | segv | ext.rs:272 (via _asyncio.rs:2492) |
import _asyncio\ndef f():pass\n_asyncio._enter_task(0,f)\n_asyncio._enter_task(0,f) |
RuntimeError |
| 0019 | F | panic | contextvars.rs:82 |
shared contextvars.Context/ContextVar across threads |
thread-safe |
| 0020 | A/F | panic | utils.rs:61 |
concurrently clear() + repr() a shared set/dict |
RuntimeError |
| 0021 | A | panic | sys.rs:874 |
PYTHONBREAKPOINT=nonexistent.foo + warnings.simplefilter('error'); sys.breakpointhook() |
catchable RuntimeWarning |
| 0022 | B/F | panic | itertools.rs:282 |
c=itertools.cycle([0]); next(c); 8 threads next(c) |
never crashes |
| 0023 | F | segv | frame.rs:10092 |
concurrent next() on a shared generator (rare, ~0.6%) |
ValueError |
| 0024 | H | segv | _ctypes/function.rs:182 |
import ctypes; ctypes.CDLL('libc.so.6').strlen(1.5) |
ArgumentError |
- 108 Rust-implemented modules (
rust_modules.txt) — the crash-rich targeting surface. - 973 risky panic-family lines (
risky_sites.tsv), by reachability: 159py(direct attr), 126protocol(slot), 686internal(transitive), 1 other. - By pattern: 686
.unwrap(), ~114.expect(, plusunreachable!/unimplemented!/todo!/panic!/.args[. Thepy+protocoltiers (285 sites) are the primary triage queue for §5.1. - Limitation to fix in the toolkit:
unwrap_scanv1 does not re-map aprotocoltrait method to the owning class's Python protocol name across files, nor follow calls from an exposed fn into arbitrary helpers. The toolkit's mapper (§5.0) should close both.
Known/open upstream: 0003 = #5210, 0007a = #2796 (umbrella), Class J = #3493/#1779. 0001
is a distinct, unfixed variant of the #7813/#7965 thread-teardown family. 0004 is in the csv rework
area (#8310). The rest appear unreported. (Full table: notes/prior-art-rustpython-tracker.md.)
- Findings:
reports/RUSTPY-00NN-*/(report.md+repro.py+meta.json). - Catalog:
catalog/known_panics.tsv(regenscripts/gen_known_panics.py). - Per-fleet narrative:
INDEX.md. Class notes:notes/. - Static tool:
tools/unwrap_scan/(README.md,rust_modules.txt,risky_sites.tsv). - Fuzzer + dedup engine (sibling repo
fusil):fusil/python/rustpython_dedup.py, the--rustpythonplugin (fusil_rustpython_plugin), modes infusil/python/__init__.py/write_python_code.py::_write_tsan_stress_region.
Compiled from the fusil–RustPython campaign (fleets 01–09, findings RUSTPY-0001..0024). The most
actionable single takeaway for rustpy-review-toolkit: build §5.1 (panic-site-auditor) +
§5.9 (cpython-parity-differential) first — together they cover more than half the catalogue — then
add §5.2 (unsafe-soundness) and §5.3 (thread-safety) for the memory-unsafety long tail.